diff --git a/clips/migrations/0011_occurrence_schema.py b/clips/migrations/0011_occurrence_schema.py new file mode 100644 index 0000000..cf9ad2e --- /dev/null +++ b/clips/migrations/0011_occurrence_schema.py @@ -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), + ), + ] diff --git a/clips/models.py b/clips/models.py index b7b4ea1..6933782 100644 --- a/clips/models.py +++ b/clips/models.py @@ -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) diff --git a/clips/tests/test_api.py b/clips/tests/test_api.py index 757acd4..6925190 100644 --- a/clips/tests/test_api.py +++ b/clips/tests/test_api.py @@ -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"]) diff --git a/docs/animation-model.md b/docs/animation-model.md index 506ae01..25a0ab2 100644 --- a/docs/animation-model.md +++ b/docs/animation-model.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 46b776e..12d8bb5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/lane-model.md b/docs/lane-model.md new file mode 100644 index 0000000..8445434 --- /dev/null +++ b/docs/lane-model.md @@ -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. diff --git a/docs/multi-face-representation.md b/docs/multi-face-representation.md index 51ae686..bed5863 100644 --- a/docs/multi-face-representation.md +++ b/docs/multi-face-representation.md @@ -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 } - :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 ...} ...}}} diff --git a/docs/timing-model.md b/docs/timing-model.md index b5a17eb..b8fe700 100644 --- a/docs/timing-model.md +++ b/docs/timing-model.md @@ -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. diff --git a/frontend/src/arthur/audio/mix.cljs b/frontend/src/arthur/audio/mix.cljs index 13c52c5..2d582f2 100644 --- a/frontend/src/arthur/audio/mix.cljs +++ b/frontend/src/arthur/audio/mix.cljs @@ -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)) diff --git a/frontend/src/arthur/demo/stage.cljs b/frontend/src/arthur/demo/stage.cljs index 35e0ade..3e142b3 100644 --- a/frontend/src/arthur/demo/stage.cljs +++ b/frontend/src/arthur/demo/stage.cljs @@ -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)) diff --git a/frontend/src/arthur/domain/bring.cljs b/frontend/src/arthur/domain/bring.cljs index 3152885..a2dfdd5 100644 --- a/frontend/src/arthur/domain/bring.cljs +++ b/frontend/src/arthur/domain/bring.cljs @@ -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})) diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index d7db89f..1c6f52b 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -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) diff --git a/frontend/src/arthur/domain/nest.cljs b/frontend/src/arthur/domain/nest.cljs index a706e1f..88b31d8 100644 --- a/frontend/src/arthur/domain/nest.cljs +++ b/frontend/src/arthur/domain/nest.cljs @@ -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))) diff --git a/frontend/src/arthur/domain/node.cljs b/frontend/src/arthur/domain/node.cljs index c337e82..5abb678 100644 --- a/frontend/src/arthur/domain/node.cljs +++ b/frontend/src/arthur/domain/node.cljs @@ -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 }") + (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")) diff --git a/frontend/src/arthur/domain/pick.cljs b/frontend/src/arthur/domain/pick.cljs index 91c63a3..24cbe4e 100644 --- a/frontend/src/arthur/domain/pick.cljs +++ b/frontend/src/arthur/domain/pick.cljs @@ -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)))) diff --git a/frontend/src/arthur/domain/pose.cljs b/frontend/src/arthur/domain/pose.cljs index d305fd6..75dbf36 100644 --- a/frontend/src/arthur/domain/pose.cljs +++ b/frontend/src/arthur/domain/pose.cljs @@ -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)) diff --git a/frontend/src/arthur/domain/project.cljs b/frontend/src/arthur/domain/project.cljs index 9d83172..28aba02 100644 --- a/frontend/src/arthur/domain/project.cljs +++ b/frontend/src/arthur/domain/project.cljs @@ -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." diff --git a/frontend/src/arthur/domain/sequence.cljs b/frontend/src/arthur/domain/sequence.cljs new file mode 100644 index 0000000..3cf8cc0 --- /dev/null +++ b/frontend/src/arthur/domain/sequence.cljs @@ -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)))))))))) diff --git a/frontend/src/arthur/domain/symbol.cljs b/frontend/src/arthur/domain/symbol.cljs index e852290..51102d1 100644 --- a/frontend/src/arthur/domain/symbol.cljs +++ b/frontend/src/arthur/domain/symbol.cljs @@ -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))))) diff --git a/frontend/src/arthur/domain/trace.cljs b/frontend/src/arthur/domain/trace.cljs index a26490a..5ea8018 100644 --- a/frontend/src/arthur/domain/trace.cljs +++ b/frontend/src/arthur/domain/trace.cljs @@ -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 [])))) diff --git a/frontend/src/arthur/events/edit.cljs b/frontend/src/arthur/events/edit.cljs index 1730285..63eda3f 100644 --- a/frontend/src/arthur/events/edit.cljs +++ b/frontend/src/arthur/events/edit.cljs @@ -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))) diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 5733626..d6a7e38 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -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]] diff --git a/frontend/src/arthur/flow/freeze.cljs b/frontend/src/arthur/flow/freeze.cljs index 67c4690..ab989a4 100644 --- a/frontend/src/arthur/flow/freeze.cljs +++ b/frontend/src/arthur/flow/freeze.cljs @@ -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 diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index 6716f47..41cd561 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -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 diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs index 0f10356..218df22 100644 --- a/frontend/src/arthur/subs/ui.cljs +++ b/frontend/src/arthur/subs/ui.cljs @@ -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]))) diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index f7a4ab0..50964cd 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -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 diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index b8f262c..5de5c40 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -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? diff --git a/frontend/test/arthur/domain/bring_test.cljs b/frontend/test/arthur/domain/bring_test.cljs index 3484228..d0ddbcc 100644 --- a/frontend/test/arthur/domain/bring_test.cljs +++ b/frontend/test/arthur/domain/bring_test.cljs @@ -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))))) diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index 3d12ebc..ee44659 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -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") diff --git a/frontend/test/arthur/domain/lane_test.cljs b/frontend/test/arthur/domain/lane_test.cljs new file mode 100644 index 0000000..1c739f8 --- /dev/null +++ b/frontend/test/arthur/domain/lane_test.cljs @@ -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]))))) diff --git a/frontend/test/arthur/domain/leaf_test.cljs b/frontend/test/arthur/domain/leaf_test.cljs index 663ceaf..77fd213 100644 --- a/frontend/test/arthur/domain/leaf_test.cljs +++ b/frontend/test/arthur/domain/leaf_test.cljs @@ -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") diff --git a/frontend/test/arthur/domain/nest_test.cljs b/frontend/test/arthur/domain/nest_test.cljs index 1912252..0fafeca 100644 --- a/frontend/test/arthur/domain/nest_test.cljs +++ b/frontend/test/arthur/domain/nest_test.cljs @@ -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)] diff --git a/frontend/test/arthur/domain/trace_test.cljs b/frontend/test/arthur/domain/trace_test.cljs index a9c3303..00cf994 100644 --- a/frontend/test/arthur/domain/trace_test.cljs +++ b/frontend/test/arthur/domain/trace_test.cljs @@ -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 diff --git a/frontend/test/arthur/events/export_test.cljs b/frontend/test/arthur/events/export_test.cljs index 8edc501..13d3ec7 100644 --- a/frontend/test/arthur/events/export_test.cljs +++ b/frontend/test/arthur/events/export_test.cljs @@ -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"}}}}}) diff --git a/frontend/test/arthur/events/sequence_test.cljs b/frontend/test/arthur/events/sequence_test.cljs new file mode 100644 index 0000000..eef2650 --- /dev/null +++ b/frontend/test/arthur/events/sequence_test.cljs @@ -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])))))) diff --git a/frontend/test/arthur/export_test.cljs b/frontend/test/arthur/export_test.cljs index 328ad72..5cad0d6 100644 --- a/frontend/test/arthur/export_test.cljs +++ b/frontend/test/arthur/export_test.cljs @@ -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" diff --git a/frontend/test/arthur/flow/freeze_test.cljs b/frontend/test/arthur/flow/freeze_test.cljs index 09512cf..1fe8139 100644 --- a/frontend/test/arthur/flow/freeze_test.cljs +++ b/frontend/test/arthur/flow/freeze_test.cljs @@ -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))) diff --git a/frontend/test/browser/sequence.mjs b/frontend/test/browser/sequence.mjs new file mode 100644 index 0000000..32693ae --- /dev/null +++ b/frontend/test/browser/sequence.mjs @@ -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}`); + } +}