An occurrence is a node, with a clock of its own

A lane's drawings were going to be one instance whose source was a KEYED
channel: frame 0 says `:drawing-a`, frame 4 says `:drawing-b`, and the cels of
a row are that channel's keys. Two things followed from it, and both were
wrong.

The first is that playback meant whichever shape the channel happened to have.
A framed source played its symbol; a keyed source froze the selected frame.
So `node/placed-at` read animation out of storage, and adding an ordinary key
to a still turned it into an animation — the last-key bug, which was not a bug
in the code so much as the rule working as written. But WHICH drawing is used
and HOW time runs inside it are independent questions, and all four combinations
are ordinary: hold one drawing, play one animation, cut between held drawings,
cut between playing ones.

So an occurrence names one symbol in `:source {:symbol ...}` and says how its
source time advances in `:playback {:in :speed :end}` — `source = in + speed *
f`, a hold being speed 0, with `:stop`, `:hold` or `:loop` at the end named
rather than guessed. `node/placed-frame` samples it forwards, which works for
holds too, and `node/source-time` is the separate, invertible edit map, nil
where inversion is meaningless. The two were one function before, and a hold
had to lie about one of them.

The second is that a keyed source only looked necessary because an occurrence
was assumed to need a ROW. It does not. A lane is a group with `:layout
:sequence`, its occurrences are ordinary instances in the same flat node map,
and `timeline/rows` draws them as cel blocks on the lane's own row: twelve
exposures, one row, each cel still separately selectable and addressable. The
vertical growth that justified the keyed source is a presentation question, and
it is answered in the view.

`arthur.domain.sequence` holds the first commands over that shape — add lane,
append drawing, extend hold — each one history step, each refusing rather than
half-applying. Extending a hold leaves the lane's keys at their authored times,
because you are adjusting drawings underneath timed motion; a correction owned
by an occurrence travels with it. Ownership does that work, so no key needs a
flag saying what it follows. Ripple past the symbol's end is refused with the
frame count it would need, and `:extent :grow-symbol` is the caller saying yes.

`clip/blank` no longer carries `:subjects {} :features {} :groups {}`. Empty
maps write no leaf, so a blank document could not survive its own round trip —
`leaf/leaves` promises exactness and was the only honest side of that.

Documents are schema 3. A version 2 document is not read; nothing here converts
one. `docs/lane-model.md` is the design, and says which of its parts are built.

392 tests, 5,525 assertions, and `test/browser/sequence.mjs` drives the editor
through create, hold, explicit overflow and undo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-30 15:20:23 -04:00
parent 624242b407
commit 3d3c1bbca0
38 changed files with 1564 additions and 213 deletions

View 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),
),
]

View file

@ -229,7 +229,7 @@ class Project(models.Model):
settings.AUTH_USER_MODEL, blank=True, related_name="shared_projects",
)
name = models.CharField(max_length=200, default="untitled")
schema_version = models.PositiveIntegerField(default=2)
schema_version = models.PositiveIntegerField(default=3)
seq = models.PositiveBigIntegerField(default=0)
palette = models.CharField(max_length=64, default="arthur/default")
created = models.DateTimeField(auto_now_add=True)

View file

@ -422,7 +422,7 @@ class DocumentTests(TestCase):
self.assertEqual(5, len(response.json()["written"]))
loaded = self.client.get(f"/api/projects/{self.project.id}").json()
self.assertEqual(2, loaded["schema_version"])
self.assertEqual(3, loaded["schema_version"])
self.assertEqual(1, len(loaded["clips"]))
clip = loaded["clips"][0]
self.assertEqual("c1", clip["cid"])

View file

@ -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.
@ -491,7 +496,9 @@ for all three is the same — **their own**:
### Instances
A node with `:kind :instance` 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.

View file

@ -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.

484
docs/lane-model.md Normal file
View file

@ -0,0 +1,484 @@
# The Lane Model
Revised 2026-09-30. Target design. Occurrence ownership, source playback, the
first exposure commands and a one-row cel strip are implemented; correction
layers, the other commands and the remaining views are not. See the status note
under [Proof obligations](#proof-obligations-and-implementation-order).
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 exposures, 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, occurrences, 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 |
| Occurrence | One use of content, its interval, source playback, and local treatment | `a2` exposed on frames 12–16 |
| Lane | A sequence of occurrences and shared properties | The girl's drawings and the girl's overall transform |
| View row or column | Presentation and editor state | Timeline row, exposure-sheet column, or property curve |
Use the existing instance/node identity mechanism for occurrences. An exposure
should not acquire a second identity system just because it is shown as a cel.
Lanes group occurrences; 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 occurrence has a stable ID. Moving it, changing its hold, swapping its source,
or trimming it preserves that ID. Repeating it creates a new occurrence 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.
Occurrences 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 occurrence objects.
A sequence lane has non-overlapping half-open occurrence 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 occurrence, or the lane. For example:
- Rotate the reusable drawing: all its uses change.
- Rotate one occurrence: only that exposure changes.
- Animate the lane's rotation: whichever drawing is showing follows it.
An occurrence 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 sequence group and ordinary instances
A lane is a group node with `:layout :sequence`. Its occurrences 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; occurrence 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}}}}
:exposure-a
{:id :exposure-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}}
:exposure-b
{:id :exposure-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 occurrence's channels read occurrence
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
occurrence 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 sequence group may contain visual occurrences or audio occurrences; its
capability must reject an incompatible mixture rather than infer it per frame.
A source reference is fixed within an occurrence. The lane changes content when
another occurrence 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.
An occurrence names content and describes how its source time is sampled. In the
basic case, after mapping lane time into occurrence time:
```text
source_time = in_point + speed × occurrence_time
```
A newly created occurrence 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 occurrences, 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 occurrence 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 occurrences, 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, occurrence, 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 occurrences to one source time. APIs must distinguish forward sampling
from inverse editing, and expose enough context to resolve an occurrence 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 occurrence'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 exposure 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.
An occurrence interval is authored. Lane content extent is derived from its
occurrences, 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 exposure extension.
An edit target identifies the symbol, occurrence 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 occurrence.
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 exposure, 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 an occurrence. Blank range removes
content coverage without inventing a hidden drawing. These are different actions.
Reuse creates another occurrence pointing at existing content. Duplicate creates
a new content identity. Make unique rebinds the selected occurrence 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: exposure edits preserve lane keys
Working default from the follow-up discussion: extending a drawing's hold changes
exposure 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 an occurrence
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 `:exposure-a` by two lane frames with ripple:
| Fact | Before | After |
| --- | --- | --- |
| Exposure A's lane interval | `[0,4)` | `[0,6)` |
| Exposure 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 exposures underneath timed motion.
The position correction stays attached to drawing B's occurrence. Neither the
background's keys nor audio on another lane moves.
The command contract for this edit names the symbol and occurrence, a delta in
lane frames, `:ripple` behavior, and an explicit scope of exposure timing. It
extends A's local support by the delta converted through A's placement rate,
and shifts subsequent occurrence 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 occurrences 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 occurrences
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 an exposure ripple.
The initial UI should default stage transforms to the lane when drawing in a cel
workflow, so movement usually remains independent of exposure timing. The
inspector names the target: lane motion, this occurrence, 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 occurrences. 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 occurrence 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 controls next to the breadcrumb act in that explicit location. Selection
does not secretly change where a new symbol goes. A shared drawing indicates its
reuse and offers Make this occurrence 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/occurrence/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 exposure ghosted behind it.
2. Use Duplicate drawing (`D`) when the current shapes are the starting point.
Use Reuse drawing for a deliberately linked exposure. The UI shows the
difference before an edit can change other uses.
3. Time the performance. Hold longer (`H`) extends the selected occurrence and
ripples later occurrences 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 exposure for newly created drawings, or run a
separate Retime exposures command on a selected range. This does not quantize
lane transforms or silently retime already authored exposures.
5. Place the background in a lane below. Its source holds one frame throughout
its occurrence. 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
occurrence's source playback to advance. No lane conversion is required.
The timeline shows named exposure blocks with property marks and optional curve
subrows. The exposure sheet shows the same occurrences by frame and lane. The
graph editor edits the same properties; the stage resolves the same document.
Onion skin is configurable and counts neighboring exposure 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: an occurrence names one symbol
and carries its own playback clock, and `node/problems` rejects the old
`[:source]` channel. `arthur.domain.sequence` holds lane validation and the
first three commands — add lane, append drawing, extend hold with explicit
ripple and shot-length policy — each one history step. The timeline draws a
lane's occurrences as cel blocks on the lane's own row.
Correction layers, the remaining commands (reuse, duplicate, make unique, blank,
split, trim, slip, retime) and the exposure-sheet view are not implemented; a
refusal is the current behavior where the model demands an explicit choice
nobody has made yet. The suite stands at 392 tests and 5,525 assertions, with
`frontend/test/browser/sequence.mjs` driving the editor through create, hold,
overflow and undo. 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 occurrences, final hold, split, trim, ripple, and overwrite | Exact boundaries, stable IDs, deterministic collision handling |
| Extend a hold under lane keys and occurrence-local corrections | Lane key times remain fixed; later occurrence 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 occurrences | 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 exposure-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: occurrence ownership and playback semantics; shared
commands and validation; correction layers and time-addressing contracts; then
breadcrumb, cel strip, and an exposure-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.

View file

@ -11,8 +11,10 @@ and persistence. No new kind of scene container is needed.
:symbols
{:main {:nodes {:root {:time {:mode :map :expose 2}}
:face {:parent :root :channels <source-to-stage placement>}
:face-1 {:kind :instance :of :face-1 :parent :face :z "a0"}
:face-2 {:kind :instance :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 ...} ...}}}

View file

@ -1,5 +1,9 @@
# Timing model
[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.

View file

@ -87,12 +87,16 @@
(.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))

View file

@ -29,8 +29,10 @@
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)
@ -49,12 +51,13 @@
{:root {:id :root :name "stage" :kind :group :z "a1"}}
(map (fn [{:keys [uuid name z span at center anchor drift phase]}]
(let [anchor (or anchor default-anchor)]
[uuid {:id uuid :name name :kind :instance :of symbol
[uuid {:id uuid :name name :kind :instance
:parent :root :z z :span span
:time {:mode :map :at at :rate 1}
:source {:symbol symbol}
:channels {[:xform :pos] (if drift
(position-track center anchor drift phase frames)
(ch/framed (mapv - center anchor)))
(position-track center anchor drift phase frames)
(ch/framed (mapv - center anchor)))
[:xform :anchor] {:animated? false :value anchor}
[:xform :scale] scale}}]))
instances))

View file

@ -11,6 +11,7 @@
so the events that fetch them are only fetching."
(:refer-clojure :exclude [take])
(:require [arthur.domain.clip :as clip]
[arthur.domain.node :as node]
[clojure.string :as string]))
(defn symbols
@ -19,8 +20,8 @@
`other` to its id here.
AN ID THAT IS TAKEN IS RENAMED, never merged: two symbols that happen to share
an id are two drawings, and an instance's `:of` inside the copy is rewritten to
follow. `wanted` maps a root's id in `other` to the id it should preferably get,
an id are two drawings, and an occurrence'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`.
@ -35,12 +36,16 @@
(some #{%} (vals ids)))]
(assoc ids sid (clip/free-id taken? (get wanted sid sid)))))
{} (sort-by str reach))
;; Occurrence 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))
(update :nodes #(into {} (map (fn [[id n]]
[id (cond-> n (:of n) (update :of ids))]))
%))))]
(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}))

View file

@ -38,7 +38,8 @@
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 symbol has its own."
(:refer-clojure :exclude [symbol])
(:require [arthur.domain.feature :as feature]
(:require [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]
@ -84,8 +85,7 @@
(defn places
"The ids of the symbols `sid` places, directly."
[clip sid]
(into #{} (keep (fn [n] (when (= :instance (:kind n)) (:of n))))
(vals (:nodes (symbol clip sid)))))
(into #{} (mapcat node/sources) (vals (:nodes (symbol clip sid)))))
(defn contains-symbol?
"Whether `inner` is `outer` or is placed anywhere inside it. Placing `outer`
@ -124,14 +124,18 @@
"A new, empty document: one empty symbol.
`:nodes` is empty rather than seeded with a layer, because an empty symbol is
a true statement and a layer nobody asked for is one more thing to delete. The
tracking maps are present and empty for the same reason `clip-keys` exists: a
field that is sometimes absent is a field every reader needs a fallback for."
a true statement and a layer nobody asked for is one more thing to delete.
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
:subjects {} :features {} :groups {}
:symbols {:main {:id :main :frames blank-frames :nodes {}}}})
(defn- transform-op
@ -186,32 +190,37 @@
own (symbol/resolver sym store palette
(assoc opts :pose-tracks pose-tracks
:source-fps (:fps clip)))
;; Each occurrence owns its source resolver and mutable buffers.
children (into {}
(for [[id n] nodes :when (= :instance (:kind n))]
[id (build (:of n) (conj chain sid)
(get-in n [:playback :tracks]))]))
;; The instances that were on the last frame. Their resolvers
;; still hold the frame before whenever they were not.
entered (volatile! #{})
(for [[id n] nodes
:when (= :instance (:kind n))
child (sort-by str (node/sources n))]
[[id child] (build child (conj chain sid)
(get-in n [:playback :tracks]))]))
;; 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! {})
step (fn [f]
(vreset! entered #{})
(vreset! entered {})
(let [by-id (into {} (map (juxt :node identity)) (own f))]
(into []
(mapcat
(fn [id]
(let [n (get nodes id)]
(if (= :instance (:kind n))
(let [m (symbol/world-of own id)
(let [m (symbol/world-of own id)
local (symbol/frame-of own id)
target (symbol clip (:of n))
length (:frames target)
frame (when (and m (number? local))
(if (get-in n [:time :loop?])
(mod local length)
local))]
length (frames clip (node/source n))
shown (when (and m (number? local))
(node/placed-frame n local length))
frame (:frame shown)]
(if (and frame (<= 0 frame) (< frame length))
(do (vswap! entered conj id)
(map #(transform-op % m [id]) ((get children id) frame)))
(do (vswap! entered assoc id (:symbol shown))
(map #(transform-op % m [id])
((get children [id (:symbol shown)]) frame)))
[]))
(when-let [op (get by-id id)] [op]))))
ids))))]
@ -222,13 +231,14 @@
(world-of [_ [id & more]]
(if more
(when-let [w (and (contains? @entered id)
(symbol/world-of (get children id) (vec more)))]
(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) (vec more)))
(symbol/frame-of (get children [id (get @entered id)]) (vec more)))
(symbol/frame-of own id))))))]
(build sid [] nil)))
@ -296,13 +306,14 @@
{:id uuid
:name (symbol-name clip sid)
:kind :instance
:of sid
:parent nil
;; Lexicographic draw order, as `domain/paint` does it: a placement made
;; later sits above one made earlier, and neither has to renumber.
:z (str "z" (js/Date.now) "-" (name sid))
:span [0 (:frames target)]
:time {:mode :map :at frame :rate 1}
:source {:symbol sid}
:playback {:in 0 :speed 1 :end :stop}
:channels {[:xform :pos] {:animated? false
:value (if point (mapv - point middle) [0 0])}
[:xform :anchor] {:animated? false :value middle}}})))))
@ -374,22 +385,23 @@
(str "symbol " (pr-str id) ": " p))
(for [[sid sym] (:symbols clip)
[id n] (:nodes sym)
:when (and (= :instance (:kind n))
(not (contains? (:symbols clip) (:of n))))]
: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 (:of n))))
" names missing symbol " (pr-str missing)))
;; Pose tracks belong to this occurrence's single source symbol.
(for [[sid sym] (:symbols clip)
[id n] (:nodes sym)
:when (= :instance (:kind n))
:let [target (get-in clip [:symbols (:of 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)]
(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)

View file

@ -61,13 +61,28 @@
chain (map #(get nodes %) (rseq (symbol/lineage nodes id)))
m (symbol/world-of r id)
local (symbol/frame-of r id)
inner (get-in nodes [id :of])]
inst? (= :instance (:kind (get nodes id)))
;; WHICH symbol, and which frame of it, are both read off the
;; occurrence: 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)
(node/placed-frame (get nodes id) local
(clip/frames clip (node/source (get nodes id)))))
inner (:symbol shown)
lf (if shown (:frame shown) local)]
(if (and m (number? local)
(or (nil? inner) (< -1 local (clip/frames clip inner))))
{:sid inner :frame (js/Math.floor local)
;; A lane 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)
:time (when (and time (not-any? #(get-in % [:time :loop?]) chain))
(reduce node/then-time time (map node/time-of chain)))}
;; 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?) (node/source-time (get nodes id))))
(cond-> (reduce node/then-time time (map node/time-of chain))
inst? (node/then-time (node/source-time (get nodes id)))))}
(reduced nil))))
{:sid sid :frame f :matrix (node/mat) :time {:at 0 :rate 1}}
path))
@ -109,41 +124,68 @@
(partition 2 pts)))))))
(defn audio-tracks
"Every sound symbol `sid` plays, as audio nodes in `sid`'s own frames: its own
and, recursively, those inside the instances it places.
A sound inside a placed symbol is heard where the instance puts it, so each one
is carried OUT through the instance's time map — the same map a timeline row
draws with — and cut to the instance's own span, until it is in the frames of
the symbol being played. Keyed automation moves with it. What comes back is
what a mixer that only knows flat tracks can play as it is."
"Flatten audible source intervals through occurrence 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."
[clip sid]
(let [sym (clip/symbol clip sid)]
(into (vec (filter #(= :audio (:kind %)) (vals (:nodes sym))))
(mapcat
(fn [inst]
(let [outer (node/time-of inst)
->outer (fn [x] (+ (:at outer) (/ x (:rate outer))))
[in out] (or (:span inst) [0 (clip/frames clip (:of inst))])]
(keep (fn [a]
(let [[p0 p1] (or (node/placed-span a) [in out])
x0 (max p0 in)
x1 (min p1 out)
own (node/time-of a)
->own (fn [x] (* (:rate own) (- x (:at own))))
world (node/then-time outer own)]
(when (< x0 x1)
(-> a
(assoc :span [(->own x0) (->own x1)]
:time {:mode :map :at (:at world) :rate (:rate world)})
(update :channels
(fn [chs]
(into {} (map (fn [[p ch]]
[p (cond-> ch (:keys ch)
(update :keys #(into {} (map (fn [[f v]] [(->outer f) v])) %)))]))
chs)))))))
(audio-tracks clip (:of inst)))))
(filter #(= :instance (:kind %)) (vals (:nodes sym)))))))
(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))]
(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
: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 {:at (- (/ in speed)) :rate speed})
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))))]
(vec (walk sid {:at 0 :rate 1} [0 (clip/frames clip sid)] [] #{}))))
(defn- retime
"Node `n` with its own time map replaced by `m`, and nothing else touched: its
@ -184,7 +226,8 @@
js/Float64Array.from))]
(cond
(= host target) {:refused "it is already there"}
(and (= :instance (:kind n)) (clip/contains-symbol? clip (:of n) target))
(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))))))
@ -254,7 +297,13 @@
there are the same on every frame, so asking needs nothing to be on screen;
only a move that keeps the PICTURE needs a frame, for the matrix."
[clip sid path]
(let [sids (reductions #(get-in clip [:symbols %1 :nodes %2 :of]) sid path)
(let [;; Structurally, a row leads into a symbol only where it names one:
;; an occurrence does, and the lane holding it does not, so the walk
;; stops at a lane 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.
chain (mapcat (fn [sid id]
@ -262,8 +311,12 @@
(map #(get nodes %) (rseq (symbol/lineage nodes id)))))
sids path)]
{:sid (last sids)
:time (when (not-any? #(get-in % [:time :loop?]) chain)
(reduce node/then-time {:at 0 :rate 1} (map node/time-of chain)))}))
:time (when (not-any? #(or (get-in % [:time :loop?])
(and (= :instance (:kind %)) (nil? (node/source-time %)))) chain)
(reduce node/then-time {:at 0 :rate 1}
(mapcat (fn [n] (cond-> [(node/time-of n)]
(= :instance (:kind n)) (conj (node/source-time n))))
chain)))}))
(defn slide
"Move the node at row path `path` along its symbol's time by `df` frames of
@ -343,7 +396,8 @@
:let [n (get nodes (peek from))]]
(or (node/placed-span n)
(when (= :instance (:kind n))
(node/placed-span (assoc n :span [0 (clip/frames clip (:of n))])))
(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)))

View file

@ -175,7 +175,7 @@
move preserves and a timeline row draws with, and `local-frame` is what reads
a frame, floors and the lead in their load-bearing order."
[n]
(let [{:keys [mode at rate offset] :or {at 0 rate 1 offset 0}} (:time n)]
(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})))
@ -203,6 +203,53 @@
(let [{:keys [at rate]} (time-of n)]
[(+ at (/ in rate)) (+ at (/ out rate))])))
;; ---------------------------------------------------------------------------
;; what an instance places
;;
(defn source
"The symbol used by this occurrence. Sequence groups arrange occurrences;
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 occurrences 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 occurrence -> 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 occurrence'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 occurrence
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 sequence? [n]
(and (= :group (:kind n)) (= :sequence (:layout n))))
(defn finite-number? [v] (and (number? v) (js/Number.isFinite v)))
(defn local-frame
"Apply a node's time map to the frame it was handed by its parent.
@ -217,7 +264,7 @@
as two performances; offset is PER-NODE by design, because mouth lead applies
to performance nodes and not to the plate, which is the entire point of it."
[n f]
(let [{:keys [mode source-fps sample-fps] ex :expose :or {mode :inherit}} (:time n)]
(let [{:keys [mode source-fps sample-fps] ex :expose :or {mode :map}} (:time n)]
(if (= mode :inherit)
f
(do
@ -370,15 +417,27 @@
(not (contains? implemented-kinds k)))
(conj (str ":kind " k " is in the vocabulary but not implemented"))
(and (= k :instance) (nil? (:of n))) (conj "an instance needs :of")
(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")
(and (:layout n) (not (sequence? n)))
(conj ":layout :sequence belongs to a group")
(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 (pos? (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")
(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"))

View file

@ -98,13 +98,22 @@
at (fn [f p] (ch/value-at (get (node/channels n) p) f store))]
(case (:kind n)
:instance
(let [sid (:of n)
frames (clip/frames document sid)
loop? (get-in n [:time :loop?])
resolve (clip/resolver document sid store pal/index-of nil)]
(fn [f]
(let [f (if loop? (mod f frames) f)]
(when (< -1 f frames)
;; 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 nil)]))
(node/sources n))]
(fn [f0]
(let [shown (node/placed-frame n f0 (clip/frames document (node/source n)))
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))))

View file

@ -2,7 +2,8 @@
"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."
(:require [arthur.domain.node :as node]))
(defn prepare
"Sort exposure tracks once when building a resolver."
@ -35,14 +36,17 @@
"Set one held pose on an instance inside symbol `sid`. Earlier motion stays
untouched."
[clip sid instance group at source]
(let [node (get-in clip [:symbols sid :nodes instance])
placed (get-in clip [:symbols (:of node)])
length (:frames placed)
(let [inst (get-in clip [:symbols sid :nodes instance])
;; A cut is checked against the ONE symbol this occurrence places. Which
;; drawing a lane shows is a question about the lane's other occurrences,
;; 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 placed)))
groups (set (map #(or (:pose-group %) (:id %)) active))
ids (set (map :id active))]
(when-not (and (= :instance (:kind node))
(when-not (and (= :instance (:kind inst))
(or (contains? groups group)
(and (vector? group) (= 2 (count group))
(= :node (first group))

View file

@ -30,10 +30,9 @@
[arthur.domain.wire :as wire]))
(def schema-version
"The stored document format this client reads and writes. 2 is symbols: leaf
paths say `symbol`, a placing node is `:kind :instance`, and no symbol id is
reserved. `clips/migrations/0007` moved every saved project from 1."
2)
"3 stores occurrence source references and explicit playback clocks. Older
source-channel documents are unsupported; there is no compatibility conversion."
3)
(defn block-keys
"Every tier-2 key a leaf map names, in a stable order."

View file

@ -0,0 +1,122 @@
(ns arthur.domain.sequence
"Sequence groups arrange ordinary occurrences. Intervals and property clocks
have one owner; timeline rows and exposure sheets are projections of them."
(:require [arthur.domain.node :as node]))
(defn members [nodes lane]
(->> (vals nodes)
(filter #(= lane (:parent %)))
(sort-by (juxt #(or (first (node/placed-span %)) 0) #(str (:id %))))
vec))
(defn problems [nodes]
(vec
(mapcat
(fn [[id lane]]
(when (node/sequence? lane)
(let [children (filter #(= id (:parent %)) (vals nodes))
valid? (fn [n]
(and (= :instance (:kind n))
(empty? (node/problems n))
(:span n)
(every? node/finite-number? (node/placed-span n))))
intervals (sort-by first (map node/placed-span (filter valid? children)))]
(concat
(for [n children :when (not (valid? n))]
(str "sequence " id " needs finite visual occurrences: " (:id n)))
(when (some (fn [[[a b] [c d]]] (> b c)) (partition 2 1 intervals))
[(str "sequence " id " has overlapping occurrences")])))))
nodes)))
(defn- lane-map
"Lane -> containing symbol, as an invertible map in the opposite direction.
Refuse floors and loops rather than pretend an affine map preserves them."
[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)) (not (:sample-fps t))
(<= (or (:expose t) 1) 1))
(recur (:parent n) (conj seen id) (conj chain n)))))))
(defn- finish
[clip sid nodes selection extent]
(let [sym (get-in clip [:symbols sid])
changed (for [[id n] nodes :when (node/sequence? n)
child (members nodes id)
:let [m (lane-map nodes id)
end (second (node/placed-span child))]]
(when m (+ (:at m) (/ end (:rate m)))))
end (apply max (:frames sym) (keep identity changed))
ps (problems nodes)]
(cond
(seq ps) {:refused (first ps)}
(not (#{:keep :grow-symbol} extent)) {:refused "choose an explicit shot-length policy"}
(and (> end (:frames sym)) (= :keep extent))
{:refused (str "the edit needs " (js/Math.ceil end) " frames; extend the shot to continue")
:required-frames (js/Math.ceil end)}
:else {:clip (-> clip
(assoc-in [:symbols sid :nodes] nodes)
(assoc-in [:symbols sid :frames] (js/Math.ceil end)))
:selection selection})))
(defn extend-hold
"Change one held occurrence's duration by `delta` lane frames and ripple its
later siblings. Lane channels, occurrence channels and source clocks 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)
lane (get nodes (:parent n))
rate (:rate (node/time-of n))
span (:span n)
m (when lane (lane-map nodes (:id lane)))]
(cond
(not (node/sequence? lane)) {:refused "select an occurrence in a sequence lane"}
(seq (problems nodes)) {:refused (first (problems nodes))}
(not (and (integer? delta) (not (zero? delta)))) {:refused "hold change must be a nonzero whole number of lane frames"}
(not (zero? (:speed (node/playback-of n)))) {:refused "hold length applies to a held drawing"}
(nil? m) {:refused "exposure timing through a stepped or looping lane is not supported"}
(<= (+ (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)
(members nodes (:id lane)))
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 add-lane [clip sid id]
(if (or (nil? (get-in clip [:symbols sid])) (get-in clip [:symbols sid :nodes id]))
{:refused "the symbol is missing or the lane ID is already used"}
{:clip (assoc-in clip [:symbols sid :nodes id]
{:id id :name "drawings" :kind :group :layout :sequence
:z (str "z-" id)})
:selection id}))
(defn append-drawing
"Append fresh one-frame content and a held occurrence. IDs come from the
caller so a command is deterministic and replayable."
[clip sid lane-id id drawing-id {:keys [extent] :or {extent :keep}}]
(let [nodes (get-in clip [:symbols sid :nodes])
lane (get nodes lane-id)]
(cond
(not (node/sequence? lane)) {:refused "select a sequence lane"}
(seq (problems nodes)) {:refused (first (problems nodes))}
(or (contains? nodes id) (get-in clip [:symbols drawing-id])) {:refused "the new drawing IDs are already used"}
(nil? (lane-map nodes lane-id)) {:refused "drawing creation through a stepped or looping lane is not supported"}
:else
(let [at (apply max 0 (map #(second (node/placed-span %)) (members nodes lane-id)))
n {:id id :kind :instance :parent lane-id :z (str "a-" id)
:span [0 1] :time {:at at :rate 1}
:source {:symbol drawing-id} :playback {:in 0 :speed 0 :end :stop}}
clip (assoc-in clip [:symbols drawing-id]
{:id drawing-id :name (name drawing-id) :frames 1 :nodes {}})]
(let [result (finish clip sid (assoc nodes id n) id extent)
m (lane-map nodes lane-id)]
(cond-> result
(:clip result) (assoc :frame (+ (:at m) (/ at (:rate m))))))))))

View file

@ -55,6 +55,7 @@
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.sequence :as sequence]
[arthur.domain.pose :as pose]
[arthur.domain.trace :as trace]
[arthur.domain.palette :as pal]))
@ -516,9 +517,10 @@
;; 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 traces nodes
source-fps picture-fps id c lf)))
(when-let [cursor (get-in cursors [id path])]
(ch/sample! cursor
(channel-frame choices traces 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))
@ -571,6 +573,7 @@
(if-not (map? nodes)
[":nodes must be a map of id -> node"]
(-> []
(into (sequence/problems nodes))
(into (for [[id n] nodes
:when (not= id (:id n))]
(str "node under key " (pr-str id) " has :id " (pr-str (:id n)))))

View file

@ -114,9 +114,15 @@
(letfn [(walk [sid path]
(mapcat (fn [[id n]]
(when (= :instance (:kind n))
;; Every drawing the lane can show, not only the one it
;; happens to be on: a face traced in one cel is the same
;; face when the lane cuts to another.
(let [p (conj path id)]
(cond->> (walk (:of n) p)
(traceable? clip (:of n)) (cons {:path p :in sid :face (:of n)})))))
(mapcat (fn [child]
(cond->> (walk child p)
(traceable? clip child)
(cons {:path p :in sid :face child})))
(sort-by str (node/sources n))))))
(sort-by (comp str key) (get-in clip [:symbols sid :nodes]))))]
(vec (walk sid []))))

View file

@ -73,3 +73,11 @@
"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)))

View file

@ -8,8 +8,11 @@
(:require [arthur.domain.clip :as clip]
[arthur.domain.gesture :as gesture]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.sequence :as sequence]
[arthur.events.edit :as edit]
[arthur.events.paint :as paint]
[arthur.events.playback :as playback]
[arthur.footage.store :as store]
[re-frame.core :as rf]))
@ -24,7 +27,7 @@
[:clip :symbols sid :nodes id :kind]))]
(cond-> (-> db
(assoc-in [:ui :selection] selection)
(update :ui dissoc :points))
(update :ui dissoc :points :sequence-retry))
(and (= :node kind) path (not sound?))
(update-in [:ui :expanded] (fnil into #{}) (rest (reductions conj [] (pop path))))))))
@ -32,6 +35,60 @@
::set-tone
(fn [db [_ tone]] (assoc-in db [:ui :tone] tone)))
(defn apply-sequence-command
"Commit a successful domain command as one history step. A refused command
leaves the document and history untouched; an overflow offers an explicit retry."
[db sid result retry]
(if-let [why (:refused result)]
(-> db
(assoc-in [:project :status] why)
(assoc-in [:ui :sequence-retry]
(when (:required-frames result) retry)))
(let [[_ selected-sid _ path] (get-in db [:ui :selection])
prefix (if (and (= sid selected-sid) (seq path)) (pop path) [])]
(-> db
(edit/transaction (constantly (:clip result)))
(assoc-in [:ui :selection] [:node sid (:selection result) (conj prefix (:selection result))])
(update :ui dissoc :sequence-retry)))))
(rf/reg-event-db
::new-lane
(fn [db _]
(let [clip (:clip (store/entry (:clip/current db)))
sid (get-in db [:ui :open])]
(apply-sequence-command db sid (sequence/add-lane clip sid (random-uuid)) nil))))
(rf/reg-event-fx
::append-drawing
(fn [{:keys [db]} [_ extent]]
(let [{clip :clip st :store} (store/entry (:clip/current db))
[_ sid id path] (get-in db [:ui :selection])
n (get-in clip [:symbols sid :nodes id])
lane (if (node/sequence? n) id (:parent n))
result (sequence/append-drawing clip sid lane (random-uuid) (clip/fresh-id clip)
{:extent (or extent :keep)})
context (nest/inside clip st (get-in db [:ui :open]) (if (seq path) (pop path) [])
(get-in db [:playback :frame]))
{:keys [at rate]} (:time context)]
(cond-> {:db (apply-sequence-command db sid result [::append-drawing :grow-symbol])}
(and (:clip result) rate)
(assoc :dispatch [::playback/seek (+ at (/ (:frame result) rate))])))))
(rf/reg-event-db
::extend-hold
(fn [db [_ delta extent]]
(let [clip (:clip (store/entry (:clip/current db)))
[_ sid id] (get-in db [:ui :selection])
result (sequence/extend-hold clip sid id delta {:extent (or extent :keep)})]
(apply-sequence-command db sid result [::extend-hold delta :grow-symbol]))))
(rf/reg-event-fx
::sequence-retry
(fn [{:keys [db]} _]
(if-let [event (get-in db [:ui :sequence-retry])]
{:db (update db :ui dissoc :sequence-retry) :dispatch event}
{})))
(rf/reg-event-db
::toggle-row
(fn [db [_ path]]

View file

@ -751,8 +751,9 @@
:channels (face-placement params subjects)}}
(map-indexed
(fn [i [id _]]
[id {:id id :kind :instance :of id :parent :face
:z (str "a" i)}]))
[id {:id id :kind :instance :parent :face
:z (str "a" i)
:source {:symbol id}}]))
ordered)}}
(map (fn [[id part]] [id (:symbol part)])) parts)}]
(doseq [[subject inputs] ordered

View file

@ -10,6 +10,7 @@
(:require [arthur.domain.clip :as clip]
[arthur.domain.gesture :as gesture]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.symbol :as symbol]
[arthur.domain.trace :as trace]
@ -114,7 +115,11 @@
(defn- placed?
"Does row `path` from symbol `sid` still name an instance, all the way down?"
[clip sid path]
(reduce (fn [sid id] (or (get-in clip [:symbols sid :nodes id :of]) (reduced nil)))
;; Only an occurrence names a symbol. A lane is a group, so a row path that
;; ends at the lane rather than at one of its cels names no placement — which
;; is the structural answer, and does not move as the lane cuts.
(reduce (fn [sid id]
(or (node/source (get-in clip [:symbols sid :nodes id])) (reduced nil)))
sid path))
(rf/reg-sub

View file

@ -12,6 +12,7 @@
[re-frame.core :as rf]))
(rf/reg-sub ::selection (fn [db _] (get-in db [:ui :selection])))
(rf/reg-sub ::sequence-retry (fn [db _] (get-in db [:ui :sequence-retry])))
(rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone])))
(rf/reg-sub ::tool (fn [db _] (get-in db [:ui :tool])))
(rf/reg-sub ::draft (fn [db _] (get-in db [:ui :draft])))

View file

@ -215,9 +215,9 @@
[facts
"name" (or (:name n) (brief id))
"id" (brief id)
;; Which symbol an instance places. The one fact that makes an instance
;; Which symbol an occurrence places. The one fact that makes an instance
;; legible as an instance rather than as a node.
"of" (when (= :instance (:kind n)) (str (:of n)))
"of" (when (= :instance (:kind n)) (str (node/source n)))
;; A span is in the node's OWN frames and `at` is where its frame 0 sits
;; in this symbol. See `node/placed-span`.
"span" (when start (str start " … " end))
@ -439,7 +439,10 @@
;; symbol — which is the face itself when a face is open to be drawn over.
;; That last case had no section at all before, and it is the one where
;; the keys are actually being set.
placed (:of (peek node))
;; The symbol this occurrence places, or nil for a lane: which face a
;; lane traces is not a question with one answer, and naming the drawing
;; showing now would move the section under the playhead.
placed (node/source (peek node))
face (or placed (when (trace/traceable? clip open) open))
faces (when face (trace/faces clip face))
;; Where that face sits, as a row path from the open symbol, so the faces

View file

@ -22,6 +22,9 @@
happen and not where they happen again."
(:require [clojure.string :as str]
[arthur.domain.node :as node]
[arthur.domain.nest :as nest]
[arthur.domain.sequence :as sequence]
[arthur.domain.symbol :as symbol]
[arthur.domain.trace :as trace]
[arthur.events.playback :as pb]
[arthur.events.ui :as ui]
@ -52,10 +55,16 @@
(let [{:keys [at rate]} (node/time-of n)]
(if (and (zero? at) (= 1 rate))
identity
(fn [f] (js/Math.round (+ at (/ f rate)))))))
(fn [f] (+ at (/ f rate))))))
(defn- keyed-frames [ch] (some-> (:keys ch) keys sort))
(defn- source-frames
"How long the thing this node places is, for a placement carrying no span of
its own. 0 for a node that places nothing, or a reference with nothing there."
[clip n]
(or (get-in clip [:symbols (node/source n) :frames]) 0))
(defn- node-label
"What to call a node in the label column.
@ -105,19 +114,23 @@
;; symbol is resolved in — `clip/resolver` roots the
;; child at this node's local frame — so the nested
;; walk carries it down unchanged.
self (comp ->open (local->parent n))
span (mapv ->open
ancestors (rest (symbol/lineage (:nodes sym) id))
parent-map (reduce comp ->open
(map #(local->parent (get-in sym [:nodes %]))
(reverse ancestors)))
self (comp parent-map (local->parent n))
span (mapv parent-map
(or (node/placed-span
(cond-> n
(and (= :instance (:kind n)) (nil? (:span n)))
(assoc :span [0 (get-in clip [:symbols (:of n) :frames])])))
(assoc :span [0 (source-frames clip n)])))
[0 (:frames sym)]))
row {:path rpath
:depth depth
:label (node-label id n)
:kind :node
:node-kind (:kind n)
:of (:of n)
:of (node/source n)
:select [:node sid id rpath]
:expandable? true
:expanded? open?
@ -127,60 +140,65 @@
(distinct))
(vals channels))
:dense? (boolean (some :dense (vals channels)))}]
(if-not open?
[row]
(-> [row]
(into (channel-rows n rpath (inc depth) self span))
(into (when (= :instance (:kind n))
(walk (:of n) rpath (inc depth) self)))))))
;; AN OCCURRENCE IS NOT A ROW. A lane's drawings are cel
;; blocks on the lane's own row, so a lane of twelve
;; exposures is one row and not twelve — which is the
;; vertical growth that made a keyed source look
;; necessary. The occurrence is still the thing selected
;; and addressed; only its presentation is shared.
(if (node/sequence? (get-in sym [:nodes (:parent n)]))
[]
(let [row (cond-> row
(node/sequence? n)
(assoc :cels
(mapv (fn [child]
{:id (:id child)
:label (or (get-in clip [:symbols (node/source child) :name])
(some-> (node/source child) name))
:source (node/source child)
:span (mapv self (node/placed-span child))
:select [:node sid (:id child) (conj path (:id child))]})
(sequence/members (:nodes sym) id))))]
(if-not open?
[row]
(-> [row]
(into (channel-rows n rpath (inc depth) self span))
(into (when-let [{:keys [at rate]} (and (= :instance (:kind n))
(node/source-time n))]
(when-let [child (node/source n)]
(walk child rpath (inc depth)
(comp self #(+ at (/ % rate)))))))))))))
ordered))))]
(if (get-in clip [:symbols sid])
(walk sid [] 0 identity)
[])))
(defn sound-rows
"Every sound symbol `sid` plays, one row each, below the picture as an
editor's audio tracks are: its own, and those inside what it places at any
depth — each TIED to the placement it is heard through, `:via`, and drawn
where it is heard, cut to that placement's span as the mix cuts it."
"Audio rows use the same flattened intervals as the mixer, including source
in-points, occurrence speeds, parent timing, and silence beneath visual holds."
[clip sid expanded]
(letfn [(walk [sid path ->open [lo hi] via]
(mapcat
(fn [[id n]]
(let [rpath (conj path id)
self (comp ->open (local->parent n))
[a b] (mapv ->open (or (node/placed-span
(cond-> n
(and (= :instance (:kind n)) (nil? (:span n)))
(assoc :span [0 (get-in clip [:symbols (:of n) :frames])])))
[0 (get-in clip [:symbols sid :frames])]))
span [(max lo a) (min hi b)]
open? (contains? expanded rpath)]
(case (:kind n)
:audio (cons {:path rpath
:depth 0
:label (node-label id n)
:kind :node
:node-kind :audio
:via via
;; A sound heard through a placement is
;; that placement's picture's sound: its bar
;; moves the placement, so the two stay in
;; sync. Moving the placement moves it too.
:slides (if via (subvec rpath 0 1) rpath)
:select [:node sid id rpath]
:expandable? true
:expanded? open?
:span span
:keys (into [] (comp (mapcat keyed-frames) (map self) (distinct))
(vals (node/channels n)))}
(when open? (channel-rows n rpath 1 self span)))
:instance (walk (:of n) rpath self span (or via (node-label id n)))
nil)))
(sort-by (fn [[id n]] [(or (:z n) "") (str id)]) (get-in clip [:symbols sid :nodes]))))]
(if (get-in clip [:symbols sid])
(vec (walk sid [] identity [##-Inf ##Inf] nil))
[])))
(if-not (get-in clip [:symbols sid]) []
(vec
(mapcat
(fn [[path tracks]]
(let [n (first tracks)
span (node/placed-span n)
select [:node (:owner n) (:id n) path]
open? (contains? expanded path)
via (when (< 1 (count path)) (str (first path)))
row {:path path :depth 0 :label (node-label (:id n) n)
:kind :node :node-kind :audio :via via
:slides (if via (subvec path 0 1) path)
:select select :expandable? true :expanded? open? :span span
:keys (vec (distinct (mapcat keyed-frames (vals (:channels n)))))}]
(cons (cond-> row
(< 1 (count tracks))
(assoc :cels (mapv (fn [i track]
{:id i :label (node-label (:id track) track)
:span (node/placed-span track) :select select})
(range) tracks)))
(when open? (channel-rows n path 1 identity span)))))
(sort-by (comp str key) (group-by :path (nest/audio-tracks clip sid)))))))
;; ---------------------------------------------------------------------------
;; geometry
@ -206,7 +224,13 @@
rate @(rf/subscribe [::playback/rate])
frame @(rf/subscribe [::playback/frame])
frames @(rf/subscribe [::render/frames])
{:keys [fps drop]} @player/meter]
{:keys [fps drop]} @player/meter
clip @(rf/subscribe [::render/clip])
[_ sid id] @(rf/subscribe [::sub/selection])
n (get-in clip [:symbols sid :nodes id])
lane (if (node/sequence? n) n (get-in clip [:symbols sid :nodes (:parent n)]))
lane? (node/sequence? lane)
held? (and lane? (= :instance (:kind n)) (zero? (:speed (node/playback-of n))))]
[:div.pane-head
[:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")]
[:button {:on-click #(rf/dispatch [::pb/seek 0])} "|<"]
@ -228,6 +252,18 @@
[:button {:title "a new empty symbol inside the selected instance, or beside the selected node, or in the open symbol"
:on-click #(rf/dispatch [::ui/new-symbol])}
"+ symbol"]
[:button {:on-click #(rf/dispatch [::ui/new-lane])} "+ lane"]
[:button {:disabled (not lane?)
:title "append a new independent drawing to the selected lane"
:on-click #(rf/dispatch [::ui/append-drawing])} "new drawing"]
[:button {:disabled (not held?)
:title "shorten this exposure; ripple later drawings, keeping lane keys fixed"
:on-click #(rf/dispatch [::ui/extend-hold -1])} "hold −"]
[:button {:disabled (not held?)
:title "extend this exposure; ripple later drawings, keeping lane keys fixed"
:on-click #(rf/dispatch [::ui/extend-hold 1])} "hold +"]
(when @(rf/subscribe [::sub/sequence-retry])
[:button {:on-click #(rf/dispatch [::ui/sequence-retry])} "extend shot and apply"])
[:span.spacer]
[:span.dim (str frame " / " frames)]
;; Measured in the loop, not derived from the clock — the whole question
@ -353,7 +389,7 @@
"`sliding` is the pointer's side of a bar being dragged, `{:path :x :width
:df}`. What it looks like mid-drag is `[:ui :sliding]`, which the clip every
row and the stage are drawn from already has in it."
[{:keys [path span keys dense? kind node-kind select slides]} frames sliding]
[{:keys [path span keys dense? kind node-kind select slides cels]} frames sliding]
(let [{from :path x0 :x width :width} @sliding
slide (fn [^js e]
(when (= path from)
@ -374,7 +410,7 @@
:on-pointer-cancel (fn [_] (done false))}
;; Clipped to the ruler: an instance longer than the room left in its
;; symbol still plays its own frames from 0, it is just cut off at the end.
(when-let [[in out] (when span [(max 0 (first span)) (min frames (second span))])]
(when-let [[in out] (when (and span (nil? cels)) [(max 0 (first span)) (min frames (second span))])]
(when (< in out)
[:div {:class (str "tl-span" (when dense? " dense") (when (= :ghost kind) " ghost")
(when (= :audio node-kind) " sound")
@ -393,6 +429,22 @@
;; the browser has no record of.
(try (.setPointerCapture track (.-pointerId e))
(catch :default _ nil)))))}]))
(doall
(for [{:keys [id label source span select]} cels
:let [in (max 0 (first span)) out (min frames (second span))]
:when (< in out)]
^{:key (str id)}
[:button.tl-cel
{:title (str label " · select occurrence; double-click to edit shared drawing")
:style {:position "absolute" :left (edge% in frames)
:width (str (* 100 (/ (- out in) (max 1 frames))) "%")
:top "2px" :bottom "2px" :overflow "hidden" :padding "0 3px"}
:on-click (fn [e] (.stopPropagation e)
(rf/dispatch [::ui/select select])
(rf/dispatch [::pb/seek (js/Math.floor in)]))
:on-double-click (fn [e] (.stopPropagation e)
(when source (rf/dispatch [::pb/open-symbol source])))}
label]))
;; A dense channel has a value on every frame, so ticking each one is a solid
;; block that says less than the bar behind it already does.
(when-not dense?

View file

@ -1,7 +1,8 @@
(ns arthur.domain.bring-test
(:require [cljs.test :refer [deftest is]]
[arthur.domain.bring :as bring]
[arthur.domain.clip :as clip]))
[arthur.domain.clip :as clip]
[arthur.domain.node :as node]))
(defn- nested
"Three symbols: :outer places :inner, and :loose is placed by nothing."
@ -21,6 +22,6 @@
(is (= {:main :take :inner :inner-2} ids)
"the root gets the name asked for; a taken id gets the next free one")
(is (= 10 (clip/frames clip :inner)) "what was already here is untouched")
(is (= :inner-2 (:of (first (vals (get-in clip [:symbols :take :nodes])))))
(is (= #{:inner-2} (node/sources (first (vals (get-in clip [:symbols :take :nodes])))))
"and the copy's instance follows its renamed symbol")
(is (empty? (clip/problems clip)))))

View file

@ -27,12 +27,14 @@
(assoc-in [:symbols :main]
{:id :main :frames 6
:nodes {:root {:id :root :kind :group :z "a1"}
:left {:id :left :kind :instance :of :sym/test
:left {:id :left :kind :instance
:parent :root :z "a1" :span [0 4]
:source {:symbol :sym/test}
:channels {[:xform :pos] (ch/framed [100 50])}}
:right {:id :right :kind :instance :of :sym/test
:right {:id :right :kind :instance
:parent :root :z "a2" :span [0 4]
:time {:mode :map :at 2 :rate 1}
:source {:symbol :sym/test}
:channels {[:xform :pos] (ch/framed [120 50])}}}})
(assoc-in [:symbols :sym/test]
(assoc (get-in source [:symbols :main]) :id :sym/test)))
@ -66,13 +68,15 @@
:symbols
{:main {:id :main :frames 30
:nodes {:root {:id :root :kind :group :z "a1"}
:first {:id :first :kind :instance :of :sym/poses
:first {:id :first :kind :instance
:parent :root :z "a1"
:source {:symbol :sym/poses}
:playback {:tracks {:mouth {0 0, 8 20, 9 21}
[:node :mouth-detail] {0 0, 8 4}
:eye {0 0, 4 4}}}}
:second {:id :second :kind :instance :of :sym/poses
:second {:id :second :kind :instance
:parent :root :z "a2"
:source {:symbol :sym/poses}
:playback {:tracks {:mouth {0 0, 8 8}}}}}}
:sym/poses symbol}}
store {"sizes" {:data values}}
@ -109,8 +113,9 @@
(deftest stage-pose-edits-preserve-earlier-motion-and-survive-save
(let [document (-> source
(assoc-in [:symbols :main :nodes :placed]
{:id :placed :kind :instance :of :sym/test :parent :root
:z "a2"})
{:id :placed :kind :instance :parent :root
:z "a2"
:source {:symbol :sym/test}})
(assoc-in [:symbols :sym/test]
{:id :sym/test :frames 4
:nodes {:root {:id :root :kind :group :z "a1"}
@ -153,8 +158,8 @@
(let [document (stage/compose source)]
(is (empty? (clip/problems document)))
(is (= #{:main :sym/face-8625} (set (keys (:symbols document)))))
(is (= :sym/face-8625 (:of (placement document :left))))
(is (= :sym/face-8625 (:of (placement document :right))))
(is (= #{:sym/face-8625} (node/sources (placement document :left))))
(is (= #{:sym/face-8625} (node/sources (placement document :right))))
(testing "every placement is keyed by its own uuid"
;; The identity change: seven placements of one drawing are seven things,
;; and each is named by something that means only itself. Sharing a key, or
@ -165,7 +170,7 @@
(is (every? uuid? (map key symbols)))
(is (= 7 (count (distinct (map key symbols)))))
(testing "and each still says which drawing it plays and what to call it"
(is (every? #(= :sym/face-8625 (:of (val %))) symbols))
(is (every? #(= #{:sym/face-8625} (node/sources (val %))) symbols))
(is (every? #(string? (:name (val %))) symbols))
(is (= 7 (count (distinct (map #(:name (val %)) symbols))))))))
(is (= 7 (count (filter #(= :instance (:kind %))
@ -221,7 +226,7 @@
(is (= :main (clip/opens-on (clip/blank)))))
(testing "an instance can go into any symbol, and spans that symbol's frames"
(let [[n] (vals (get-in c [:symbols :outer :nodes]))]
(is (= :inner (:of n)))
(is (= #{:inner} (node/sources n)))
(is (= [0 10] (:span n)) "its own frames: all of what it places, from its own 0")
(is (= [5 15] (node/placed-span n)) "and where that lands in the symbol it is in")))
(testing "placing is refused when it would make a cycle"
@ -246,14 +251,17 @@
(is (= {:id :symbol-1 :name "symbol-1" :frames 180 :nodes {}}
(clip/symbol made :symbol-1))
"empty, and as long as the rest of what it was placed in")
(is (= {:of :symbol-1 :span [0 180] :time {:mode :map :at 20 :rate 1}}
(select-keys (get-in made [:symbols :outer :nodes u]) [:of :span :time])))
(is (= {:span [0 180] :time {:mode :map :at 20 :rate 1}}
(select-keys (get-in made [:symbols :outer :nodes u]) [:span :time])))
(is (= #{:symbol-1} (node/sources (get-in made [:symbols :outer :nodes u])))
"and it places the symbol it just made")
(is (empty? (clip/problems made)))
(is (= c (clip/new-symbol c :outer :inner 0 u)) "an id already in use is refused")
(is (= c (clip/new-symbol c :outer id 200 u)) "past the end is refused")))
(deftest an-instance-span-is-in-its-own-frames
(let [n {:id :i :kind :instance :of :x :z "a1" :span [3 13]
(let [n {:id :i :kind :instance :z "a1" :span [3 13]
:source {:symbol :x}
:time {:mode :map :at 40 :rate 2}}]
(is (= [41.5 46.5] (node/placed-span n)) "own frames 3 to 13, at double rate, from 40")
(is (= 0 (node/local-frame n 40)) "the parent's :at is where its own frame 0 lands")

View file

@ -0,0 +1,238 @@
(ns arthur.domain.lane-test
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.bring :as bring]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.history :as history]
[arthur.domain.leaf :as leaf]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.pick :as pick]
[arthur.domain.sequence :as sequence]
[arthur.domain.symbol :as symbol]))
(defn drawing [id x frames]
{:id id :frames frames
:nodes {:mark {:id :mark :kind :rect :z "a"
:channels {[:geom :size] (ch/framed 4)
[:xform :pos] (ch/framed [x 0])}}}})
(defn occurrence [id source at duration speed]
{:id id :kind :instance :parent :girl :z (name id)
:source {:symbol source} :playback {:in 0 :speed speed :end :stop}
:time {:at at :rate 1} :span [0 duration]})
(defn document []
(let [a (occurrence :a :drawing-a 0 4 0)
b (assoc-in (occurrence :b :drawing-b 4 4 0)
[:channels [:xform :pos]] (ch/keyed {0 [0 0] 1 [2 0]} :hold))
insert (assoc-in (occurrence :insert :wave 8 4 1) [:playback :in] 3)]
{:name "exposures" :fps 24 :width 320 :height 200
:symbols
{:main {:id :main :frames 12
:nodes {:girl {:id :girl :kind :group :layout :sequence :z "b"
:channels {[:xform :pos] (ch/keyed {0 [0 0] 6 [60 0] 12 [0 0]} :linear)}}
:a a :b b :insert insert
:plate {:id :plate :kind :rect :z "a"
:channels {[:geom :size] (ch/framed 10)
[:xform :pos] (ch/keyed {0 [-40 0] 11 [70 0]} :linear)}}}}
:drawing-a (drawing :drawing-a 10 1)
:drawing-b (drawing :drawing-b 20 1)
:wave (assoc-in (drawing :wave 0 10) [:nodes :mark :channels [:xform :pos]]
(ch/keyed {0 [0 0] 9 [900 0]} :linear))}}))
(defn sample [doc fs]
(let [r (clip/resolver doc :main nil pal/index-of nil)]
(into {} (map (fn [f] [f (into {} (map (juxt :node :cx)) (r f))])) fs)))
(deftest one-lane-mixes-held-drawings-and-playing-content
(let [doc (document) at (sample doc (range 12))]
(is (empty? (clip/problems doc)))
(is (= 40 (get-in at [3 [:a :mark]])))
(is (= 60 (get-in at [4 [:b :mark]])))
(is (= 72 (get-in at [5 [:b :mark]])))
(is (= 340 (get-in at [8 [:insert :mark]])))
(is (= 610 (get-in at [11 [:insert :mark]])))
(is (= (zipmap (range 12) (range -40 80 10))
(into {} (map (fn [[f ops]] [f (js/Math.round (:plate ops))])) at)))
(is (nil? (get-in at [4 [:a :mark]])) "half-open cuts have a single owner")))
(deftest exposure-ripple-keeps-lane-keys-and-moves-occurrence-corrections
(let [doc (document)
result (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol})
after (:clip result)
nodes (get-in after [:symbols :main :nodes])]
(is (= :a (:selection result)))
(is (= 14 (get-in after [:symbols :main :frames])))
(is (= [0 6] (node/placed-span (:a nodes))))
(is (= [6 10] (node/placed-span (:b nodes))))
(is (= [10 14] (node/placed-span (:insert nodes))))
(doseq [id [:girl :a :b :insert :plate]]
(is (= (get-in doc [:symbols :main :nodes id :channels]) (:channels (nodes id))))
(is (= (get-in doc [:symbols :main :nodes id :playback]) (:playback (nodes id)))))
(let [at (sample after [5 6 7 10])]
(is (= 60 (get-in at [5 [:a :mark]])))
(is (= 80 (get-in at [6 [:b :mark]])))
(is (= 72 (get-in at [7 [:b :mark]])) "B's correction follows B")
(is (= 320 (get-in at [10 [:insert :mark]])) "insert starts on source frame 3"))
(is (empty? (clip/problems after)))
(is (= (assoc-in doc [:symbols :main :frames] 14)
(:clip (sequence/extend-hold after :main :a -2 {})))
"shrinking restores content, except the explicitly grown shot")))
(deftest overflow-and-invalid-edits-are-atomic
(let [doc (document)
result (sequence/extend-hold doc :main :a 2 {})]
(is (:refused result))
(is (= 14 (:required-frames result)))
(is (not (contains? result :clip)))
(doseq [delta [0 -4 0.5 js/NaN]]
(is (:refused (sequence/extend-hold doc :main :a delta {}))))
(is (:refused (sequence/extend-hold doc :main :insert 1 {})))
(is (:refused (sequence/extend-hold doc :main :missing 1 {})))))
(deftest a-gap-is-an-uncovered-interval
(let [doc (update-in (document) [:symbols :main :nodes] dissoc :b)
at (sample doc [3 4 7 8])]
(is (= #{:plate} (set (keys (at 4)))))
(is (= #{:plate} (set (keys (at 7)))))
(is (get-in at [8 [:insert :mark]]))
(is (empty? (clip/problems doc)))))
(deftest validation-rejects-overlap-but-allows-empty-lanes
(is (some #(re-find #"overlap" %)
(clip/problems (assoc-in (document) [:symbols :main :nodes :b :time :at] 3))))
(is (empty? (clip/problems
(update-in (document) [:symbols :main :nodes] dissoc :a :b :insert))))
(is (seq (clip/problems
(assoc-in (document) [:symbols :main :nodes :a :span] [0 ##Inf]))))
(is (seq (clip/problems
(assoc-in (document) [:symbols :main :nodes :a :playback :speed] -1))))
(is (seq (node/problems {:id :old :kind :instance :z "a"
:channels {[:source] (ch/framed {:of :wave :in 0})}}))
"the obsolete format is rejected"))
(deftest playback-is-independent-of-property-channel-shape
(let [doc (document)
n (get-in doc [:symbols :main :nodes :a])
keyed (node/toggle-key n [:xform :rot] 0 nil)
unkeyed (node/toggle-key keyed [:xform :rot] 0 nil)]
(doseq [n [n keyed unkeyed]]
(is (= {:symbol :drawing-a :frame 0} (node/placed-frame n 11 1))))
(let [n (get-in doc [:symbols :main :nodes :insert])]
(is (= {:symbol :wave :frame 5} (node/placed-frame n 2 10)))
(is (nil? (node/placed-frame n 7 10)))
(is (= 9 (:frame (node/placed-frame (assoc-in n [:playback :end] :hold) 9 10))))
(is (= 2 (:frame (node/placed-frame (assoc-in n [:playback :end] :loop) 9 10)))))))
(deftest navigation-and-hit-testing-use-the-same-source-time
(let [doc (document)
n (get-in doc [:symbols :main :nodes :insert])]
(is (= 5 (:frame (nest/inside doc nil :main [:insert] 10))))
(is (= {:at 5 :rate 1} (:time (nest/inside doc nil :main [:insert] 10))))
(is (= 0 (:frame (nest/inside doc nil :main [:a] 3))))
(is (nil? (:time (nest/inside doc nil :main [:a] 3))))
(is (nil? (nest/inside doc nil :main [:a] 4)))
(is (= ((pick/bounds-of doc nil n) 2)
((pick/bounds-of doc nil (assoc-in n [:playback :in] 5)) 0)))))
(deftest seeking-and-source-reuse-do-not-share-cursors
(let [doc (assoc-in (document) [:symbols :main :nodes :b :source :symbol] :drawing-a)
fs [11 0 5 3 8 4 10 1 6 2 9 7]
at (sample doc fs)]
(is (= at (sample doc (reverse fs))))
(is (= at (sample doc (range 12))))
(let [edited (assoc-in doc [:symbols :drawing-a :nodes :mark :channels [:xform :pos]]
(ch/framed [99 0]))]
(is (= 99 (get-in (sample edited [0 4]) [0 [:a :mark]])))
(is (= 139 (get-in (sample edited [0 4]) [4 [:b :mark]]))))))
(deftest occurrence-identities-and-playback-round-trip
(let [doc (:clip (sequence/extend-hold (document) :main :a 2 {:extent :grow-symbol}))
leaves (leaf/leaves :project doc)]
(is (= doc (leaf/clip :project leaves)))
(is (contains? leaves "clip/project/symbol/main/node/a"))
(is (not (contains? leaves "clip/project/symbol/main/channel/girl/source")))
(let [{copied :clip ids :ids}
(bring/symbols (assoc-in (clip/blank) [:symbols :drawing-a] (drawing :drawing-a 99 1))
doc [:main] {})]
(is (= :drawing-a-2 (:drawing-a ids)))
(is (= #{:drawing-a-2 :drawing-b :wave} (clip/places copied (:main ids))))
(is (empty? (clip/problems copied))))))
(deftest one-transaction-undoes-the-ripple-and-shot-extension
(let [doc (document)
after (:clip (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol}))
before-leaves (leaf/leaves :p doc)
after-leaves (leaf/leaves :p after)
h (-> nil history/hold (history/record before-leaves after-leaves 0) history/settle)
undo (history/undo h after-leaves)
redo (history/redo (:history undo) (:leaves undo))]
(is (= 1 (count (:done h))))
(is (= before-leaves (:leaves undo)))
(is (= after-leaves (:leaves redo)))))
(deftest create-lane-and-append-drawings
(let [doc (:clip (sequence/add-lane (clip/blank) :main :girl))
a (:clip (sequence/append-drawing doc :main :girl :a :drawing-a {}))
b (:clip (sequence/append-drawing a :main :girl :b :drawing-b {}))]
(is (empty? (clip/problems b)))
(is (= [1 2] (node/placed-span (get-in b [:symbols :main :nodes :b]))))
(is (= 0 (get-in b [:symbols :main :nodes :b :playback :speed])))
(is (:refused (sequence/append-drawing b :main :girl :a :new {})))))
(deftest fractional-placement-rates-convert-the-hold-delta
(let [doc (-> (document)
(assoc-in [:symbols :main :nodes :a :time :rate] 2)
(assoc-in [:symbols :main :nodes :a :span] [0 8]))
after (:clip (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol}))]
(is (= [0 12] (get-in after [:symbols :main :nodes :a :span])))
(is (= [6 10] (node/placed-span (get-in after [:symbols :main :nodes :b]))))))
(deftest bare-shapes-agree-in-reference-and-playback
(let [sym (drawing :bare 12 1)]
(is (= (symbol/eval-frame sym 0 nil pal/index-of nil)
((symbol/resolver sym nil pal/index-of nil) 0))
"omitted style colour must not crash a missing cursor")))
(deftest audio-follows-only-the-playing-occurrence
(let [voice {:id :voice :kind :audio :z "a" :source {:sound "voice"}
:span [0 10]
:channels {[:audio :gain] (ch/keyed {0 0 5 1} :linear)}}
doc (-> (document)
(assoc-in [:symbols :wave :nodes :voice] voice)
(assoc-in [:symbols :drawing-a :nodes :voice] voice))
[track :as tracks] (nest/audio-tracks doc :main)]
(is (= 1 (count tracks)) "the frozen drawing contributes no audio")
(is (= [8 12] (node/placed-span track)))
(is (= [3 7] (:span track)) "the source in-point trims the audio too")
(is (= {5 0 10 1} (get-in track [:channels [:audio :gain] :keys])))
(let [moved (:clip (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol}))
[track] (nest/audio-tracks moved :main)]
(is (= [10 14] (node/placed-span track)))
(is (= [3 7] (:span track))))
(let [fast (-> doc
(assoc-in [:symbols :main :nodes :girl :time] {:at 2 :rate 2})
(assoc-in [:symbols :main :nodes :insert :playback :speed] 2))
[track] (nest/audio-tracks fast :main)]
(is (= [6 7.75] (node/placed-span track)))
(is (= [3 10] (:span track)))
(is (= 4 (get-in track [:time :rate]))))))
(deftest a-looped-insert-schedules-distinct-audio-intervals
(let [doc (-> (document)
(assoc-in [:symbols :wave :frames] 4)
(assoc-in [:symbols :wave :nodes :voice]
{:id :voice :kind :audio :z "a" :source {:sound "v"} :span [1 3]})
(assoc-in [:symbols :main :nodes :insert :playback]
{:in 3 :speed 1 :end :loop}))]
(is (= [[10 12]] (mapv node/placed-span (nest/audio-tracks doc :main))))))
(deftest enclosing-retiming-is-respected-when-extending-the-shot
(let [doc (assoc-in (document) [:symbols :main :nodes :girl :time] {:at 8 :rate 2})
result (sequence/extend-hold doc :main :a 2 {})]
(is (= 15 (:required-frames result)))
(is (nil? (:clip result)))
(is (= 15 (get-in (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol})
[:clip :symbols :main :frames])))))

View file

@ -34,7 +34,12 @@
;; No node leaves, and still `:nodes {}`: nil there is what `symbol/nodes-of`
;; refuses, so a saved blank document would not open.
(is (= (get-in (clip/blank) [:symbols :main])
(get-in (leaf/clip :c1 (leaf/leaves :c1 (clip/blank))) [:symbols :main]))))
(get-in (leaf/clip :c1 (leaf/leaves :c1 (clip/blank))) [:symbols :main])))
;; And the WHOLE blank document, not only its symbol. An empty field that the
;; codec cannot write is an empty field it cannot restore, so a blank document
;; carrying one comes back unequal to itself — which undo, whose steps are
;; leaves, then reports as a document change nobody made.
(is (= (clip/blank) (leaf/clip :c1 (leaf/leaves :c1 (clip/blank))))))
(deftest the-leaves-are-the-paths-the-sync-design-names
(let [ls (leaf/leaves :c7 @take/clip)]
@ -115,8 +120,9 @@
;; right in a log and resolves nothing: `:linked-to` dangles and an export target
;; matches no node, with no error anywhere.
(let [u #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218"
c (one-symbol {u {:id u :kind :instance :of :sym/face-8625 :parent nil
:z "a1" :name "8625 bottom left"}})
c (one-symbol {u {:id u :kind :instance :parent nil
:z "a1" :name "8625 bottom left"
:source {:symbol :sym/face-8625}}})
ls (leaf/leaves :c1 c)]
(is (contains? ls (str "clip/c1/symbol/main/node/" u))
"written plainly, with no sigil")

View file

@ -93,8 +93,8 @@
[t] (nest/audio-tracks c :main)]
(is (= [10 40] (:span t)) "the same frames of the source")
(is (= [50 80] (node/placed-span t)) "starting where the instance starts")
(is (= #{50 55} (set (keys (get-in t [:channels [:audio :gain] :keys]))))
"with its automation moved along")
(is (= #{40 45} (set (keys (get-in t [:channels [:audio :gain] :keys]))))
"automation is in the sound's own clock, including its source offset")
(testing "and cut off where the instance's own span ends"
(let [c (assoc-in c [:symbols :main :nodes #uuid "00000000-0000-4000-8000-0000000000bb" :span] [0 12])
[t] (nest/audio-tracks c :main)]

View file

@ -74,7 +74,8 @@
[]
(assoc-in @frozen [:symbols :wrap]
{:id :wrap :frames 200
:nodes {:m {:id :m :kind :instance :of :main :z "a0"
:nodes {:m {:id :m :kind :instance :z "a0"
:source {:symbol :main}
:channels {[:xform :pos] {:animated? false :value [30 -10]}}}}}))
(deftest a-face-switched-on-shows-wherever-it-is-placed

View file

@ -60,12 +60,14 @@
:nodes {:root {:id :root :kind :group :z "a1"}
#uuid "22222222-2222-4222-8222-222222222222"
{:id #uuid "22222222-2222-4222-8222-222222222222"
:kind :instance :of :sym/face :parent :root :z "a2"
:name "8625 right"}
:kind :instance :parent :root :z "a2"
:name "8625 right"
:source {:symbol :sym/face}}
#uuid "11111111-1111-4111-8111-111111111111"
{:id #uuid "11111111-1111-4111-8111-111111111111"
:kind :instance :of :sym/face :parent :root :z "a1"
:name "8625 left"}
:kind :instance :parent :root :z "a1"
:name "8625 left"
:source {:symbol :sym/face}}
:a-rect {:id :a-rect :kind :rect :parent :root :z "a3"}}}
:sym/face {:frames 40 :nodes {:root {:id :root :kind :group :z "a1"}}}}})

View file

@ -0,0 +1,42 @@
(ns arthur.events.sequence-test
(:require [cljs.test :refer [deftest is]]
[arthur.domain.lane-test :as fixture]
[arthur.domain.sequence :as sequence]
[arthur.events.ui :as ui]
[arthur.domain.history :as history]
[arthur.domain.leaf :as leaf]
[arthur.footage.store :as store]
[arthur.ui.timeline :as timeline]))
(deftest one-row-projects-all-occurrences-and-keeps-selection-addresses
(let [doc (fixture/document)
rows (timeline/rows doc :main #{})
lane (first (filter :cels rows))]
(is (= 2 (count rows)))
(is (= [[0 4] [4 8] [8 12]] (mapv :span (:cels lane))))
(is (= [[:node :main :a [:a]] [:node :main :b [:b]] [:node :main :insert [:insert]]]
(mapv :select (:cels lane))))
(is (= [0 6 12] (:keys lane)))
(is (= 1 (count (filter :cels (timeline/rows doc :main #{[:girl]})))))))
(deftest sequence-commands-use-isolated-history-transactions
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "sequence-test")
db {:clip/current id :paint/revision 0
:ui {:open :main :selection [:node :main :a [:a]]}}
refused (ui/apply-sequence-command db :main
(sequence/extend-hold doc :main :a 1 {}) [:retry])]
(is (= doc (:clip (store/entry id))))
(is (nil? (:history (store/entry id))))
(is (= [:retry] (get-in refused [:ui :sequence-retry])))
(let [r1 (sequence/extend-hold doc :main :a 1 {:extent :grow-symbol})
db1 (ui/apply-sequence-command db :main r1 nil)
r2 (sequence/extend-hold (:clip r1) :main :a 1 {:extent :grow-symbol})
db2 (ui/apply-sequence-command db1 :main r2 nil)
h (:history (store/entry id))
undo (history/undo h (leaf/leaves "u" (:clip r2)))
undo2 (history/undo (:history undo) (:leaves undo))]
(is (= 2 (count (:done h))) "rapid button presses remain separate commands")
(is (= (:clip r1) (leaf/clip "u" (:leaves undo))))
(is (= doc (leaf/clip "u" (:leaves undo2))))
(is (= [:node :main :a [:a]] (get-in db2 [:ui :selection]))))))

View file

@ -222,10 +222,12 @@
{:main
{:frames 12
:nodes (cond-> {:root {:id :root :kind :group :z "a1"}
p1 {:id p1 :kind :instance :of :sym/face :parent :root :z "a1"
:name "left" :channels {[:xform :pos] (ch/framed [0 0])}}
p2 {:id p2 :kind :instance :of :sym/face :parent :root :z "a2"
:name "right" :channels {[:xform :pos] (ch/framed [4 0])}}
p1 {:id p1 :kind :instance :parent :root :z "a1"
:name "left" :source {:symbol :sym/face}
:channels {[:xform :pos] (ch/framed [0 0])}}
p2 {:id p2 :kind :instance :parent :root :z "a2"
:name "right" :source {:symbol :sym/face}
:channels {[:xform :pos] (ch/framed [4 0])}}
:loose (assoc (poly :loose "a4" [0 0 1 0 1 1] :brow)
:parent :root)}
voice? (assoc v1 {:id v1 :kind :audio :parent :root :z "a3"

View file

@ -87,7 +87,7 @@
(deftest the-tree-is-the-one-the-model-specifies
(is (= [:face :root] (symbol/lineage (:nodes (clip/symbol @clip* :main)) :face)))
(is (= :face-1 (get-in @clip* [:symbols :main :nodes :face-1 :of])))
(is (= #{:face-1} (node/sources (get-in @clip* [:symbols :main :nodes :face-1]))))
(is (= [:head] (symbol/lineage (:nodes @sym*) :head)))
(is (= [:mouth :head] (symbol/lineage (:nodes @sym*) :mouth)))
(is (= [:mouth-in :mouth :head] (symbol/lineage (:nodes @sym*) :mouth-in)))

View file

@ -0,0 +1,131 @@
// Local editor smoke test. Uses the in-memory blank document and disables the
// project route, so it never creates an account, project, or server-side write.
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import assert from 'node:assert/strict';
const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/';
const profile = mkdtempSync(join(tmpdir(), 'arthur-sequence-'));
const port = 9335;
const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [
'--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run',
'--no-default-browser-check', '--mute-audio', '--window-size=1440,1000',
`--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url,
], { stdio: 'ignore' });
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
let ws;
try {
let target;
for (let i = 0; i < 100 && !target; i++) {
await sleep(100);
try {
target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json()))
.find(t => t.type === 'page' && t.url.startsWith(url));
} catch { /* browser starting */ }
}
assert(target, 'browser exposes the editor page');
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let serial = 0;
const pending = new Map();
const errors = [];
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.method === 'Runtime.exceptionThrown') errors.push(msg.params.exceptionDetails);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) reject(new Error(JSON.stringify(msg.error)));
else resolve(msg.result);
}
};
const send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++serial;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
const evaluate = async expression => {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails));
return r.result.value;
};
await send('Runtime.enable');
for (let i = 0; i < 100; i++) {
if (await evaluate('typeof arthur !== "undefined" && !!arthur.events?.ui && !!document.querySelector("canvas.stage")')) break;
await sleep(100);
}
await evaluate(`(() => {
const k = cljs.core.keyword;
cljs.core.swap_BANG_(re_frame.db.app_db, db => cljs.core.assoc(db, k('route'), k('local-test')));
window.sequenceSnapshot = () => {
const db = cljs.core.deref(re_frame.db.app_db);
const entry = arthur.footage.store.entry(cljs.core.get(db, k('clip/current')));
return cljs.core.clj__GT_js(entry);
};
return true;
})()`);
await sleep(250);
const click = async label => {
assert(await evaluate(`(() => {
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)});
if (!b || b.disabled) return false;
b.click(); return true;
})()`), `enabled button: ${label}`);
await sleep(180);
};
const shot = async () => (await evaluate('sequenceSnapshot()'));
const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance')
.sort((a, b) => a.time.at - b.time.at);
await click('+ lane');
await click('new drawing');
await click('hold +');
await click('hold +');
await click('hold +');
await click('new drawing');
let s = await shot();
assert.deepEqual(instances(s).map(n => [n.time.at, n.span[1]]), [[0, 4], [4, 1]]);
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 2);
assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1);
assert.equal(await evaluate(`cljs.core.get_in(cljs.core.deref(re_frame.db.app_db),
cljs.core.vector(cljs.core.keyword('playback'), cljs.core.keyword('frame')))`), 4,
'new drawing seeks to its occurrence');
// Shorten this test shot to the occupied extent, purely in memory.
await evaluate(`(() => {
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
arthur.footage.store.edit_clip_BANG_(cljs.core.get(db, k('clip/current')),
clip => cljs.core.assoc_in(clip, cljs.core.vector(k('symbols'), k('main'), k('frames')), 5));
document.querySelector('.tl-cel').click();
})()`);
await sleep(200);
const before = await shot();
await click('hold +');
s = await shot();
assert.deepEqual(s.clip, before.clip, 'refused overflow makes no document change');
assert.equal(s.history.done.length, before.history.done.length);
await click('extend shot and apply');
s = await shot();
assert.equal(s.clip.symbols.main.frames, 6);
assert.deepEqual(instances(s).map(n => [n.time.at, n.span[1]]), [[0, 5], [5, 1]]);
assert.equal(s.history.done.length, before.history.done.length + 1);
await evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key:'z', ctrlKey:true, bubbles:true}))`);
await sleep(250);
assert.deepEqual((await shot()).clip, before.clip, 'one undo restores exposure, ripple, and shot length');
assert.equal(errors.length, 0, JSON.stringify(errors));
console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo; no server writes');
} finally {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));
await sleep(350);
}
ws?.close();
chrome.kill();
await new Promise(resolve => { if (chrome.exitCode !== null || chrome.signalCode !== null) resolve(); else chrome.once('exit', resolve); });
try {
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
console.warn(`Temporary browser profile retained at ${profile}: ${error.code}`);
}
}