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..647f798 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. @@ -230,10 +235,30 @@ combines: ```clojure {:animated? true :interp :hold :dense {...} :generated {...} - :over [{:blend :offset :keys {88 [2 0], 96 [0 0]}} - {:blend :replace :keys {104 [[3 7] [4 7] …]}}]} + :over [{:id :nudge :support [88 98] :op :offset + :values {:animated? true :interp :linear :keys {88 [2 0], 96 [0 0]}}} + {:id :redraw :support [104 105] :op :replace + :values {:animated? false :value [[3 7] [4 7] …]}}]} ``` +A LAYER'S VALUES ARE A CHANNEL, which is what keeps a constant adjustment, a +ramp and a return motion from being three mechanisms: a framed one says the same +thing on every frame it covers, a keyed one moves. They read through `value-at` +and `cursor` like any channel, one reading head each, so the specification and +the playback path share their blending and differ only in how they read — and a +layer's values may not carry layers of their own, which the stack already +orders. + +`:support` is half-open and explicit, `[in out)`. Outside it a layer is inactive +and the base evaluates exactly as it did before, which is the difference between +a bounded correction and inserting boundary keys — the latter alters the +neighbouring segments. And a layer has NO TIME SPACE of its own: its support and +its values' keys are in the frames the base channel's keys are in, the node's +own. A correction on a lane is therefore in lane frames and reaches across the +drawings exposed under it; one on a single occurrence is in that occurrence's +frames and travels with it when the exposure moves. Ownership had already +answered the question, so there is no field to disagree with. + - **`:offset`** adds a delta to the base. "Nudge the mouth two pixels right for ten frames" survives a re-freeze at different parameters, because it was never a position — it was a correction. @@ -243,6 +268,22 @@ This is what `docs/design.md` means by an override layer, and it is why re-freezing is safe: the base is regenerated, the layers are untouched. It is Blender's NLA blending and AE's effect stack at one property. +WHEN THE BASE OUTGROWS A CORRECTION it is a CONFLICT, which is neither a dropped +layer nor an applied one. Turning `:verts` gives the mouth a different number of +points, and an `:offset` is a row of components that has to match: so the +regeneration records `:conflict` on the layer, the layer stays in the document, +the picture is the base meanwhile, and `clip/conflicts` is the list a view +offers to resolve. Deliberately not `problems` — the document loads and saves +fine, it just contains a decision nobody has made yet. A later regeneration +that restores the shape clears the mark. Only `:offset` can conflict; `:replace` +states a whole value and has nothing to agree with. + +A correction is NOT a hand placement. `regenerate-head` leaves the head's +authored channels alone once somebody has placed it by hand, and it compares the +channels WITHOUT their layers to decide: otherwise the first correction anyone +made would stop the head following re-measurement forever, which is the opposite +of what a layer is for. + Layers are what "set it by hand" means for anything measured, and the measured channel does not need to know. A hand-set gaze is an `:over` on `[:xform :pos]` of the iris; a hand-set mouth shape is an `:over` on @@ -491,7 +532,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..c38ae8a 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. @@ -819,7 +822,7 @@ collaborator's keying. The fix is addressing, not an algorithm: ``` palette -sequence/:sid +lane/:sid clip/:cid/timing clip rate clip/:cid/subject/:sid tracked subject and settings clip/:cid/feature/:fid tracked feature and settings diff --git a/docs/lane-handoff.md b/docs/lane-handoff.md new file mode 100644 index 0000000..0357e74 --- /dev/null +++ b/docs/lane-handoff.md @@ -0,0 +1,184 @@ +# Lane and cel handoff + +Status (2026-09-30): the lane model is implemented through its commands and its +first two views. Cels are ordinary nodes with their own playback clock, the +timeline draws them as one row, the cel sheet draws frames down and lanes across, +and both views issue the same commands. Correction layers evaluate and survive +regeneration. What is missing is the commands that make a correction. + +The commits beginning at `3d3c1bb` are the argument for the model and are worth +reading before touching what they did — they are the design record, more than +this file is. + + 3d3c1bb An occurrence is a node, with a clock of its own + 9446829 Reuse, duplicate and make unique: deciding what is shared + 26517af A position is an argument, not another command + 94c0a21 A correction is a layer, and a layer's values are a channel + 72b57e3 Regenerate the base, keep the hand work, and say when you cannot + 76106d3 The shot is as long as somebody said it was + 598c186 One word for one thing: it is a cel + +## Read first, in this order + +1. [The Lane Model](lane-model.md) — the design, and the status note under + *Proof obligations* says what is built. It supersedes `animation-model.md`, + `timing-model.md` and `architecture.md` wherever they overlap. +2. `frontend/src/arthur/domain/lane.cljs` — every command, and the reasoning in + its docstrings. +3. `frontend/test/arthur/domain/lane_test.cljs` — what the model is asserted to + do. It is the fastest way to see the shapes. +4. `frontend/src/arthur/domain/channel.cljs`, the correction-layer section. + +## Vocabulary — one word for one thing + +Renamed in `598c186`, after four words had accumulated for one object. Use these +and do not reintroduce the others. + +| word | means | +| --- | --- | +| instance | the `:kind`. The general thing, anywhere in a document | +| cel | an instance in a lane. One drawing, held for some duration | +| lane | a group with `:layout :sequence` | +| drawing | the content a cel names — an ordinary symbol | +| placement | ONLY where a node sits: `nest/placement`, and the transform that puts a face on the stage. Never the node itself | + +`occurrence` and `exposure` are not words for a cel. **`exposure` means something +else and still does**: `:time :expose` is how many frames each step of a subtree +lasts, which is what shooting on twos is — `node/expose`, `clock/exposed-frame`, +`subs/render ::exposure`. Keeping these apart is why the block is called a cel. + +`:layout :sequence` stays as the field, and is the one place two words are kept +on purpose: the layout names the RULE — children follow one another and may not +overlap — and a group carrying it is called a lane. `node/lane?` is where they +meet. + +The second view is the CEL SHEET, not the exposure sheet. + +## Decisions already made — do not re-litigate + +These were each argued out and are load-bearing. Changing one is a design +decision, not a cleanup. + +- **The shot length is authored.** `:frames` is the symbol's window; the + occupied extent of its lanes is a different fact derived from the cels. A + command grows the window only when the caller passes `:extent :grow-symbol`, + and never shrinks it. Blanking the end of a shot leaves empty frames at the + end, because deriving the window from the extent would make deleting the last + drawing silently shorten the film. `lane/finish`. +- **Placing ripples; overwrite is `blank` then non-rippling placement.** + `lane/overwrite-drawing` composes those pieces as one transaction. Insertion + retains its ripple rule; overwrite does not move any surviving cel. +- **A position inside a cel refuses and names `split`.** One command must not + quietly perform two. The UI offers the retry. +- **A correction has no time space of its own.** Its `:support` and its values' + keys are in the frames the base channel's keys are in — the node's. A + correction on a lane is in lane frames and reaches across the drawings under + it; one on a cel travels with that cel. Ownership already answered it. +- **A layer's values are a channel.** Constant, ramp and return motion are one + mechanism. Do not add a second way to say what a value is over time. +- **A conflict is not a `problem`.** A document whose topology outgrew a + correction loads, evaluates and saves; `clip/conflicts` lists the decisions + waiting for a person. `problems` means the document will not load. +- **Refuse rather than guess.** Every command returns `{:clip :selection}` or + `{:refused why}`, never a half-applied edit. Where the model needs a choice + nobody has made, refusing and saying why is the behaviour, not a placeholder. +- **A cel is not a row.** Rows, expansion and selection are editor state. The + document has never known about rows and must not learn. + +## Next steps, in order + +1. **The commands that make a correction** — Constant adjustment, Ramp, Return + motion over a selected range, per `lane-model.md`. The evaluator is done and + has no opinion about how a range or a motion shape is chosen, which is now a + view question. A panel also needs to offer `clip/conflicts` for resolution. + Note the one open question: a correction needs a stable `:id` from somewhere, + and cel ids come from the caller because this namespace is pure. +2. **Slip source and retime.** Both have real design questions open and the doc + says to refuse rather than approximate: retime needs a defined warp and + interpolation behaviour, and is not moving keys whose numbers happen to fall + inside a selection. +3. **Deleting reused content.** Reference discovery exists (`node/sources`, + `clip/places`, `clip/contains-symbol?`); the policy does not. +4. **Collaboration.** `lane-model.md` is explicit that one leaf per channel does + NOT solve two people editing different keys of the same channel. No conflict + policy exists for that. + +## Mechanisms to reuse — these keep paying out + +- **`:span` is in the node's OWN frames** and `:time` says where they land in + the lane. Moving an edge of a cel is therefore one write to `:span`, with + `:time` and `:playback` untouched. This is why split costs nothing, why the + two halves of a split go on meaning what the one cel meant, why trimming the + front of a playing insert starts it later into its animation instead of + restarting it, and why extending a hold leaves lane keys alone. `lane/local` + and `lane/edged` are the whole geometry; trim, split and blank are all it. +- **`lane/finish`** is the one commit path: it validates, applies the shot-length + policy, and returns the refusal. New commands go through it. +- **`:required-frames` plus the retry event** is the pattern for "this needs a + decision you have not made": the domain reports what it would need, the UI + offers one button. `events/ui/lane-retry`. +- **`lane/lane-frame`** converts a symbol frame to a lane frame, or returns nil + through a stepped or looping lane where there is no single answer. Nil refuses; + it never snaps. +- **`channel/conflict-with`** is the rule for whether one offset fits a base, + used by `conflicts` and regeneration. Validation additionally follows prior + replacement layers, so it cannot approve a stack that throws when read. +- **Generated sampling applies to the base, not the hand correction.** Picture + rate and pose selection may choose an earlier generated frame; correction + support and values still read the node's current authored frame. +- **Two test patterns worth copying.** `the-cursor-agrees-with-the-specification-in-any-frame-order` + holds the optimized cursor to `value-at` in forward, backward and random order + — add a case to it for any new channel shape. And `drawn` in `lane_test` + samples every frame before and after an edit, which is how split and trim are + proved to change nothing: state a claim as "the same picture" rather than as + numbers computed by hand. + +## Known gaps and traps + +- **Audio lanes do not work.** `symbol/lane-problems` requires `:instance` + children, so an audio node in a lane is rejected outright. `lane-model.md` + says a lane may hold visual OR audio cels and should reject only a mixture. +- **`:z` is required on cels and means nothing there.** A lane never has two + cels on one frame, so draw order between them cannot matter. `node/problems` + requires `:z` on every node uniformly, which is its own kind of simplicity — + but the field is noise on a cel. +- **`channel/offset-onto` throws** on a shape mismatch that no regeneration has + recorded as a conflict. That is deliberate — a correction that silently does + not take is the failure the design exists to prevent, and `channel/problems` + catches the authored case — but it is a throw in the read path, so any new + producer of layers must not create a mismatched one. +- **`docs/timing-handoff.md` is a separate, unreconciled thread.** Performance- + pose selection and plate drawings/tracing, instance-specific picture-rate + requests, `pose/put-cut` addressing only `:main`. It predates the lane model + and nobody has squared the two. +- **The button row in the timeline pane is a test harness, not a design.** It is + how the commands were made reachable and provable. `lane-model.md` describes + the real cel action strip, the breadcrumb and the location bar; none exist. +- **`shadow-cljs release app` clobbers the dev bundle.** Both builds write + `../static/arthur/js`, which Django serves, and the optimized build does not + export the `arthur` global — so after a release the browser tests fail with + `ReferenceError: arthur is not defined`. Run `npx shadow-cljs compile app` to + restore it. A running `watch app` does not notice; it rebuilds on the next + source change. + +## Running it + +From `frontend/`: + + npx shadow-cljs compile test && node out/node-tests.js # 429 tests, 5,767 assertions + npx shadow-cljs compile app # the bundle Django serves + npx shadow-cljs release app # then `compile app` again — see above + +The browser tests need the Django dev server up (`mise exec -- python manage.py +runserver 8778` from the repo root) and a compiled dev bundle: + + node --experimental-websocket test/browser/lane.mjs # the lane/cel flow + CHROME=/usr/bin/chromium node --experimental-websocket test/browser/take.mjs + +`take.mjs` defaults to a macOS Chrome path, hence `CHROME=`. It writes a real +project to the local server by design; `lane.mjs` never writes to the server. + +From the repo root: `mise exec -- python manage.py test clips` — 56 tests. + +Documents are schema 3. A version 2 document is not read and nothing converts +one; there is no backward compatibility to preserve anywhere in this work. diff --git a/docs/lane-model.md b/docs/lane-model.md new file mode 100644 index 0000000..6bdd9f3 --- /dev/null +++ b/docs/lane-model.md @@ -0,0 +1,581 @@ +# The Lane Model + +Revised 2026-09-30. Target design. Cel ownership, source playback, the +content and cel commands, placement anywhere in a lane, overwrite, a one-row cel +strip, a frame-down cel sheet and the correction-layer evaluator are implemented; +the commands that produce a correction and the retiming commands are not. +See the status note under +[Proof obligations](#proof-obligations-and-implementation-order). + +[Lane and cel handoff](lane-handoff.md) records what is built, the decisions +that are settled, and what to do next. + +This revises the Claude artifact [The Lane Model](https://claude.ai/code/artifact/cd42981d-ed08-493f-94df-b7dd6657f0e6). +Its prose and diagram source were recovered from session +`1c603f71-84eb-498e-aeaf-4c0346f1f513`; the live artifact was not accessible for +reading or editing here. This repository document is the revised design. The +original artifact has not been updated, and edits made there outside the recorded +session may not be represented here. + +For the subjects covered here, this document supersedes the original artifact +and conflicting proposals in `animation-model.md`, `timing-model.md`, and +`architecture.md`. Those documents retain useful detail about the existing system. + +## Goal and compatibility policy + +Arthur is one animation document with several ways to see and edit it: drawing +on the stage, arranging clips, timing cels, editing curves, and generating +motion from footage. Each view exposes relevant facts and invokes shared editing +operations. Switching views must preserve the meaning of the work. + +The user explicitly requires no backward compatibility. Replace obsolete shapes, +APIs, and tests when a better model requires it. Do not retain compatibility +branches, adapters, or migrations solely to preserve the current document format. +A format marker can reject unsupported files clearly; it does not promise to +convert them. This policy does not authorize deleting existing user assets. + +Simplicity means predictable composition, clear ownership, and few independent +rules. Minimizing field count is secondary to representing independent choices. + +## What stays + +- A symbol is the one container for authored scene nodes. A drawing can be a + one-frame symbol; an animation uses the same container over more frames. +- Nodes have stable identities and flat parent references. Shared content is + referenced rather than copied implicitly. +- Animatable properties are addressed by channel paths. Generated and authored + values participate in the same evaluation machinery. +- Authored data, generated blocks, and source media remain separate. Documents + reference immutable blocks; caches and resolver indexes remain derived. +- A pure reference evaluator specifies the result. Playback, seeking, preview, + export, and optimized cursors must agree with it. +- Existence, visibility, and missing measured data remain distinct facts. + +## Content, cels, lanes, and rows + +These have different identities and responsibilities: + +| Concept | Owns | Example | +| --- | --- | --- | +| Content | Reusable nodes and their animation | Drawing `a2`, an animated head, or a sound asset | +| Cel | One use of content, its interval, source playback, and local treatment | `a2` exposed on frames 12–16 | +| Lane | A sequence of cels and shared properties | The girl's drawings and the girl's overall transform | +| View row or column | Presentation and editor state | Timeline row, cel-sheet column, or property curve | + +Use the existing instance/node identity mechanism for cels. A cel +should not acquire a second identity system just because it is shown as a cel. +Lanes group cels; they do not introduce another node-holding content type. +The concrete candidate below uses existing group and instance nodes; its ownership +boundaries are part of the design. It is now the implemented shape, and the field +spellings below are the ones the runtime reads. + +Each cel has a stable ID. Moving it, changing its hold, swapping its source, +or trimming it preserves that ID. Repeating it creates a new cel that may +reference the same content. A split retains the original ID on the left and gives +the right piece a new ID; commands return the resulting selection explicitly. + +Cels are the canonical authored arrangement. A source-at-time channel or +interval index may be compiled from them for evaluation, but is not a second +editable copy of the schedule. This replaces the earlier proposal that every cel +must be represented solely as a source key. Ordinary property animation still +uses lightweight keys; it does not need cel objects. + +A lane has non-overlapping half-open cel intervals `[start,end)` +in its own time space. Uncovered intervals are gaps. Empty lanes are valid. +Compositing and simultaneous sounds are represented by multiple lanes or ordinary +scene composition; an accidental overlap never silently selects a winner. +Transitions, if added, need explicit overlap and mixing semantics. + +Properties can belong to content, one cel, or the lane. For example: + +- Rotate the reusable drawing: all its uses change. +- Rotate one cel: only that cel changes. +- Animate the lane's rotation: whichever drawing is showing follows it. + +A cel can have its own transform, gain, corrections, and source timing +while remaining a block in the same timeline row. Independent treatment never +requires a new row or an otherwise unnecessary wrapper symbol. + +### Concrete candidate: a lane and ordinary instances + +A lane is a group node with `:layout :sequence`. Its cels are ordinary +instance nodes whose `:parent` points to the group. All remain in their symbol's +flat node map. The sequence constraint is document semantics; which rows the UI +expands remains editor state. Ordinary groups retain unconstrained composition. + +This example is a document the runtime accepts, built and evaluated by +`frontend/test/arthur/domain/lane_test.cljs`. Times here are zero-based. Channels +use the existing representation; cel source references and playback have +replaced the `[:source]` channel, which no longer exists. + +```clojure +;; Within :main's :nodes; referenced drawings/animations live in :symbols. +{:girl + {:id :girl :kind :group :layout :sequence :z "b" + :channels {[:xform :rot] + {:animated? true :interp :linear :keys {0 0, 6 30, 12 0}}}} + + :cel-a + {:id :cel-a :kind :instance :parent :girl :z "a" + :time {:at 0 :rate 1} :span [0 4] + :source {:symbol :drawing-a} + :playback {:in 0 :speed 0 :end :stop}} + + :cel-b + {:id :cel-b :kind :instance :parent :girl :z "b" + :time {:at 4 :rate 1} :span [0 4] + :source {:symbol :drawing-b} + :playback {:in 0 :speed 0 :end :stop} + :channels {[:xform :pos] + {:animated? true :interp :hold :keys {0 [0 0], 1 [2 0]}}}} + + :animated-insert + {:id :animated-insert :kind :instance :parent :girl :z "c" + :time {:at 8 :rate 1} :span [0 4] + :source {:symbol :wave} + :playback {:in 3 :speed 1 :end :stop}}} +``` + +The lane's rotation reads lane time. Each cel's channels read cel +time. Its content reads source time. The stills sample frame 0, while the insert +samples source frames 3, 4, 5, and 6. The group transform composes with the +cel transform and then the content's own transform. + +`:span` remains in the node's own coordinates, consistent with ordinary nodes. +The interval in lane time is derived through `:time`; do not also store parent +start/end values. Sequence children require finite intervals and positive +placement rates. Ordering and overlap checks use the mapped intervals, not `:z`. +The sequence group may contain visual cels or audio cels; its +capability must reject an incompatible mixture rather than infer it per frame. + +A source reference is fixed within a cel. The lane changes content when +another cel becomes active. This is a deliberate revision of the original +diagnosis that making `:of` a channel was necessary to avoid vertical growth: +multiple instances can occupy one row when the view presents their containing +sequence. A lane-level source schedule is therefore derived, not authored twice. + +## Source selection and source playback are independent + +The current implementation makes framed sources play and keyed sources hold. +Retire that rule. Channel storage shape must not determine playback behavior. +Adding or removing a key must not turn a still into an animation or vice versa. + +A cel names content and describes how its source time is sampled. In the +basic case, after mapping lane time into cel time: + +```text +source_time = in_point + speed × cel_time +``` + +A newly created cel starts at local time zero. Moving it preserves this +origin relative to its content. Trimming can narrow its local support without +resetting that origin; split pieces likewise preserve the source and property +values at the cut. Trimming, slipping, and retiming are distinct operations with +explicitly different effects on the interval and the source map. + +| Intent | Source playback | +| --- | --- | +| Hold a drawing | Constant source frame, equivalently speed 0 | +| Play an animated symbol | Advancing source time, normally speed 1 | +| Cut between animations | Several cels, each with its own in-point and speed | +| Mix stills and animation in a lane | Constant and advancing maps in the same sequence | + +The source reference itself is discrete and never numerically interpolated. +Interpolation belongs to properties that support it; a property registry should +declare value types, defaults, and permitted interpolation and correction modes. +Generic key toggles must consult those capabilities rather than assume every +non-boolean value can be tweened. + +Define source bounds and end behavior explicitly: stop contributing outside the +source, hold an endpoint, or loop an explicit range. A still uses a valid constant +frame. A loop uses a nonempty half-open range and a defined modulo rule. Playback +never guesses these policies from whether a channel happens to have keys. + +Audio shares cel arrangement, trimming, gain ownership, and clock mapping. +It does not inherit visual frame-hold semantics: holding one audio sample is not +an audio freeze effect. Validate supported playback policies by media capability. +Actual audio scheduling must follow active cels, including gaps and cuts, +rather than playing every sound reachable through a structural reference. + +## Time spaces and sampling + +Name the relevant space whenever an API accepts a time or range: project, +symbol/lane, cel, or source. Store authored frame coordinates exactly; +avoid cumulative rounding when moving through nested mappings. Quantize at a +declared sampling boundary, not at every traversal step. Audio also needs its +continuous clock/sample space rather than visual frame quantization. + +A hold is an evaluable time map with no unique inverse. A loop can map many +displayed cels to one source time. APIs must distinguish forward sampling +from inverse editing, and expose enough context to resolve a cel or +explicitly refuse an ambiguous operation. Do not report a missing time map merely +because inversion is unavailable. + +Separate invertible placement timing from source sampling. A zero source speed +can mean hold without making the cel's own edit clock non-invertible. +Reparenting through changing transforms or non-invertible timing must either +preserve the full result by an explicit bake or return a reason it cannot; a +matrix captured at one frame does not prove preservation across the animation. + +The source's frame step, generated-pose sampling, and the lane's transform clock +are independent scopes. Drawing on twos must not accidentally step a smooth lane +transform. An explicit whole-subtree stepping operation can exist separately. + +Share the quantization primitive where possible, but retain its units, phase, +rounding policy, and order relative to retiming and lead. The original suggestion +that cel and picture-rate sampling are simply one floor is insufficient: +noninteger grids and source-frame quantization require specified behavior. +Identity timing can be implicit; remove `:time :mode` if it only duplicates that. + +A cel interval is authored. Lane content extent is derived from its +cels, including the explicit end of the last one. A separately authored +container trim/window is legitimate when it intentionally gates children. Do not +conflate that window with occupied extent or infer a final hold from the next key +when no next key exists. A range of frame numbers alone cannot encode visibility +or a missing measurement. + +## Shared editing operations + +Every view issues the same domain commands. A command accepts an explicit target +and edit policy, computes a valid change, and returns the change, resulting +selection, and any refusal reason. A button and a drag must not implement two +versions of cel extension. + +An edit target identifies the symbol, cel path, selected entities or +properties, and the time range with its space. Navigation also distinguishes +editing shared content directly from editing it through a particular cel. +Crossing a source cut must not silently redirect an active drawing edit to a +different symbol: retain the explicit content target until navigation changes it. + +Core commands include new drawing, reuse drawing, duplicate drawing, make unique, +blank range, split, trim, move, extend cel, slip source, retime, and apply a +bounded property edit. Ripple/overwrite policy and the set of affected lanes are +explicit command arguments. Preview consequences before committing a gesture. + +New drawing creates fresh empty content and a cel. Blank range removes +content coverage without inventing a hidden drawing. These are different actions. +Reuse creates another cel pointing at existing content. Duplicate creates +a new content identity. Make unique rebinds the selected cel only. + +Copy semantics must specify nested sharing. A normal content copy duplicates its +owned nodes and channels while preserving references to other reusable symbols. +For a fully independent drawing assembled from nested symbols, provide an +explicit deep-copy operation with ID remapping. Never promise decoupling while +leaving the relevant edited object shared. Immutable media blocks may remain shared. + +Commands are atomic undo transactions, even when they touch several leaves. +Pointer movement and keyboard invocation use explicit begin/preview/commit or +cancel boundaries; a timing heuristic alone must not decide user intent. +Collaboration applies a transaction consistently, validates affected references, +and detects conflicts at the owned data being changed. One leaf per channel does +not solve simultaneous edits to different keys of that same channel; define a +conflict policy rather than claiming that granularity solves all collaboration. + +### Default timing behavior: cel edits preserve lane keys + +Working default from the follow-up discussion: extending a drawing's hold changes +cel timing, leaving lane animation at its authored times. The user raised +keeping keyframes in place as a possibility; this is the proposed predictable +default, not a claim that they selected every timing policy below. + +Ownership supplies the remaining rule: properties attached to a cel +travel with it. Extending its end does not stretch those properties; moving it +changes where their existing local times land. No per-key attachment flag is +needed to recover ownership that the document already expresses. + +For the concrete example, extend `:cel-a` by two lane frames with ripple: + +| Fact | Before | After | +| --- | --- | --- | +| Cel A's lane interval | `[0,4)` | `[0,6)` | +| Cel B's lane interval | `[4,8)` | `[6,10)` | +| Animated insert's lane interval | `[8,12)` | `[10,14)` | +| Girl's rotation peak | Lane frame 6 | Lane frame 6 | +| B's position change | B frame 1, lane frame 5 | B frame 1, lane frame 7 | +| Insert's first source frame | Source frame 3 | Source frame 3 | + +The rotation peak now coincides with a different point in the drawing sequence. +That is the intended consequence of changing cels underneath timed motion. +The position correction stays attached to drawing B's cel. Neither the +background's keys nor audio on another lane moves. + +The command contract for this edit names the symbol and cel, a delta in +lane frames, `:ripple` behavior, and an explicit scope of cel timing. It +extends A's local support by the delta converted through A's placement rate, +and shifts subsequent cel placements by that delta in lane time. It does +not modify any channel's key map, source in-point, or playback speed. Reject a +nonpositive resulting duration. Validate and commit the entire change together. + +The symbol's authored end is another explicit boundary: preview an overflow and +offer to extend the symbol or cancel. A command can request that extension as +part of its transaction; it must not silently truncate later cels or grow +other uses of a shared symbol. In the example, a 12-frame symbol needs an explicit +extension to 14 frames or the edit must be refused without partial changes. + +Retime performance is a separate operation over explicitly selected cels +and channels. It applies the same time transformation to their relevant clocks, +keys, and correction supports. Stretching an interval requires a defined warp +and interpolation behavior; it is not merely moving keys whose frame numbers +happen to lie inside the selection. Until supported, refuse this operation +rather than approximating it with a cel ripple. + +The initial UI should default stage transforms to the lane when drawing in a cel +workflow, so movement usually remains independent of cel timing. The +inspector names the target: lane motion, this cel, or shared drawing. +Changing that scope is explicit. It changes what the edit means, not just which +panel happens to be open. + +## Three-frame rotation and correction layers + +A range says where an edit applies; it does not specify the motion. Offer distinct +commands for a constant adjustment, a ramp, and a return-to-start motion. For UI +frames 10–12, the internal range contains exactly three frame samples after +conversion from the displayed numbering convention. + +- Constant adjustment: the same offset throughout those three samples. +- Ramp: interpolate from the specified start value to the target over the range. +- Return motion: interpolate from the starting value to a peak and back. + +For a return motion sampled on three frames, the values can be `0, angle, 0`. +Outside the selected range, the underlying animation must evaluate exactly as it +did before. A range-scoped correction layer expresses this directly; blindly +inserting boundary keys can alter neighboring segments or destroy existing motion. + +Implement corrections as an ordered stack over the base channel. Each correction +has stable identity, explicit support interval, blend operation, and values in a +named time space. Outside its support it is inactive. `replace` can supply a value +over an absent base; `offset` cannot offset a nonexistent value. Blend capability +depends on property type, and geometry corrections require compatible topology. + +Regeneration replaces the generated base and preserves corrections. If changed +topology or removed targets make a correction incompatible, report a resolvable +conflict instead of silently dropping or misapplying it. Provenance explains +where the base came from; explicit sampling policy determines its playback. + +This is core to the workflow: generate motion, correct it by hand, adjust the +generator, and keep the corrections. It should be proven before adding many views. + +## Other unifications worth keeping + +Pose choices, tracing-frame choices, and ordinary held values should share the +channel evaluator and cursor infrastructure. Preserve their different ownership, +fallback behavior, and sampling scope. A pose choice must address the relevant +content/feature explicitly; switching to another symbol must not accidentally +reuse a track just because both symbols contain a node with the same local name. + +Keep the two animation idioms distinct: keyed geometry modifies one mark over +time; drawing substitution selects content that may have different structure. +Linear geometry interpolation requires compatible vertex correspondence, not +merely two drawings that happen to look related. + +Derived library grouping may collect drawings used by a single lane. This is a +convenience, not ownership or deletion authority. Reference discovery for cycle +validation, copying, and deletion examines all structural references, including +currently inactive cels. Authored folders, favorites, and labels remain +legitimate user data even when the UI could have suggested defaults. + +## UX: location, selection, and controls + +The breadcrumb sits above the timeline and states the editing location, shared +content identity, and cel context when applicable. Show local time and +its project context where a useful mapping exists. Holds and loops need an honest +description instead of a fictitious unique global frame. + +Creation 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 cel unique. Names help identify content; +linked-use indicators must rely on IDs, because different drawings can share names. + +| Surface | Primary scope and controls | +| --- | --- | +| Topbar | Project name, save/open/export, project rate and stage size | +| Location bar | Breadcrumb, add lane/content, shared-content context | +| Cel action strip | New drawing, duplicate drawing, hold longer/shorter, blank range | +| Lane header | Lane selection, lock, mute/solo where applicable, onion settings, expansion | +| Stage tools | Drawing and transform modes, active target and scope | +| Inspector | Selected content/cel/lane properties and valid key controls | + +Cel actions have visible contextual buttons, shortcuts, a context menu, and +command-palette entries. These are different entrances to the same commands. +Shortcut names from the original sketch (`N`, `D`, `H`, `B`, `K`) are provisional; +their meanings must match the visible labels and avoid tool conflicts. + +The inspector normally edits values and the timeline normally edits timing, but +this is an organizational default. Numeric duration and in-point controls are +useful inspector edits to the same domain facts. Do not ban a convenient control +just to preserve a visual division. + +Default nesting navigation enters content; expanding a lane reveals properties. +Other views may show hierarchies differently without changing the document. +Tabs can pin explicit locations. Zoom, expansion, onion preferences, and current +selection are editor state rather than animation content. Persistent workspace +preferences can be saved separately. + +## A session, revised + +1. In `main`, create a girl lane and a new drawing. Draw; use New drawing (`N`) + to create the next one with the previous cel ghosted behind it. +2. Use Duplicate drawing (`D`) when the current shapes are the starting point. + Use Reuse drawing for a deliberately linked cel. The UI shows the + difference before an edit can change other uses. +3. Time the performance. Hold longer (`H`) extends the selected cel and + ripples later cels in the explicitly targeted lane. A trim gesture + can use overwrite instead. The preview shows which boundaries will move. +4. Choose a two-frame default cel for newly created drawings, or run a + separate Retime cels command on a selected range. This does not quantize + lane transforms or silently retime already authored cels. +5. Place the background in a lane below. Its source holds one frame throughout + its cel. Key the lane's X position at the beginning and end and choose + linear interpolation. The background slides while the girl's drawings cut. +6. Select three frames on the girl's lane, choose Return motion, and rotate to + the desired peak. A bounded rotation correction affects the girl across any + drawing boundaries in that range. Existing motion survives outside it. +7. Insert a playing animated symbol among the girl's held drawings. Set that + cel's source playback to advance. No lane conversion is required. + +The timeline shows named cel blocks with property marks and optional curve +subrows. The cel sheet shows the same cels by frame and lane. The +graph editor edits the same properties; the stage resolves the same document. +Onion skin is configurable and counts neighboring cel events, skipping gaps +by default; a long hold does not consume the budget. Repeated uses of the same +drawing remain distinct events. Deduplicating identical ghosts is a display option. + +## Proof obligations and implementation order + +The source-channel prototype has been removed: a cel names one symbol +and carries its own playback clock, and `node/problems` rejects the old +`[:source]` channel. What a lane IS lives in `arthur.domain.symbol` beside the +other rules about a node map; `arthur.domain.lane` holds the commands over +one — add lane, place a drawing (new, reused or duplicated), make unique, split, +trim, move, blank and extend hold. Each is one history step, and each refuses rather than +half-applying. The timeline draws a lane's cels as cel blocks on the +lane's own row, and offers Make unique only where the selected cel actually +shares its drawing. + +There is ONE placement function and a position argument, so appending is not a +different operation from inserting: `:end` is a position like any other, the one +where nothing has to move. Placing ripples — cels at or after the +position move later by the new cel's duration — and `:keep` versus +`:grow-symbol` still decides what happens at the shot's end. OVERWRITE is not a +policy argument yet, deliberately: taking frames away from the cel +already there is trimming, and until `trim` exists, placement that would need it +refuses instead of approximating it. A position inside an existing cel +refuses too, and names `split` — one command does not quietly perform two. + +Splitting turned out to cost almost nothing, which is evidence for the +representation rather than for the command. The two pieces keep ONE `:time` and +differ only in `:span`, so the right piece's own frames carry on where the +left's stopped and its source clock, keys and corrections go on meaning what +they meant — a held drawing holds the same frame either side, a playing insert +plays through the cut without a seam, and the test for it samples every frame +before and after and asserts the picture is identical. That falls out of `:span` +being in the node's own coordinates; it is not something split arranges. + +Content copies are shallow by default and keep their references to other +symbols; `:deep? true` is the explicit copy that shares nothing, so the promise +of independence is only made where it is kept. + +THE SHOT LENGTH IS AUTHORED, which is the decision the range commands forced. +`:frames` is the symbol's window — how long the shot IS — and the occupied +extent of its lanes is a different fact derived from the cels. A command +grows the window only when the caller says `:grow-symbol`, and never shrinks it: +blanking the end of a shot leaves a shot with empty frames at the end, because +that is a true statement about what somebody authored, and deriving the window +from the extent would make deleting the last drawing quietly shorten the film. +`finish` keeps the two numbers apart by name now rather than by a `max` that +read like an accident. + +Trim NARROWS one edge and moves nothing else; lengthening is `extend-hold`, +which carries the ripple and shot-length policies because it needs them. +Move is one write to `:time :at` and REFUSES a destination that would overlap, +because moving a drawing and re-timing the ones around it are different +intentions — clear the room with `blank` or `trim` first, which is the +composition. Blank leaves a gap and does not close it; a cel wholly inside +the range goes, one overlapping an end is trimmed to it, and the one spanning +the range is split. Their drawings stay in the library, since a lane does not +own its content. + +All three are the same geometry as `split`: a `:span` is in the cel's own +frames, so moving an edge is one write and `:time` and `:playback` are never +touched. That is why trimming the front of a playing insert starts it later into +its animation instead of restarting it — the difference between trimming and +slipping, and the reason they stay separate commands. + +Correction layers EVALUATE. `channel/problems` used to refuse an `:over` stack +and `value-at`/`cursor` used to throw on one; both now read it, and the +agreement test that holds the optimized cursor to the specification covers +stacked channels in forward, backward and random frame order. A layer's values +are themselves a channel, so a constant adjustment, a ramp and a return motion +are one mechanism; `:support` is half-open and a layer is inactive outside it; +and a layer has no time space of its own, because the node its channel is on +already has one. Nothing had to change in the codec — a channel is one leaf, so +a correction persists inside it — and nothing had to change in validation +plumbing, since `node/problems` already reports every channel's problems. + +Both halves of ownership are under test at lane level: a three-frame correction +on the girl's lane reaches across the drawing boundary beneath it and leaves +every frame outside its support identical, and a correction owned by one +cel travels with that cel when a hold before it grows. + +Regeneration keeps them, which is the obligation the layer design exists to +meet: `rebased` replaces a base and carries its corrections across, and a +correction the new base no longer fits is MARKED rather than dropped or +misapplied — `clip/conflicts` lists those for a view to offer, separately from +`problems`, because a conflict is a decision nobody has made yet and not a +document that will not load. Turning the mouth's `:verts` knob is a real +topology change and is what the test uses. Two latent faults turned up there and +are fixed: `regenerate-head` compared authored channels to measured ones +directly, so the first correction on the head would have stopped it following +re-measurement for good; and an incompatible offset threw in the read path, +which would have taken the stage down on exactly the case the model says to +report. + +Overwrite is `blank` followed by non-rippling placement, composed inside one +transaction; insertion keeps its ripple rule. Still unbuilt: slip source, +retime, and deleting reused content. A lane cannot hold AUDIO cels — `lane-problems` +requires visual ones, though this document says a lane may hold either and +should reject only a mixture. + +What is NOT implemented is a command that produces a layer — the doc's Constant +adjustment, Ramp and Return motion — and with it the question of how a view +offers those three over a selected range, and how it offers a conflict for +resolution. Slip source and retime are also not implemented; a refusal is the +current behavior where the model demands an explicit choice nobody has made yet. +The cel sheet is the same projected cels and selection addresses with its axes +turned: frames down and lanes across, so commands selected there and in the +timeline have identical targets. The suite stands at 429 tests and 5,767 +assertions, with `frontend/test/browser/lane.mjs` driving the editor through +create, hold, overflow, undo, reuse, make unique, duplicate, split, insert, +trim, move and blank. Rewrite tests that encode superseded +behavior rather than preserving behavior to keep them green. + +Build small adversarial documents and test their domain operations before +expanding the interface: + +| Scenario | Required invariant | +| --- | --- | +| Same drawing exposed twice, then one made unique | Linked edits affect both before copying and only the selected content after | +| Holds, playing inserts, nonzero in-points, and gaps on one lane | Source behavior is independent of property key count and channel encoding | +| Adjacent cels, final hold, split, trim, ripple, and overwrite | Exact boundaries, stable IDs, deterministic collision handling | +| Extend a hold under lane keys and cel-local corrections | Lane key times remain fixed; later cel corrections travel with their owners; source playback origins survive | +| Ripple beyond the symbol end | Explicit extent policy; refusal leaves the document unchanged; resizing and retiming undo together | +| Girl on twos over a moving background | Drawing cadence does not quantize either lane's continuous properties | +| Three-frame correction crossing a drawing boundary | Exact support, same result outside it, one undo step | +| Nested retiming, holds, loops, and fractional sampling | Explicit time spaces; ambiguous inverse edits cannot silently choose a target | +| Audio inside changing source cels | Only active intervals sound, with correct trim and source timing | +| Regenerate with corrections and a topology change | Compatible edits survive; incompatible ones produce actionable conflicts | +| Reference cycles and deletion of reused content | Inactive references are validated too; no dangling references | +| Save/load and command undo/redo | Identity, source maps, corrections, and evaluation round-trip | +| Timeline and cel-sheet invocation of one command | Identical document changes and selection targets | +| Random forward/backward seeks and export | Reference and optimized evaluation agree, including defaults and absence | +| Concurrent commands on overlapping and disjoint targets | Transactions remain valid; conflicts are explicit and undo preserves others' work | + +Implementation order: cel ownership and playback semantics; shared +commands and validation; correction layers and time-addressing contracts; then +breadcrumb, cel strip, and a cel-sheet projection. Use those two temporal +views plus direct stage editing to prove the model before broadening the UI. + +A new presentation should not require duplicate animation state. A genuinely new +authoring capability may require new domain data. The model is successful when +such additions have a clear owner and compose with existing operations, not when +it can claim that no future feature will ever need another field. 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 adec0d7..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)) @@ -147,14 +151,13 @@ The raw product. `mix!` packages it as a WAV URL for the transport and `export/frames` packages it as WAV bytes in an archive; a muxer would take it as it is, which is why this is the function the others are written in terms of." - ([document sid] (buffer! document sid nil)) - ([document sid store] - (let [tracks (tracks-of document sid)] - (if (empty? tracks) - (js/Promise.resolve nil) - (-> (js/Promise.all - (into-array (map source! (distinct (map :source tracks))))) - (.then (fn [pairs] (render! document sid (into {} (array-seq pairs)) store)))))))) + [document sid store] + (let [tracks (tracks-of document sid)] + (if (empty? tracks) + (js/Promise.resolve nil) + (-> (js/Promise.all + (into-array (map source! (distinct (map :source tracks))))) + (.then (fn [pairs] (render! document sid (into {} (array-seq pairs)) store))))))) (defn decode! "Promise of the `AudioBuffer` behind a URL. What a clip whose audio is a plain diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index f4c0f8e..d089502 100644 --- a/frontend/src/arthur/db.cljs +++ b/frontend/src/arthur/db.cljs @@ -12,6 +12,7 @@ are entirely dense." (:require [arthur.demo :as demo] [arthur.domain.clip :as domain-clip] + [arthur.domain.trace :as trace] [arthur.demo.swarm :as swarm] [arthur.demo.take :as take])) @@ -159,10 +160,12 @@ :ui {:open nil :tabs [] :selection nil + :time-view :timeline :tone :skin-base :tool nil :draft [] :knobs {} + :trace {:faces #{} :opacity trace/opacity-default} :expanded #{}}}) (def rates diff --git a/frontend/src/arthur/demo.cljs b/frontend/src/arthur/demo.cljs index 46b527f..0e0b096 100644 --- a/frontend/src/arthur/demo.cljs +++ b/frontend/src/arthur/demo.cljs @@ -6,6 +6,7 @@ validates would not be the one that renders, and the model would be validated against a scene nobody ever looked at." (:require [arthur.domain.clip :as domain-clip] + [arthur.domain.palette :as pal] [arthur.domain.symbol :as symbol] [cljs.reader :as reader] [shadow.resource :as rc])) @@ -25,4 +26,4 @@ "Draw ops for one frame, via the specification path. The page uses `symbol/resolver` instead; this is here for the REPL." [f] - (symbol/eval-frame main f)) + (symbol/eval-frame main f nil pal/index-of nil)) diff --git a/frontend/src/arthur/demo/stage.cljs b/frontend/src/arthur/demo/stage.cljs index 35e0ade..41ad6cb 100644 --- a/frontend/src/arthur/demo/stage.cljs +++ b/frontend/src/arthur/demo/stage.cljs @@ -22,15 +22,17 @@ (defn compose "The authored layout plus a source clip -> the composed stage document. - A PLACEMENT IS KEYED BY ITS :uuid, not by the authored id. The authored id + AN INSTANCE IS KEYED BY ITS :uuid, not by the authored id. The authored id (`:left`, `:voice-right`) is a handle for reading the EDN and for the `:linked-to` written there; it does not appear in the document this returns. - What replaces it is an identity that means one placement and nothing else: seven + What replaces it is an identity that means one instance and nothing else: seven instances of one symbol are seven different things to name — to export on their own, to link a voice to, to point at later — and an id like `:left` is a description of where a thing sits, which is exactly what changes when the stage - is re-arranged. `:name` carries the label for a human and `:of` carries the - symbol, so the node still says what it is and which drawing it plays." + is re-arranged. `:name` carries the label for a human and `:source :symbol` + carries the symbol, so the node still says what it is and which drawing it + plays — and `:playback` says how time runs inside it, which is a separate + question from which drawing that is." [source] (let [{:keys [name width height frames symbol instances audio scale]} layout default-anchor (or (:anchor layout) @@ -42,19 +44,20 @@ by-id (into {} (map (juxt :id :uuid)) (concat instances audio)) uuid-of (fn [what id] (or (get by-id id) - (throw (ex-info "the stage layout names a placement that is not there" + (throw (ex-info "the stage layout names an instance that is not there" {:in what :id id :known (vec (sort-by str (keys by-id)))})))) nodes (into {: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..59cc3d7 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 a cel's `:source :symbol` inside the copy is + rewritten to follow. `wanted` maps a root's id in `other` to the id it should preferably get, which is how a symbol made from footage is called what the person typed rather than `:main`. @@ -35,12 +36,16 @@ (some #{%} (vals ids)))] (assoc ids sid (clip/free-id taken? (get wanted sid sid))))) {} (sort-by str reach)) + ;; Cel identity and timing stay put; content references follow + ;; the symbol IDs assigned in the destination document. + repoint (fn [n ids] + (if (node/source n) + (update-in n [:source :symbol] ids) + n)) copy (fn [sid] (-> (clip/symbol other sid) (assoc :id (ids sid)) - (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/channel.cljs b/frontend/src/arthur/domain/channel.cljs index 9e4ef4f..f0fdfbc 100644 --- a/frontend/src/arthur/domain/channel.cljs +++ b/frontend/src/arthur/domain/channel.cljs @@ -68,8 +68,11 @@ (defn framed [v] {:animated? false :value v}) (defn keyed - ([ks] (keyed ks :hold)) - ([ks interp] {:animated? true :interp interp :keys ks :over []})) + "A channel of keys, and how each one leads to the next. `interp` is an + argument, never a default: `:hold` and `:linear` are the difference between a + cut and a tween, which is the whole content of the channel." + [ks interp] + {:animated? true :interp interp :keys ks :over []}) ;; --------------------------------------------------------------------------- @@ -91,17 +94,160 @@ (when-let [ks (:keys ch)] (vec (sort (keys ks))))) -(defn- check-unimplemented! - "An override layer must fail LOUDLY rather than be ignored. +;; --------------------------------------------------------------------------- +;; correction layers +;; +;; `:over` is an ORDERED STACK on top of whatever the channel already says. +;; Generated motion stays the base; a hand correction is a layer above it, so +;; regenerating replaces the base and the corrections survive. That is the whole +;; reason the stack exists rather than the hand edit being written into the keys. +;; +;; A LAYER'S VALUES ARE A CHANNEL. A constant adjustment is a framed one, a ramp +;; or a return motion is a keyed one, and neither needs a second way of saying +;; what a value is over time: layers read through `value-at` and `cursor` like +;; anything else, which is also what stops the fast path and the specification +;; from being two implementations of blending. +;; +;; A LAYER HAS NO TIME SPACE OF ITS OWN. Its `:support` and its values' keys are +;; in the frames the base channel's keys are in — the node's own. A correction on +;; a lane is therefore in lane frames and crosses the drawing boundaries under +;; it; a correction on one cel is in that cel's frames and travels +;; with it when it moves. Ownership already answered the question, so there is no +;; field to get wrong. - Silently dropping an :over layer would present as a hand - correction that did not take — a correction the user made once, watched fail, - and has no reason to trust again. Nothing can produce one yet, so this can - only fire on a data shape that has run ahead of the code." +(defn layer + "One correction: `values` applied to the base wherever `support` covers the + frame. `op` is `:offset` or `:replace`." + [id support op values] + {:id id :support support :op op :values values}) + +(defn- covers? + "Half-open, as a span is: a correction over frames 10 to 12 is `[10 13)`." + [[in out] f] + (and (<= in f) (< f out))) + +(defn- width + "Components in a value, or nil for a number. A dense value is a typed-array + view, an authored one a vector, and a correction has to add to either." + [v] + (cond (number? v) nil (vector? v) (count v) :else (.-length v))) + +(defn- shape + "What kind of value this is, for asking whether one can be added to another: + `:scalar`, a component count, or `:opaque` for a value that is neither — a + `[:vis]` boolean is opaque, and can be replaced but not offset." + [v] + (cond + (number? v) :scalar + (vector? v) (count v) + (and (some? v) (number? (.-length v))) (.-length v) + :else :opaque)) + +(defn value-shape + "The shape of the values a channel yields, without sampling it, or nil where + there is nothing to read it off — an empty key map says nothing about what its + values would have been, and nil must not be taken for a scalar." [ch] - (when (seq (:over ch)) - (throw (ex-info "channel has :over layers and the override layer is not built (port-plan step 2 scope)" - {:over (:over ch) :channel (dissoc ch :dense)})))) + (cond + (not (:animated? ch)) (when (some? (:value ch)) (shape (:value ch))) + (:dense ch) (if (= 1 (:stride (:dense ch))) :scalar (:stride (:dense ch))) + (seq (:keys ch)) (shape (val (first (:keys ch)))) + :else nil)) + +(defn- shape-conflict [base-shape correction-shape] + (cond + (or (nil? base-shape) (nil? correction-shape)) nil + (= :opaque base-shape) "the base is not a number or a row of components" + (= :opaque correction-shape) "the correction is not a number or a row of components" + (not= base-shape correction-shape) + (str "the base has " (pr-str base-shape) " and the correction " + (pr-str correction-shape) " — a correction cannot offset a value of" + " a different shape"))) + +(defn conflict-with + "Why correction `l` cannot apply to base channel `base`, or nil. + + ONLY `:offset` can conflict. It adds component by component, so it needs the + base to have the components it has — which is what a topology change takes + away when a re-freeze gives a mouth a different number of points. `:replace` + states a whole value and so has nothing to agree with. + + Shapes that cannot be read yet do not conflict: an empty key map is not a + disagreement, it is a channel with nothing in it." + [base l] + (when (= :offset (:op l)) + (shape-conflict (value-shape base) (value-shape (:values l))))) + +(defn- stack-conflict + "Why layer `i` can encounter a value of the wrong shape after the layers + before it. A replace covering all of this layer's support becomes the only + possible input; a partly overlapping replace adds another possible input." + [ch i l] + (when (and (= :offset (:op l)) + (vector? (:support l)) (= 2 (count (:support l)))) + (let [[a b] (:support l) + shapes (reduce + (fn [possible prior] + (let [[c d] (when (and (vector? (:support prior)) + (= 2 (count (:support prior)))) + (:support prior))] + (if (and c d (not (:conflict prior)) (= :replace (:op prior)) + (< a d) (< c b)) + (let [s (value-shape (:values prior))] + (if (and (<= c a) (<= b d)) #{s} (conj possible s))) + possible))) + #{(value-shape ch)} (take i (:over ch))) + v (value-shape (:values l))] + (some #(shape-conflict % v) shapes)))) + +(defn conflicts + "Corrections on `ch` that cannot apply to its base, as `[{:id :why}]`. + + NOT `problems`. A conflict is a legitimate state for a document to be in: a + regeneration changed the topology under a correction that was right when it was + made, and resolving it is a person's decision, not a reason the document will + not load. `flow/regenerate` records one on the layer, a conflicted layer is not + applied, and this is how a view finds them to offer." + [ch] + (vec (for [l (:over ch) + :let [why (or (:conflict l) (conflict-with ch l))] + :when why] + {:id (:id l) :why why}))) + +(defn- offset-onto + "`base` plus `v`, component-wise. A vector, never a write into `base`, which + for a dense channel is a view onto the block itself." + [base v ch] + (let [wb (width base) wv (width v)] + (cond + (and (nil? wb) (nil? wv)) (+ base v) + (and wb wv (= wb wv)) + (mapv (fn [i] (+ (component base i) (component v i))) (range wb)) + :else + (throw (ex-info "a correction cannot offset a value of a different shape" + {:base wb :correction wv :channel (dissoc ch :dense)}))))) + +(defn- over-at + "Fold `ch`'s layers onto `base` at frame f. `read` samples one layer's values + and is the only thing that differs between the specification and the cursor." + [ch f base read] + (reduce-kv + (fn [v i {:keys [support op values conflict]}] + ;; A conflicted correction is neither applied nor forgotten: it stays in + ;; the document, `conflicts` reports it, and a person decides. Applying it + ;; would misapply it; removing it would throw away hand work. + (if (or conflict (not (covers? support f))) + v + (let [x (read i values f)] + (cond + (nothing? x) v + (= :replace op) x + ;; `replace` can supply a value over an absent base; `offset` has + ;; nothing to add to and says so rather than inventing a pose. + (nothing? v) absent + :else (offset-onto v x ch))))) + base + (vec (:over ch)))) ;; --------------------------------------------------------------------------- ;; dense blocks @@ -147,9 +293,9 @@ Decoding costs the view. `out` is a stride-sized destination the caller owns — `cursor` allocates one per channel — because a copy per node per frame is the allocation this whole model is arranged to avoid; passing nil allocates, which - is what `value-at`, the specification, does." - ([blk f st] (dense-at blk f st nil)) - ([{:keys [store offset stride scale] nf :frames} f st out] + is what `value-at`, the specification, does — and it says so by passing nil, + because there is no arity here that decides it for a caller." + [{:keys [store offset stride scale] nf :frames} f st out] (let [{:keys [data state]} (get st store)] (when (nil? data) (throw (ex-info "dense channel's store key is not in the store" @@ -164,7 +310,7 @@ :else (let [dst (or out (js/Float64Array. stride))] (dotimes [k stride] (aset dst k (/ (aget data (+ o k)) scale))) - dst)))))))) + dst))))))) ;; --------------------------------------------------------------------------- ;; the specification @@ -201,17 +347,28 @@ (defn value-at "Sample a channel at frame f. THE SPECIFICATION — correct, allocating, and - O(n) in the keys. `cursor`/`sample!` is what playback uses." - ([ch f] (value-at ch f nil)) - ([ch f store] - (check-unimplemented! ch) - (cond - (not (:animated? ch)) (:value ch) - (:dense ch) (dense-at (:dense ch) f store) - (:keys ch) (let [ks (:keys ch)] - (if (empty? ks) absent (keyed-at ch f))) - :else - (throw (ex-info "animated channel has neither :keys nor :dense" {:channel ch}))))) + O(n) in the keys. `cursor`/`sample!` is what playback uses. + + `store` IS AN ARGUMENT, NEVER A DEFAULT. A dense channel cannot be read + without the tier-2 store it names, and an arity that filled in nil let a + caller omit it, read correctly for every channel that happened not to be + dense, and throw the first time a selection landed on one that was. That is + how `gesture/values` took the stage down on an iris. A caller with no store + says `nil` and means it." + ([ch f store] (value-at ch f f store)) + ([ch base-f correction-f store] + (let [base (cond + (not (:animated? ch)) (:value ch) + (:dense ch) (dense-at (:dense ch) base-f store nil) + (:keys ch) (let [ks (:keys ch)] + (if (empty? ks) absent (keyed-at ch base-f))) + :else + (throw (ex-info "animated channel has neither :keys nor :dense" + {:channel ch})))] + (if (seq (:over ch)) + (over-at ch correction-f base + (fn [_ values f] (value-at values f store))) + base)))) ;; --------------------------------------------------------------------------- ;; the playback path @@ -233,7 +390,7 @@ (recur (inc mid) hi mid) (recur lo (dec mid) best)))))) -(deftype Cursor [ch ks store buf ^:mutable i] +(deftype Cursor [ch ks store buf overs ^:mutable i] Object (toString [_] (str "#Cursor{" (pr-str (if ks :keyed (if (:dense ch) :dense :framed))) " i=" i "}"))) @@ -244,48 +401,68 @@ needs, for the same reason the resolver owns one point buffer per node. Only a wide fixed-point block gets a buffer: a stride-1 block decodes to a - number and a block with no `:scale` is handed back as a view." - ([ch] (cursor ch nil)) - ([ch store] - (check-unimplemented! ch) - (let [d (:dense ch)] - (->Cursor ch - (when (and (:animated? ch) (not d) (seq (:keys ch))) (frames ch)) - store - (when (and d (:scale d) (> (:stride d) 1)) - (js/Float64Array. (:stride d))) - 0)))) + number and a block with no `:scale` is handed back as a view. + + A correction layer gets a reading head of its own, because its values are a + channel and this is how a channel is read fast. One level deep: a layer's + values may not themselves carry layers, which `problems` refuses. + + `store` is an argument for the reason it is one on `value-at`." + [ch store] + (let [d (:dense ch)] + (->Cursor ch + (when (and (:animated? ch) (not d) (seq (:keys ch))) (frames ch)) + store + (when (and d (:scale d) (> (:stride d) 1)) + (js/Float64Array. (:stride d))) + (mapv #(cursor (:values %) store) (:over ch)) + 0))) + +(defn- base-sample! + "What the cursor's channel says at f BEFORE its corrections. Advancing the + reading head is this function's whole job, and it is separate from blending so + that a layer cannot accidentally be read through the base's index." + [^Cursor cur ch ks f] + (cond + (not (:animated? ch)) (:value ch) + (:dense ch) (dense-at (:dense ch) f (.-store cur) (.-buf cur)) + (nil? ks) absent ; animated with an empty key map + :else + (let [n (count ks) + i (.-i cur) + last (dec n) + i' (cond + ;; still inside the key the cursor sits on + (and (<= (nth ks i) f) + (or (= i last) (> (nth ks (inc i)) f))) + i + ;; the next one — one frame of playback crossed one key + (and (< i last) + (<= (nth ks (inc i)) f) + (or (= (inc i) last) (> (nth ks (+ i 2)) f))) + (inc i) + + :else (bsearch ks f))] + (set! (.-i cur) i') + (interpolate ch f (nth ks i') (when (< i' last) (nth ks (inc i'))))))) (defn sample! "Value of the cursor's channel at f. O(1) when f is at or one key past where the cursor already sits — the playback case — and O(log n) otherwise, which is a seek. Advancing and seeking are deliberately different costs: a scrub can - afford a binary search and a frame cannot." - [^Cursor cur f] - (let [ch (.-ch cur) - ks (.-ks cur)] - (cond - (not (:animated? ch)) (:value ch) - (:dense ch) (dense-at (:dense ch) f (.-store cur) (.-buf cur)) - (nil? ks) absent ; animated with an empty key map - :else - (let [n (count ks) - i (.-i cur) - last (dec n) - i' (cond - ;; still inside the key the cursor sits on - (and (<= (nth ks i) f) - (or (= i last) (> (nth ks (inc i)) f))) - i - ;; the next one — one frame of playback crossed one key - (and (< i last) - (<= (nth ks (inc i)) f) - (or (= (inc i) last) (> (nth ks (+ i 2)) f))) - (inc i) + afford a binary search and a frame cannot. - :else (bsearch ks f))] - (set! (.-i cur) i') - (interpolate ch f (nth ks i') (when (< i' last) (nth ks (inc i')))))))) + A correction layer is sampled through its OWN cursor, so a stacked channel is + still one reading head per key map and `value-at` stays the specification for + the blending as well as for the base." + ([cur f] (sample! cur f f)) + ([^Cursor cur base-f correction-f] + (let [ch (.-ch cur) + base (base-sample! cur ch (.-ks cur) base-f)] + (if (seq (:over ch)) + (over-at ch correction-f base + (fn [i _ f] (sample! (nth (.-overs cur) i) f))) + base)))) ;; --------------------------------------------------------------------------- @@ -345,8 +522,45 @@ (or (:dense ch) (not linear-values?))) (conj ":linear interpolation needs numeric keys of one shape") - (and (map? ch) (seq (:over ch))) - (conj ":over layers are not implemented (port-plan step 2 scope)") + (and (map? ch) (contains? ch :over) (not (vector? (:over ch)))) + (conj ":over is an ORDERED stack, so it is a vector") + + (and (map? ch) (vector? (:over ch))) + (into (for [{:keys [id support op values]} (:over ch) + p (cond-> [] + (nil? id) + (conj "needs an :id — a correction has an identity a regeneration can keep") + + (not (and (vector? support) (= 2 (count support)) + (every? #(and (number? %) (js/Number.isFinite %)) support) + (< (first support) (second support)))) + (conj (str ":support " (pr-str support) + " must be a finite, increasing [in out)")) + + (not (#{:offset :replace} op)) + (conj (str ":op " (pr-str op) " is not :offset or :replace")) + + ;; One level. A layer over a layer is an ordering mechanism + ;; the stack already is, and it would make the read + ;; unbounded in depth for nothing. + (seq (:over values)) + (conj "a layer's values cannot carry layers of their own") + + (seq (problems (dissoc values :over))) + (conj (str "values are not a channel: " + (first (problems (dissoc values :over))))))] + (str "correction " (pr-str id) " " p))) + + ;; A shape mismatch NOBODY HAS RECORDED is an authoring bug; one a + ;; regeneration recorded is a conflict awaiting a person, and `conflicts` + ;; reports those. The distinction is what keeps a topology change from + ;; making a document that will not load. + (and (map? ch) (vector? (:over ch))) + (into (for [[i l] (map-indexed vector (:over ch)) + :when (not (:conflict l)) + :let [why (stack-conflict ch i l)] + :when why] + (str "correction " (pr-str (:id l)) " " why))) ;; A scale of zero divides every value in the block by zero, and a negative ;; one mirrors the geometry. Both are authored-data bugs that present as a diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index d4089c5..d0c1828 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 @@ -174,8 +178,7 @@ own frame. Nil for a node that was not on that frame. It is how something drawn beside the picture, like a tracing photo, rides a node inside it without resolving anything a second time." - ([clip store palette sid] (resolver clip store palette sid nil)) - ([clip store palette sid {:keys [picture-fps] :as opts}] + [clip sid store palette {:keys [picture-fps] :as opts}] (letfn [(build [sid chain pose-tracks] (when (some #{sid} chain) (throw (ex-info "symbol cycle" {:chain (conj chain sid)}))) @@ -184,34 +187,40 @@ nodes (:nodes sym) rank (symbol/draw-rank nodes (symbol/order nodes)) ids (sort-by rank (keys nodes)) - own (symbol/resolver sym store palette pose-tracks - (assoc opts :source-fps (:fps clip))) + own (symbol/resolver sym store palette + (assoc opts :pose-tracks pose-tracks + :source-fps (:fps clip))) + ;; Each cel 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,15 +231,16 @@ (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)))) + (build sid [] nil))) (defn center "The middle of everything symbol `sid` draws, over all its frames, in its own @@ -244,7 +254,7 @@ Effects' anchor point are set once and left. A symbol that grows later keeps its instances' pivots where they were, so nothing on screen moves." [clip store sid] - (let [resolve (resolver clip store pal/index-of sid) + (let [resolve (resolver clip sid store pal/index-of nil) bounds (fn [[x0 y0 x1 y1 :as b] x y] (if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y])) [x0 y0 x1 y1] @@ -272,7 +282,7 @@ middle goes there; without one — a drop on the timeline — the drawing stays where it was drawn. - THE UUID IS AN ARGUMENT. A placement's identity is the key it has in the node + THE UUID IS AN ARGUMENT. An instance's identity is the key it has in the node map — it is what `:linked-to`, an export target and a saved leaf all name — so generating one in here would make this function's result depend on when it was called, and this namespace is the pure one. @@ -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 + ;; Lexicographic draw order, as `domain/paint` does it: an instance made ;; later sits above one made earlier, and neither has to renumber. :z (str "z" (js/Date.now) "-" (name sid)) :span [0 (: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}}}))))) @@ -355,6 +366,23 @@ (map #(keyword (namespace wanted) (str (name wanted) "-" %)) (iterate inc 2)))))) +(defn conflicts + "Every hand correction in the document that its base has outgrown, as + `[{:symbol :node :channel :id :why}]`. + + SEPARATE FROM `problems` on purpose. A conflict is a document a person still + has to make a decision about — a regeneration changed the topology under a + correction that was right when it was made — and not a reason the document + will not load. Nothing is dropped and nothing is misapplied meanwhile: the + layer stays where it is, the picture is the base, and this is the list a view + offers to resolve." + [clip] + (vec (for [[sid sym] (:symbols clip) + [id n] (:nodes sym) + [prop c] (:channels n) + {:keys [why] :as x} (ch/conflicts c)] + (assoc (select-keys x [:id]) :symbol sid :node id :channel prop :why why)))) + (defn problems "Human-readable reasons this clip will not evaluate or save." [clip] @@ -374,22 +402,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 cel'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/gesture.cljs b/frontend/src/arthur/domain/gesture.cljs index db31b31..ec27248 100644 --- a/frontend/src/arthur/domain/gesture.cljs +++ b/frontend/src/arthur/domain/gesture.cljs @@ -15,9 +15,17 @@ [arthur.domain.node :as node])) (defn values - "Node `n`'s transform on its own frame `f`, as vectors." - [n f] - (let [at #(ch/value-at (get (node/channels n) [:xform %]) f) + "Node `n`'s transform on its own frame `f`, as vectors. + + `store` IS NOT OPTIONAL, though `ch/value-at` would let it be. A measured + transform is a dense channel, and a dense channel read without the tier-2 + store it names throws — so leaving it off read correctly for every hand-placed + node and crashed the stage the moment a selection landed on an iris, a brow or + a head. Those are not hard to land on: `pick/choose` keeps a selection at the + depth it already has, so once anything inside a face is selected, an ordinary + click beside it selects its neighbour — which near the eyes is an iris." + [n f store] + (let [at #(ch/value-at (get (node/channels n) [:xform %]) f store) xy #(let [v (at %)] [(ch/component v 0) (ch/component v 1)])] {:pos (xy :pos) :rot (at :rot) :scale (xy :scale) :anchor (xy :anchor)})) @@ -27,8 +35,7 @@ it would throw the measurement away." [n] (cond - (some #(let [c (get-in n [:channels [:xform %]])] (or (:dense c) (:generated c))) - [:pos :rot :scale]) + (node/measured? n) "its transform is measured — place the instance it is in")) (defn- through [m [x y]] diff --git a/frontend/src/arthur/domain/lane.cljs b/frontend/src/arthur/domain/lane.cljs new file mode 100644 index 0000000..d2ad7b4 --- /dev/null +++ b/frontend/src/arthur/domain/lane.cljs @@ -0,0 +1,450 @@ +(ns arthur.domain.lane + "The commands over a lane of cels: make one, put drawings in it, change + how long they are exposed, and decide which of them share content. + + WHAT A LANE IS lives in `arthur.domain.symbol`, beside the other rules about a + node map: a group with `:layout :sequence`, whose children are non-overlapping + visual cels. This namespace only changes them. + + EVERY COMMAND IS ONE STEP AND ALL OF IT. Each returns `{:clip :selection}` or + `{:refused reason}` — never a half-applied edit, and never a document that + `clip/problems` would reject. A command that cannot say what the person meant + refuses and says why, rather than picking for them: the overflow policy is a + caller's `:extent`, and decoupling shared content is its own command instead + of something an ordinary edit does silently. + + IDS FOR CELS COME FROM THE CALLER, because a cel's identity is + a uuid and this namespace is pure. Ids for new CONTENT are derived from the + drawing being copied — `clip/free-id` is pure too, and `drawing-a-2` says what + it came from in a way `symbol-7` does not." + (:require [arthur.domain.bring :as bring] + [arthur.domain.clip :as clip] + [arthur.domain.node :as node] + [arthur.domain.symbol :as symbol])) + +(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 + "Commit `nodes` as symbol `sid`'s, or refuse. + + THE SHOT LENGTH IS AUTHORED. `:frames` is the symbol's window — how long the + shot IS — and the occupied extent of its lanes is a different fact derived + from the cels. A command may GROW the window when the caller says + `:grow-symbol`, and never shrinks it: emptying the end of a shot leaves a shot + with empty frames at the end, which is a true statement about what somebody + authored. Deriving the window from the extent instead would make deleting the + last drawing silently shorten the film. + + So there are two numbers and this function keeps them apart: `needed` is where + the cels reach, `:frames` is what was authored, and the only way the + second follows the first is a caller asking." + [clip sid nodes selection extent] + (let [sym (clip/symbol clip sid) + reach (for [[id n] nodes :when (node/lane? n) + child (symbol/lane-cels nodes id) + :let [m (lane-map nodes id) + end (second (node/placed-span child))]] + (when m (+ (:at m) (/ end (:rate m))))) + needed (js/Math.ceil (apply max 0 (keep identity reach))) + ps (symbol/problems (assoc sym :nodes nodes))] + (cond + (seq ps) {:refused (first ps)} + (not (#{:keep :grow-symbol} extent)) {:refused "choose an explicit shot-length policy"} + (and (> needed (:frames sym)) (= :keep extent)) + {:refused (str "the edit needs " needed " frames; extend the shot to continue") + :required-frames needed} + :else {:clip (cond-> (assoc-in clip [:symbols sid :nodes] nodes) + (> needed (:frames sym)) + (assoc-in [:symbols sid :frames] needed)) + :selection selection}))) + +;; --------------------------------------------------------------------------- +;; the geometry every cel edit is made of +;; +;; A `:span` is in the cel's OWN frames and its `:time` says where those +;; land in the lane. So moving an edge of a cel is one write to `:span`, +;; and `:time` and `:playback` are untouched — which is why trimming the front +;; of a playing insert starts it later in its source instead of resetting it, +;; and why the two halves of a split go on meaning what the one cel meant. +;; Trim, split and blank are all this one operation, applied differently. + +(defn- local + "Lane frame `f` as one of `n`'s own frames." + [n f] + (let [{:keys [at rate]} (node/time-of n)] + (* rate (- f at)))) + +(defn- edged + "`n` with its `:in` or `:out` edge at lane frame `f`." + [n which f] + (assoc-in n [:span (case which :in 0 :out 1)] (local n f))) + +(defn extend-hold + "Change one held cel's duration by `delta` lane frames and ripple its + later siblings. Lane channels, cel 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))) + ;; The LANE's own shape, not the whole symbol's: refusing a cel + ;; edit over some unrelated defect elsewhere in the symbol would be + ;; this command answering for a part of the document it never touches. + broken (first (symbol/lane-problems nodes))] + (cond + (not (node/lane? lane)) {:refused "select a cel in a lane"} + broken {:refused broken} + (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 "cel timing through a stepped or looping lane is not supported"} + (<= (+ (second span) (* rate delta)) (first span)) {:refused "a drawing must keep a positive cel"} + :else + (let [[_ boundary] (node/placed-span n) + later (filter #(>= (first (node/placed-span %)) boundary) + (symbol/lane-cels 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 split + "Cut cel `id` in two at lane frame `cut`. The left piece keeps its + identity; the right gets `new-id`. + + NOTHING BUT `:span` DIFFERS between the two pieces. They keep one `:time`, so + the right piece's own frames carry on exactly where the left's stopped, and its + source clock, its keys and its corrections therefore go on meaning what they + meant before the cut — preserved by construction rather than by arithmetic on + in-points that could be wrong. A held drawing holds the same frame on both + sides; a playing insert plays on through the cut without a seam. That is what + `:span` being in the node's OWN coordinates buys, and it is why splitting + needs no shot-length policy: the pieces occupy the frames the one cel + occupied. + + The right piece is the selection, because it is the piece that was made." + [clip sid id cut new-id] + (let [nodes (get-in clip [:symbols sid :nodes]) + n (get nodes id) + lane (get nodes (:parent n)) + {:keys [at rate]} (node/time-of n) + [lo hi] (or (node/placed-span n) [nil nil])] + (cond + (not (node/lane? lane)) {:refused "select a cel in a lane"} + (not (integer? cut)) {:refused "a cut is a whole lane frame"} + (contains? nodes new-id) {:refused "the new cel ID is already used"} + (not (and lo (< lo cut hi))) + {:refused (str "frame " cut " is not inside this cel")} + :else + (let [nodes (-> nodes + (assoc id (edged n :out cut)) + (assoc new-id (assoc (edged n :in cut) + :id new-id :z (str "a-" new-id))))] + (finish clip sid nodes new-id :keep))))) + +(defn trim + "Move one edge of cel `id` to lane frame `to`, without disturbing a + single other cel. + + TRIM NARROWS. Lengthening a cel is `extend-hold`, which carries a ripple + policy and a shot-length policy because it needs them; letting trim grow as + well would give one gesture two sets of rules and a way to overlap its + neighbour. `edge` is `:in` or `:out`. + + The source clock is untouched, so trimming the front of a playing insert + starts it later INTO its animation rather than restarting it — which is the + difference between trimming and slipping, and why they are separate commands." + [clip sid id edge to] + (let [nodes (get-in clip [:symbols sid :nodes]) + n (get nodes id) + lane (get nodes (:parent n)) + [lo hi] (or (node/placed-span n) [nil nil])] + (cond + (not (node/lane? lane)) {:refused "select a cel in a lane"} + (not (#{:in :out} edge)) {:refused "an edge is :in or :out"} + (not (integer? to)) {:refused "an edge goes to a whole lane frame"} + (not (and lo (< lo to hi))) + {:refused (str "frame " to " is not inside this cel; trim narrows it")} + :else (finish clip sid (assoc nodes id (edged n edge to)) id :keep)))) + +(defn move + "Put cel `id` at lane frame `to`, leaving every other cel and + its own length, source and corrections alone. + + One write to `:time :at`. A destination that would overlap a neighbour is + REFUSED rather than rippled or overwritten: moving a drawing and re-timing the + ones around it are different intentions, and a move that silently pushed the + rest would be the second one wearing the first one's name. Clear the room + first — `blank` makes a gap, `trim` shortens a neighbour." + [clip sid id to] + (let [nodes (get-in clip [:symbols sid :nodes]) + n (get nodes id) + lane (get nodes (:parent n)) + {:keys [at]} (node/time-of n)] + (cond + (not (node/lane? lane)) {:refused "select a cel in a lane"} + (not (integer? to)) {:refused "a cel moves to a whole lane frame"} + (nil? (node/placed-span n)) {:refused "a cel needs a span to move"} + :else + (let [moved (update-in n [:time :at] (fnil + 0) (- to (first (node/placed-span n))))] + (if (not= to (first (node/placed-span moved))) + {:refused "cel timing through a stepped or looping lane is not supported"} + (finish clip sid (assoc nodes id moved) id :keep)))))) + +(defn blank + "Clear lane frames `[a b)` of lane `lane-id`, leaving a GAP. + + A gap is not a drawing. Nothing is invented to cover those frames and nothing + closes the hole — the cels after it stay where they are, because + emptying frames and re-timing a performance are different intentions. + + What it does to each cel it meets is the edge geometry above: one wholly + inside is removed, one overlapping an end is trimmed to it, and the one that + spans the whole range is split, which is the only case that needs `id`. Their + drawings stay in the library — a lane does not own its content, and a drawing + whose last cel is gone is still a drawing somebody made." + [clip sid lane-id [a b] {:keys [id]}] + (let [nodes (get-in clip [:symbols sid :nodes]) + lane (get nodes lane-id) + members (when (node/lane? lane) (symbol/lane-cels nodes lane-id)) + spanning (when members + (first (filter #(let [[lo hi] (node/placed-span %)] (and (< lo a) (> hi b))) + members)))] + (cond + (not (node/lane? lane)) {:refused "select a lane"} + (not (and (integer? a) (integer? b) (< a b))) + {:refused "a range to blank is whole lane frames, and not empty"} + (and spanning (or (nil? id) (contains? nodes id))) + {:refused "blanking inside one cel splits it, which needs a free ID for the remainder"} + :else + (let [nodes (reduce + (fn [ns n] + (let [[lo hi] (node/placed-span n)] + (cond + (or (<= hi a) (>= lo b)) ns + (and (< lo a) (> hi b)) + (-> ns + (assoc (:id n) (edged n :out a)) + (assoc id (assoc (edged n :in b) :id id :z (str "a-" id)))) + (and (>= lo a) (<= hi b)) (dissoc ns (:id n)) + (< lo a) (assoc ns (:id n) (edged n :out a)) + :else (assoc ns (:id n) (edged n :in b))))) + nodes members)] + (finish clip sid nodes (or (when spanning id) lane-id) :keep))))) + +(defn add-lane [clip sid id] + (if (or (nil? (clip/symbol clip 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})) + +;; --------------------------------------------------------------------------- +;; putting drawings in a lane + +(defn- held + "A one-frame held cel of `drawing-id`, starting at lane frame `at`. + + Held rather than playing, and one frame rather than the length of what it + places: a cel's duration is the lane's business — `extend-hold` is how + it changes — and reading it off the content would make placing a ten-frame + animation and holding its first drawing the same gesture." + [id lane-id drawing-id at] + {: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}}) + +(defn lane-frame + "Symbol frame `f` as a frame of lane `lane-id`'s OWN time, or nil through a + stepped or looping lane, where one frame of the symbol is not one frame of the + lane and there is no single answer to give a command." + [clip sid lane-id f] + (when-let [{:keys [at rate]} (lane-map (get-in clip [:symbols sid :nodes]) lane-id)] + (* rate (- f at)))) + +(defn- lane-end + "Where lane `lane-id`'s occupied frames stop, in its own time." + [nodes lane-id] + (apply max 0 (map #(second (node/placed-span %)) + (symbol/lane-cels nodes lane-id)))) + +(defn- place + "Put a held cel of `drawing-id` into `lane-id` at lane frame `at`, and + RIPPLE: everything starting at or after it moves later by its duration. + + There is one placement function and `:end` is a position like any other, so + appending is not a different operation from inserting — the end is just where + nothing has to move. Overwriting is the other policy and is NOT this: taking + frames away from the cel already there is trimming, which is its own + command and not something placing a drawing should do on the quiet. + + `:frame` in the result is where it landed, in the open symbol's time, for a + caller that wants to look at what it just made." + [clip sid lane-id id drawing-id at extent ripple?] + (let [nodes (get-in clip [:symbols sid :nodes]) + at (if (= :end at) (lane-end nodes lane-id) at) + n (held id lane-id drawing-id at) + [lo hi] (node/placed-span n) + later (when ripple? + (filter #(>= (first (node/placed-span %)) lo) + (symbol/lane-cels nodes lane-id))) + nodes (reduce (fn [ns sibling] + (update-in ns [(:id sibling) :time :at] (fnil + 0) (- hi lo))) + (assoc nodes id n) later) + result (finish clip sid nodes id extent) + m (lane-map nodes lane-id)] + (cond-> result + (:clip result) (assoc :frame (+ (:at m) (/ at (:rate m))))))) + +(defn- placeable + "Why a held cel cannot go into `lane-id` at `at`, or nil." + [clip sid lane-id id at] + (let [nodes (get-in clip [:symbols sid :nodes]) + lane (get nodes lane-id) + ;; INSIDE a cel is not a position for another one. Splitting that + ;; cel is what makes it two, and doing it here would be one command + ;; quietly performing two: the caller asks for `split` and then places. + inside (when (number? at) + (some (fn [n] (let [[lo hi] (node/placed-span n)] + (when (< lo at hi) n))) + (symbol/lane-cels nodes lane-id)))] + (cond + (not (node/lane? lane)) "select a lane" + (contains? nodes id) "the new cel ID is already used" + (not (or (= :end at) (and (integer? at) (not (neg? at))))) + "a position is :end or a whole lane frame" + inside (str "frame " at " is inside a cel; split it first") + (nil? (lane-map nodes lane-id)) "drawing creation through a stepped or looping lane is not supported" + :else (first (symbol/lane-problems nodes))))) + +(defn append-drawing + "Append fresh empty content and a held cel of it. IDs come from the + caller so a command is deterministic and replayable. + + Fresh content, not a blank range: a lane with no cel over a frame shows + nothing there already, and a drawing nobody has drawn in is a different thing + from a gap." + [clip sid lane-id id drawing-id {:keys [at extent] :or {extent :keep at :end}}] + (if-let [why (or (placeable clip sid lane-id id at) + (when (clip/symbol clip drawing-id) "the new drawing ID is already used"))] + {:refused why} + (place (assoc-in clip [:symbols drawing-id] + {:id drawing-id :name (name drawing-id) :frames 1 :nodes {}}) + sid lane-id id drawing-id at extent true))) + +(defn reuse-drawing + "Append a held cel of content the document ALREADY has, so the same + drawing is exposed twice and editing it changes both cels. + + This is the command `make-unique` is the undo of, and the reason they are two + commands: reuse is a decision to share, and sharing is not something to + discover later when an edit turns up somewhere else." + [clip sid lane-id id drawing-id {:keys [at extent] :or {extent :keep at :end}}] + (if-let [why (or (placeable clip sid lane-id id at) + (when-not (clip/symbol clip drawing-id) "there is no such drawing to reuse") + ;; Placing something that contains this symbol would close a + ;; loop, and a lane is no different from any other placement. + (when (clip/contains-symbol? clip drawing-id sid) + "a symbol cannot go inside itself"))] + {:refused why} + (place clip sid lane-id id drawing-id at extent true))) + +(defn- copied + "A copy of symbol `from`, as `{:clip :id}`. + + SHALLOW by default: its own nodes and channels are copied, and its references + to other symbols are kept, so a head built out of reusable eyes still uses + those eyes. `deep?` copies everything it places as well, with new ids + throughout, for a drawing that must share nothing — the distinction the + shallow copy cannot make on its own, and a promise of independence that only + the deep one keeps." + [clip from deep?] + (if deep? + (let [{c :clip ids :ids} (bring/symbols clip clip [from] {})] + {:clip c :id (ids from)}) + (let [id (clip/free-id (:symbols clip) from)] + {:clip (assoc-in clip [:symbols id] (assoc (clip/symbol clip from) :id id)) + :id id}))) + +(defn duplicate-drawing + "Append a held cel of a COPY of what cel `id` places, for when + the drawing on screen is the starting point for the next one. + + The copy is of the content only. The new cel is a plain one-frame hold + rather than a copy of `id`'s own transform or corrections: those belong to + that cel, and carrying them over would make duplicating a drawing quietly + duplicate the treatment of one use of it." + [clip sid id new-id {:keys [at extent deep?] :or {extent :keep at :end}}] + (let [n (get-in clip [:symbols sid :nodes id]) + from (node/source n)] + (if-let [why (or (when-not from "select a cel to duplicate") + (when-not (clip/symbol clip from) "the drawing it places is missing") + (placeable clip sid (:parent n) new-id at))] + {:refused why} + (let [{c :clip copy :id} (copied clip from deep?)] + (place c sid (:parent n) new-id copy at extent true))))) + +(defn overwrite-drawing + "Put a fresh one-frame drawing at lane frame `at`, replacing whatever was + there and leaving every other cel where it was. + + This is `blank` and placement composed in ONE command and therefore one undo + step. `remainder-id` is used only when clearing the frame cuts one cel into + two; ids still come from the caller because this namespace is pure." + [clip sid lane-id id drawing-id at {:keys [extent remainder-id] + :or {extent :keep}}] + (let [nodes (get-in clip [:symbols sid :nodes])] + (if-let [why (cond + (not (and (integer? at) (not (neg? at)))) + "a position is a nonnegative whole lane frame" + (contains? nodes id) "the new cel ID is already used" + (or (= id remainder-id) (contains? nodes remainder-id)) + "the remainder cel needs a free ID different from the new cel" + (clip/symbol clip drawing-id) "the new drawing ID is already used" + (nil? (lane-map nodes lane-id)) + "drawing creation through a stepped or looping lane is not supported")] + {:refused why} + (let [cleared (blank clip sid lane-id [at (inc at)] {:id remainder-id})] + (if (:refused cleared) + cleared + (place (assoc-in (:clip cleared) [:symbols drawing-id] + {:id drawing-id :name (name drawing-id) :frames 1 :nodes {}}) + sid lane-id id drawing-id at extent false)))))) + +(defn make-unique + "Point cel `id` at a private copy of its content, leaving every other + cel of that drawing sharing the original. + + Refused when nothing else uses it: a drawing with one cel is already + unique, and answering with a silent copy would leave a second identical symbol + in the library for no reason a person could see." + [clip sid id {:keys [deep?]}] + (let [n (get-in clip [:symbols sid :nodes id]) + from (node/source n) + elsewhere (for [[osid osym] (:symbols clip) + [oid on] (:nodes osym) + :when (and (= from (node/source on)) (not= [sid id] [osid oid]))] + [osid oid])] + (if-let [why (or (when-not from "select a cel to make unique") + (when-not (clip/symbol clip from) "the drawing it places is missing") + (when (empty? elsewhere) "nothing else uses this drawing"))] + {:refused why} + (let [{c :clip copy :id} (copied clip from deep?) + c (assoc-in c [:symbols sid :nodes id :source :symbol] copy) + ps (clip/problems c)] + (if (seq ps) {:refused (first ps)} {:clip c :selection id}))))) diff --git a/frontend/src/arthur/domain/nest.cljs b/frontend/src/arthur/domain/nest.cljs index 9b0aef9..2903050 100644 --- a/frontend/src/arthur/domain/nest.cljs +++ b/frontend/src/arthur/domain/nest.cljs @@ -33,7 +33,7 @@ [clip store sid frame id] (let [sym (clip/symbol clip sid) sym (update sym :nodes select-keys (symbol/lineage (:nodes sym) id)) - r (symbol/resolver sym store pal/index-of nil {:source-fps (:fps clip)})] + r (symbol/resolver sym store pal/index-of {:source-fps (:fps clip)})] (r frame) r)) @@ -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 + ;; cel: inside a held cel is its drawing on the frame + ;; the hold pins, not on `local`, and inside a playing insert + ;; is its animation at its own in-point and speed. + shown (when (number? local) + (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 cel and parent clocks. + A held visual source is silent. Every returned track carries a source offset, + an output interval, and automation mapped into the open symbol's time." [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: + ;; a cel 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 5ee917a..39f506c 100644 --- a/frontend/src/arthur/domain/node.cljs +++ b/frontend/src/arthur/domain/node.cljs @@ -81,6 +81,21 @@ [n] (merge (defaults-of n) (:channels n))) +(defn measured? + "Is this node's transform regenerated from the footage rather than authored? + + ONE PREDICATE, TWO CALLERS, and they are the same question asked twice: a hand + edit to a measured transform is thrown away by the next regenerate, which is + what `gesture/refusal` refuses — and a DEFAULT written under one is worse than + useless, which is what `flow/freeze`'s pivot pass declines to do. A node a hand + cannot transform has no use for a pivot, and at a measured scale an anchor does + not cancel out of `local!` the way it does at the identity, so writing one + would move the very thing it was meant to leave alone." + [n] + (boolean (some #(let [c (get-in n [:channels [:xform %]])] + (or (:dense c) (:generated c))) + [:pos :rot :scale]))) + (defn set-channel "Write `v` into channel `path`: a key on the node's own frame `f` when the channel is keyed, its one value when it is not." @@ -92,10 +107,13 @@ (defn toggle-key "Key channel `path` on the node's own frame `f` with the value it has there, or take the key there off. The first key starts the channel animating and taking - the last one off leaves it that one value. A boolean holds; anything else tweens." - [n path f] + the last one off leaves it that one value. A boolean holds; anything else tweens. + + `store` because the value it keys is read out of the channel, and a measured + channel's values live in tier 2." + [n path f store] (let [c (get (channels n) path) - v (ch/value-at c f) + v (ch/value-at c f store) ks (dissoc (:keys c) f)] (assoc-in n [:channels path] (cond @@ -118,13 +136,13 @@ ;; --------------------------------------------------------------------------- ;; time maps ;; -;; Exposure, mouth lead and a symbol instance's timing are ONE mechanism, and +;; Cel, mouth lead and a symbol instance's timing are ONE mechanism, and ;; seeing that is what keeps them from being three implementations that disagree ;; at the edges. (defn expose "Hold a frame back onto an exposure grid: 1 = on 1s, 2 = on 2s. Frame 5 at - exposure 2 reads the pose from frame 4. + cel 2 reads the pose from frame 4. FLOOR, NEVER ROUND. Rounding would let an output frame read a pose from the FUTURE, which is a lead — a separate control, applied after this one, for a @@ -152,12 +170,12 @@ "A node's own time as the affine map it is: `{:at a :rate r}`, meaning a frame `p` of its parent is frame `r·(p − a)` of its own. THE SAME FOR EVERY NODE. A node with no time map is `{:at 0 :rate 1}`, reading its parent's frames as its - own; a mouth lead's `:offset` is folded into `:at`. Exposure and picture + own; a mouth lead's `:offset` is folded into `:at`. Cel and picture sampling are floors, not part of the map, and are left out: this is the map a 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}))) @@ -185,21 +203,75 @@ (let [{:keys [at rate]} (time-of n)] [(+ at (/ in rate)) (+ at (/ out rate))]))) +;; --------------------------------------------------------------------------- +;; what an instance places +;; +(defn source + "The symbol used by this cel. Sequence groups arrange cels; + a row is a view of that group, not one row per source." + [n] + (when (= :instance (:kind n)) (get-in n [:source :symbol]))) + +(defn sources + "Structural references, including cels outside the playhead." + [n] + (if-let [sid (source n)] #{sid} #{})) + +(defn playback-of [n] + (merge {:in 0 :speed 1 :end :stop} (:playback n))) + +(defn source-time + "Invertible cel -> source map, or nil for holds and endpoint policies. + Forward sampling remains available through `placed-frame` in every case." + [n] + (let [{:keys [in speed end]} (playback-of n)] + (when (and (pos? speed) (= :stop end) (not (get-in n [:time :loop?]))) + {:at (- (/ in speed)) :rate speed}))) + +(defn placed-frame + "Sample source time without changing the cel's property clock: + `{:symbol :frame}`, the symbol shown and which of its frames. `length` is that + symbol's frame count. Nil means no source contribution — this cel + places nothing, or its playback has run past what there is to show." + [n f length] + (when-let [sid (source n)] + (when (and (number? length) (pos? length)) + (let [{:keys [in speed end]} (playback-of n) + raw (+ in (* speed f)) + frame (case (if (get-in n [:time :loop?]) :loop end) + :loop (mod raw length) + :hold (max 0 (min (dec length) raw)) + :stop raw)] + (when (and (<= 0 frame) (< frame length)) + {:symbol sid :frame frame}))))) + +(defn lane? + "Is this group a LANE — a succession of cels rather than a composition? + + `:layout :sequence` is the field because it names the RULE: children follow + one another and may not overlap. A group carrying it is called a lane, which + is the one place two words are kept for one thing, and they are kept apart on + purpose — the layout says what the rule is, the noun says what the thing is." + [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. ORDER IS LOAD-BEARING: expose first, then offset. Flooring onto a grid and shifting against the clock do not commute — shift first and the floor discards - it on most frames, so the lead slider reads as doing nothing at exposures above + it on most frames, so the lead slider reads as doing nothing at cels above 1, which is indistinguishable from the slider being unwired. Composed along the parent chain, outermost first, by symbol/eval-frame. Two - rules fall out and they are different rules: exposure INHERITS STRICTLY, + rules fall out and they are different rules: cel INHERITS STRICTLY, because a head cutting on odd frames against a mouth cutting on even ones reads as two performances; offset is PER-NODE by design, because mouth lead applies to performance nodes and not to the plate, which is the entire point of it." [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 @@ -352,15 +424,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 (lane? 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/paint.cljs b/frontend/src/arthur/domain/paint.cljs index d62b91e..e96174e 100644 --- a/frontend/src/arthur/domain/paint.cljs +++ b/frontend/src/arthur/domain/paint.cljs @@ -25,7 +25,7 @@ {:id id :name (str "shape " (inc (count (shapes clip sid)))) :kind :poly :paint? true :parent nil :z z :span [frame end] - :channels {geometry (channel/keyed {frame points}) + :channels {geometry (channel/keyed {frame points} :hold) [:style :color] (channel/framed color) ;; Turned and scaled about its middle, as a placed ;; symbol is: set once here and never followed. @@ -41,7 +41,9 @@ [start end] (:span node)] (if (and (:paint? node) (<= start frame) (< frame end) ch) (assoc-in clip (into path [:channels geometry :keys frame]) - (vec (channel/value-at ch frame))) + ;; A drawing is authored and keyed, never dense, so there is + ;; no tier-2 store to read it out of. + (vec (channel/value-at ch frame nil))) clip))) (defn set-vertex [clip sid id key-frame vertex [x y]] diff --git a/frontend/src/arthur/domain/pick.cljs b/frontend/src/arthur/domain/pick.cljs index 5fedcfd..24cbe4e 100644 --- a/frontend/src/arthur/domain/pick.cljs +++ b/frontend/src/arthur/domain/pick.cljs @@ -75,35 +75,83 @@ (subvec hit 0 (inc (count selected))) selected)) -(defn local-bounds - "`[x0 y0 x1 y1]` around what node `n` draws on its own frame `f`, in its own - coordinates, or nil when it draws nothing there. Inside an instance is its - symbol, resolved at that frame." - [document store n f] +(defn- union + "The smaller box around both, either of which may be nil." + [a b] + (cond (nil? a) b + (nil? b) a + :else (let [[ax0 ay0 ax1 ay1] a [bx0 by0 bx1 by1] b] + [(min ax0 bx0) (min ay0 by0) (max ax1 bx1) (max ay1 by1)]))) + +(defn bounds-of + "A closure from a frame to `[x0 y0 x1 y1]` around what node `n` draws on it, in + its own coordinates — nil on a frame it draws nothing on. + + A CLOSURE, as `clip/resolver` is, and for the same reason it is: inside an + instance is its whole symbol resolved at that frame, and a resolver costs the + symbol to BUILD and a lookup to RUN. Asking frame by frame through a fresh one + is a resolver per frame, which is what made `pivot` want a separate path for + instances rather than the one walk it is." + [document store n] (let [grow (fn [[x0 y0 x1 y1 :as b] x y] (if b [(min x0 x) (min y0 y) (max x1 x) (max y1 y)] [x y x y])) - at #(ch/value-at (get (node/channels n) %) f store)] + 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) - f (if (get-in n [:time :loop?]) (mod f frames) f)] - (when (< -1 f frames) - (reduce (fn [b {:keys [kind pts n cx cy r size]}] - (case kind - :poly (reduce #(grow %1 (aget pts (* 2 %2)) (aget pts (inc (* 2 %2)))) - b (range n)) - :disc (-> b (grow (- cx r) (- cy r)) (grow (+ cx r) (+ cy r))) - :rect (let [h (/ size 2)] - (-> b (grow (- cx h) (- cy h)) (grow (+ cx h) (+ cy h)))) - b)) - nil - ((clip/resolver document store pal/index-of sid) f)))) - :poly (let [pts (at [:geom :pts])] - (when-not (ch/nothing? pts) - (reduce (fn [b i] (grow b (ch/component pts (* 2 i)) (ch/component pts (inc (* 2 i))))) - nil (range (quot (if (vector? pts) (count pts) (.-length pts)) 2))))) - :disc (let [r (at [:geom :radius])] (when-not (ch/nothing? r) [(- r) (- r) r r])) - :rect (let [s (at [:geom :size])] - (when-not (ch/nothing? s) (let [h (/ s 2)] [(- h) (- h) h h]))) - nil))) + ;; 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)))) + b (range n)) + :disc (-> b (grow (- cx r) (- cy r)) (grow (+ cx r) (+ cy r))) + :rect (let [h (/ size 2)] + (-> b (grow (- cx h) (- cy h)) (grow (+ cx h) (+ cy h)))) + b)) + nil + (resolve f)))))) + :poly (fn [f] + (let [pts (at f [:geom :pts])] + (when-not (ch/nothing? pts) + (reduce (fn [b i] (grow b (ch/component pts (* 2 i)) (ch/component pts (inc (* 2 i))))) + nil (range (quot (if (vector? pts) (count pts) (.-length pts)) 2)))))) + :disc (fn [f] + (let [r (at f [:geom :radius])] + (when-not (ch/nothing? r) [(- r) (- r) r r]))) + :rect (fn [f] + (let [s (at f [:geom :size])] + (when-not (ch/nothing? s) (let [h (/ s 2)] [(- h) (- h) h h])))) + (constantly nil)))) + +(defn pivot + "The middle of everything node `n` draws over its own frames `fs`, in its own + coordinates — where it should turn and scale about. Nil for a node that draws + nothing on any of them. + + `clip/center`'s rule, for a NODE rather than a symbol, and the same rule + `clip/place-symbol` and `paint/new-shape` already set theirs by. A node left + without one pivots about its own coordinate ORIGIN, and an origin is not a + middle: traced geometry is in the footage's normalised space, whose origin is + the top-left corner of the IMAGE, so the pivot lands hundreds of stage pixels + off the stage and a corner drag slides the shape about instead of resizing it. + + ALL its frames, not the first, for the reason `clip/center` says: a mouth that + opens and travels still has its middle where the mouth is." + [document store n fs] + (let [bounds (bounds-of document store n)] + (when-let [[x0 y0 x1 y1] (reduce #(union %1 (bounds %2)) nil fs)] + [(/ (+ x0 x1) 2) (/ (+ y0 y1) 2)]))) diff --git a/frontend/src/arthur/domain/pose.cljs b/frontend/src/arthur/domain/pose.cljs index d305fd6..c868c14 100644 --- a/frontend/src/arthur/domain/pose.cljs +++ b/frontend/src/arthur/domain/pose.cljs @@ -2,10 +2,11 @@ "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." + "Sort pose tracks once when building a resolver." [tracks] (into {} (map (fn [[group entries]] @@ -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 cel places. Which + ;; drawing a lane shows is a question about the lane's other cels, + ;; and each of them owns its own tracks — so there is nothing to union. + placed (get-in clip [:symbols (node/source inst)]) + length (or (:frames placed) 0) active (filter (fn [n] (some :pose-sampled? (vals (:channels n)))) (vals (:nodes 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)) @@ -66,7 +70,7 @@ clip))) (defn problems - "Errors in one symbol instance's exposure tracks." + "Errors in one symbol instance's pose tracks." [tracks source-frames groups] (cond (nil? tracks) [] diff --git a/frontend/src/arthur/domain/project.cljs b/frontend/src/arthur/domain/project.cljs index 9d83172..0fc7f8b 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 cel 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/symbol.cljs b/frontend/src/arthur/domain/symbol.cljs index 162b72a..7737a28 100644 --- a/frontend/src/arthur/domain/symbol.cljs +++ b/frontend/src/arthur/domain/symbol.cljs @@ -26,11 +26,13 @@ Two ways to evaluate one at a frame: - (eval-frame sym f store) THE SPECIFICATION. Allocating, order-free, + (eval-frame sym f store palette opts) + THE SPECIFICATION. Allocating, order-free, obviously correct. Use it in tests and for a one-off render. - (resolver sym store) -> (fn [f] ops). What playback uses. Caches the + (resolver sym store palette opts) + -> (fn [f] ops). What playback uses. Caches the topological order and the z paths, holds one CURSOR per channel and one PREALLOCATED point buffer per node, so a frame allocates the op @@ -88,6 +90,47 @@ [nodes id] (dec (count (lineage nodes id)))) +(defn lane-cels + "The cels of lane `lane`, in the order they are exposed. + + Sorted by where they START, not by `:z`: a lane's blocks follow one another in + time, and two of them cannot be in the same place for `:z` to decide between. + Ties go to the id so the order is the same on every run." + [nodes lane] + (->> (vals nodes) + (filter #(= lane (:parent %))) + (sort-by (juxt #(or (first (node/placed-span %)) 0) #(str (:id %)))) + vec)) + +(defn lane-problems + "What makes a lane not a lane. A SEQUENCE is the one composition rule the node + map carries — ordinary groups compose freely — so it is checked here, beside + the parent and stencil references, rather than wherever a command happens to + build one. + + Cels must be visual, finite and non-overlapping. An accidental overlap + is refused rather than resolved by draw order: two drawings exposed on one + frame of one lane is a document nobody meant to write, and picking a winner + would hide it. Empty lanes are valid — a lane is made before it is filled." + [nodes] + (vec + (mapcat + (fn [[id lane]] + (when (node/lane? 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 cels: " (:id n))) + (when (some (fn [[[_ b] [c _]]] (> b c)) (partition 2 1 intervals)) + [(str "lane " id " has overlapping cels")]))))) + nodes))) + (defn order "Node ids in topological order: every node after its parent. @@ -353,7 +396,7 @@ {:keys (vec (sort-by str (keys sym)))}))) (into {} (remove #(= :audio (:kind (val %)))) nodes))) -(defn- channel-frame +(defn- base-channel-frame "A trace selects the measured frames its node reads; marked channels read instance pose choices." [choices traces nodes source-fps picture-fps id c lf] @@ -422,25 +465,21 @@ the only place the space changes. This is the definition of what a frame means. `resolver` is what plays it." - ([sym f] (eval-frame sym f nil pal/index-of)) - ([sym f store] (eval-frame sym f store pal/index-of)) - ([sym f store palette] (eval-frame sym f store palette nil nil)) - ([sym f store palette pose-tracks opts] + [sym f store palette {:keys [pose-tracks source-fps picture-fps]}] (let [nodes (nodes-of sym) choices (pose/prepare pose-tracks) traces (prepared-traces nodes) - {:keys [source-fps picture-fps]} opts ord (order nodes)] (eval-into {:read (fn [id path c lf] - (ch/value-at c (channel-frame choices traces nodes - source-fps picture-fps id c lf) - store)) + (ch/value-at c (base-channel-frame choices traces nodes + source-fps picture-fps id c lf) + lf store)) :palette palette :mat-for (fn [_id] (node/mat)) :pinv-for (fn [id] (node/pinv (get nodes id))) :buf-for (fn [_id n] (js/Float64Array. (* 2 n))) :scratch (node/mat)} - nodes ord (draw-rank nodes ord) f)))) + nodes ord (draw-rank nodes ord) f))) ;; --------------------------------------------------------------------------- ;; the playback path @@ -484,12 +523,15 @@ The op maps themselves are allocated fresh, and deliberately: there are a dozen of them per frame against hundreds of points, so pooling them would buy - nothing and cost the ability to hand an op list around as plain data." - ([sym] (resolver sym nil pal/index-of nil nil)) - ([sym store] (resolver sym store pal/index-of nil nil)) - ([sym store palette] (resolver sym store palette nil nil)) - ([sym store palette pose-tracks] (resolver sym store palette pose-tracks nil)) - ([sym store palette pose-tracks {:keys [source-fps picture-fps]}] + nothing and cost the ability to hand an op list around as plain data. + + `store` and `palette` are POSITIONAL because neither is optional: a dense + channel cannot be read without the store it names, and every op carries a + colour index. `opts` is a map because the rest genuinely are optional, and + because a fifth of them later is then a key rather than a nil at every one of + these call sites — which is what the arity ladder that used to be here was + standing in for." + [sym store palette {:keys [pose-tracks source-fps picture-fps]}] (let [nodes (nodes-of sym) choices (pose/prepare pose-tracks) traces (prepared-traces nodes) @@ -515,9 +557,11 @@ ;; 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 + (base-channel-frame choices traces nodes + source-fps picture-fps id c lf) + lf))) :palette palette :mat-for (fn [id] (get mats id)) :on-place (fn [id p] (vswap! placed assoc id p)) @@ -532,7 +576,7 @@ (-invoke [_ f] (step f)) IResolver (world-of [_ id] (:m (get @placed id))) - (frame-of [_ id] (:f (get @placed id))))))) + (frame-of [_ id] (:f (get @placed id)))))) ;; --------------------------------------------------------------------------- @@ -570,6 +614,7 @@ (if-not (map? nodes) [":nodes must be a map of id -> node"] (-> [] + (into (lane-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 55179e5..5ea8018 100644 --- a/frontend/src/arthur/domain/trace.cljs +++ b/frontend/src/arthur/domain/trace.cljs @@ -9,13 +9,12 @@ :trace {:frames [0 12 30] :origin :keys} - Whether the photo is showing, and how strongly, is a viewing aid for one - placement. It is on an INSTANCE, not keyed, and draws nothing into the picture. - It covers every face at or below that instance, and the nearest instance that - says anything decides, so a take shows its faces' footage and one face inside - it can still be switched off: - - :underlay {:on? true :opacity 0.5} + Whether the photo is showing, and how strongly, is not the document's at all. + It is a viewing aid, like soloing a row, so it lives in the editor's own state + — `[:ui :trace]`, a set of face symbols switched on and one opacity — and is + never keyed, saved or exported. A face is the same face wherever it is placed, + so one switch shows it in the take it is placed in AND in its own tab, which is + where it is drawn over; nothing has to be switched on twice or per placement. THE TRACE IS THE ONE FACT about which measured frame a head reads. `:continuous` reads the frame it is on, `:keys` jumps to each trace frame's measured head and @@ -29,6 +28,10 @@ (def origins [:continuous :keys :start]) +(def ^:const opacity-default + "How strongly a switched-on photo draws until someone moves the slider." + 0.5) + (defn of "The head's trace, a head that has none being one that moves freely." [head] @@ -111,37 +114,50 @@ (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 [])))) -(defn underlay-at - "The underlay in force at the instance at row path `path` from symbol `sid`: - the nearest one set on it or above it, `:own?` saying which. Nil when none is." - [clip sid path] - (:u (reduce (fn [{:keys [sid u]} id] - (let [n (get-in clip [:symbols sid :nodes id])] - {:sid (:of n) - :u (if-let [own (:underlay n)] - (assoc own :own? true) - (some-> u (assoc :own? false)))})) - {:sid sid} path))) +(defn traceable-faces + "Every face that can be traced while symbol `sid` is open, each once: `sid` + itself when it is a face — open in its own tab, to be drawn over — and the + faces placed inside it at any depth." + [clip sid] + (into [] (distinct) + (cond->> (map :face (faces clip sid)) + (traceable? clip sid) (cons sid)))) + +(defn showing-for + "The faces showing their footage once `sid` is the open symbol, given the ones + `on` already showing. + + OPENING A FACE IS ASKING TO DRAW OVER IT: a symbol has measured footage behind + it only because it was traced from that footage, so its own tab starts with the + footage showing rather than with a switch to be found first. A take or a scene + is the picture itself, and a reference drawn over one would read as part of it, + so nothing is switched on for those. Either way it is switched by hand + afterwards, from the bar above the stage or from a face's own timeline row." + [clip sid on] + (cond-> (set on) (traceable? clip sid) (conj sid))) (defn shown - "Every face whose footage shows from symbol `sid` down, as `{:path :face - :opacity}`: the row path of the face's instance, the face's symbol, and how - strongly to draw it." - [clip sid] - (letfn [(walk [sid path opacity] - (mapcat (fn [[id n]] - (when (= :instance (:kind n)) - (let [path (conj path id) - u (:underlay n) - opacity (if u (when (:on? u) (:opacity u 0.5)) opacity)] - (cond->> (walk (:of n) path opacity) - (and opacity (traceable? clip (:of n))) - (cons {:path path :face (:of n) :opacity opacity}))))) - (get-in clip [:symbols sid :nodes])))] - (vec (walk sid [] nil)))) + "The faces whose footage is showing while symbol `sid` is open, as `faces` lists + them — the row path of the face's instance from `sid`, and the face — filtered + to the `showing` set. + + The open symbol itself is in the list, at the EMPTY path, when it is a face: + that is a face open in its own tab to be drawn over, and the one place tracing + matters most. There is no inheritance to work out and no opacity to carry + because the switch is the face's, not a placement's." + [clip sid showing] + (into [] (filter (comp (set showing) :face)) + (cond->> (faces clip sid) + (traceable? clip sid) (cons {:path [] :in sid :face 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/playback.cljs b/frontend/src/arthur/events/playback.cljs index d0cbdce..2686c49 100644 --- a/frontend/src/arthur/events/playback.cljs +++ b/frontend/src/arthur/events/playback.cljs @@ -13,6 +13,7 @@ (:require [arthur.audio.mix :as mix] [arthur.clock :as clock] [arthur.domain.clip :as clip] + [arthur.domain.trace :as trace] [arthur.footage.store :as footage] [re-frame.core :as rf])) @@ -36,7 +37,13 @@ (-> db (assoc :clip/current id :clip (select-keys entry [:fps :width :height :audio :display-fps])) - (update :ui merge {:open sid :tabs (if sid [sid] [])}) + (update :ui merge + {:open sid :tabs (if sid [sid] []) + ;; From scratch, not merged: the faces switched on were another + ;; document's, and a face id means nothing in this one. + :trace {:faces (trace/showing-for (:clip entry) sid #{}) + :opacity (or (get-in db [:ui :trace :opacity]) + trace/opacity-default)}}) (assoc-in [:playback :frame] 0) (assoc-in [:playback :playing?] false)))) @@ -188,6 +195,7 @@ {:db (-> db (update-in [:ui :tabs] #(if (some #{sid} %) % (conj (vec %) sid))) (assoc-in [:ui :open] sid) + (update-in [:ui :trace :faces] #(trace/showing-for clip sid %)) (assoc-in [:playback :frame] 0) (assoc-in [:playback :playing?] false)) ::pause! nil diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index 8ae3e69..fe3f87b 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -622,7 +622,8 @@ (rf/reg-event-db ::toggle-key (fn [db [_ sid id path frame]] - (edit/edit db #(update-in % [:symbols sid :nodes id] node/toggle-key path frame)))) + (let [st (:store (store/entry (:clip/current db)))] + (edit/edit db #(update-in % [:symbols sid :nodes id] node/toggle-key path frame st))))) ;; A face's trace frames and origin, on its symbol — see `domain/trace`. (rf/reg-event-db @@ -630,12 +631,6 @@ (fn [db [_ sid value]] (edit/edit db #(assoc-in % [:symbols sid :nodes :head :trace] value)))) -;; Whether one instance shows its face's footage under it. Not keyed: it is a -;; drawing aid, not part of the picture. -(rf/reg-event-db - ::set-underlay - (fn [db [_ sid id underlay]] - (edit/edit db #(assoc-in % [:symbols sid :nodes id :underlay] underlay)))) (rf/reg-event-db ::set-segment-interp diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index e59f872..37e45bb 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.lane :as lane] [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 :lane-retry)) (and (= :node kind) path (not sound?)) (update-in [:ui :expanded] (fnil into #{}) (rest (reductions conj [] (pop path)))))))) @@ -32,6 +35,222 @@ ::set-tone (fn [db [_ tone]] (assoc-in db [:ui :tone] tone))) +(rf/reg-event-db + ::set-time-view + (fn [db [_ view]] + (if (#{:timeline :cel-sheet} view) + (assoc-in db [:ui :time-view] view) + db))) + +(defn apply-lane-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 :lane-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 :lane-retry))))) + +(rf/reg-event-db + ::new-lane + (fn [db _] + (let [clip (:clip (store/entry (:clip/current db))) + sid (get-in db [:ui :open])] + (apply-lane-command db sid (lane/add-lane clip sid (random-uuid)) nil)))) + +(defn- committed + "One appending command, as effects: commit it, and look at what it made. + + Seeking is the whole reason these are `-fx` events. An appended cel lands + past the end of the lane, off the playhead, and a drawing you cannot see is not + one you can draw in. An inserted one is already under the playhead and the + seek is a no-op, which is the same rule and not a second one." + [db sid result retry] + (let [{clip :clip st :store} (store/entry (:clip/current db)) + path (nth (get-in db [:ui :selection]) 3 nil) + {:keys [at rate]} (:time (nest/inside clip st (get-in db [:ui :open]) + (if (seq path) (pop path) []) + (get-in db [:playback :frame])))] + (cond-> {:db (apply-lane-command db sid result retry)} + (and (:clip result) (:frame result) rate) + (assoc :dispatch [::playback/seek (+ at (/ (:frame result) rate))])))) + +(defn- selected-lane + "The lane a command should act in: the selected lane itself, or the one + holding the selected cel." + [clip sid id] + (let [n (get-in clip [:symbols sid :nodes id])] + (if (node/lane? n) id (:parent n)))) + +(defn selection-frame + "The playhead as a frame of the symbol that owns `selection`. + + A timeline selection carries its path from the open symbol. Walking to the + parent of the selected node crosses every enclosing instance clock before a + lane command converts that owning-symbol frame into lane time." + [clip st open selection frame] + (let [[_ sid _ path] selection] + (if (or (= sid open) (not (seq path))) + frame + (:frame (nest/inside clip st open (pop path) frame))))) + +(rf/reg-event-fx + ::append-drawing + (fn [{:keys [db]} [_ extent]] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection]) + result (lane/append-drawing clip sid (selected-lane clip sid id) + (random-uuid) (clip/fresh-id clip) + {:extent (or extent :keep)})] + (committed db sid result [::append-drawing :grow-symbol])))) + +(rf/reg-event-fx + ::reuse-drawing + (fn [{:keys [db]} [_ extent]] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection]) + result (lane/reuse-drawing clip sid (selected-lane clip sid id) (random-uuid) + (node/source (get-in clip [:symbols sid :nodes id])) + {:extent (or extent :keep)})] + (committed db sid result [::reuse-drawing :grow-symbol])))) + +(rf/reg-event-fx + ::duplicate-drawing + (fn [{:keys [db]} [_ extent deep?]] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection]) + result (lane/duplicate-drawing clip sid id (random-uuid) + {:extent (or extent :keep) :deep? deep?})] + (committed db sid result [::duplicate-drawing :grow-symbol deep?])))) + +(rf/reg-event-fx + ::insert-drawing + ;; The playhead is the position: you scrub to where the drawing goes. A lane + ;; that is stepped or retimed off whole frames has no single lane frame for a + ;; symbol frame, and `lane-frame` says so rather than snapping to one. + (fn [{:keys [db]} [_ extent]] + (let [{clip :clip st :store} (store/entry (:clip/current db)) + selection (get-in db [:ui :selection]) + [_ sid id] selection + lane (selected-lane clip sid id) + owner-frame (selection-frame clip st (get-in db [:ui :open]) selection + (get-in db [:playback :frame])) + at (when (number? owner-frame) (lane/lane-frame clip sid lane owner-frame)) + result (if at + (lane/append-drawing clip sid lane (random-uuid) (clip/fresh-id clip) + {:at at :extent (or extent :keep)}) + {:refused "this lane's frames are not the open symbol's"})] + (committed db sid result [::insert-drawing :grow-symbol])))) + +(rf/reg-event-db + ::split-cel + (fn [db _] + (let [{clip :clip st :store} (store/entry (:clip/current db)) + selection (get-in db [:ui :selection]) + [_ sid id] selection + owner-frame (selection-frame clip st (get-in db [:ui :open]) selection + (get-in db [:playback :frame])) + cut (when (number? owner-frame) + (lane/lane-frame clip sid (:parent (get-in clip [:symbols sid :nodes id])) + owner-frame))] + (apply-lane-command + db sid (if cut + (lane/split clip sid id cut (random-uuid)) + {:refused "this lane's frames are not the open symbol's"}) + nil)))) + +(defn- at-playhead + "The selected cel, its lane, and the playhead as a frame of that lane's + own time — or a refusal in place of the frame where there is no single one." + [db] + (let [{clip :clip st :store} (store/entry (:clip/current db)) + selection (get-in db [:ui :selection]) + [_ sid id] selection + n (get-in clip [:symbols sid :nodes id])] + {:clip clip :sid sid :id id :node n + :at (when-let [owner-frame (selection-frame clip st (get-in db [:ui :open]) selection + (get-in db [:playback :frame]))] + (lane/lane-frame clip sid (:parent n) owner-frame))})) + +(rf/reg-event-fx + ::overwrite-drawing + (fn [{:keys [db]} [_ extent]] + (let [{clip :clip st :store} (store/entry (:clip/current db)) + selection (get-in db [:ui :selection]) + [_ sid id] selection + lane-id (selected-lane clip sid id) + owner-frame (selection-frame clip st (get-in db [:ui :open]) selection + (get-in db [:playback :frame])) + at (when (number? owner-frame) (lane/lane-frame clip sid lane-id owner-frame)) + result (if (integer? at) + (lane/overwrite-drawing clip sid lane-id (random-uuid) (clip/fresh-id clip) at + {:extent (or extent :keep) + :remainder-id (random-uuid)}) + {:refused "this lane's frames are not the open symbol's"})] + (committed db sid result [::overwrite-drawing :grow-symbol])))) + +(rf/reg-event-db + ::trim-cel + (fn [db [_ edge]] + (let [{:keys [clip sid id at]} (at-playhead db)] + (apply-lane-command + db sid (if at + (lane/trim clip sid id edge at) + {:refused "this lane's frames are not the open symbol's"}) + nil)))) + +(rf/reg-event-db + ::move-cel + (fn [db _] + (let [{:keys [clip sid id at]} (at-playhead db)] + (apply-lane-command + db sid (if at + (lane/move clip sid id at) + {:refused "this lane's frames are not the open symbol's"}) + nil)))) + +(rf/reg-event-db + ::blank-cel + ;; The selected cel's own frames, so the range needs no second gesture and + ;; the case that would split a cel cannot arise. + (fn [db _] + (let [{:keys [clip sid node]} (at-playhead db) + span (node/placed-span node)] + (apply-lane-command + db sid (if (and span (every? integer? span)) + (lane/blank clip sid (:parent node) span {}) + {:refused "select a cel that starts and ends on whole lane frames"}) + nil)))) + +(rf/reg-event-db + ::make-unique + (fn [db [_ deep?]] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection])] + (apply-lane-command db sid (lane/make-unique clip sid id {:deep? deep?}) nil)))) + +(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 (lane/extend-hold clip sid id delta {:extent (or extent :keep)})] + (apply-lane-command db sid result [::extend-hold delta :grow-symbol])))) + +(rf/reg-event-fx + ::lane-retry + (fn [{:keys [db]} _] + (if-let [event (get-in db [:ui :lane-retry])] + {:db (update db :ui dissoc :lane-retry) :dispatch event} + {}))) + (rf/reg-event-db ::toggle-row (fn [db [_ path]] @@ -49,6 +268,32 @@ (= on #{path}) #{} :else #{path}))))) +(rf/reg-event-db + ::trace-face + ;; Showing the footage under a face is a viewing aid, like solo: editor state + ;; rather than the document, so it is not an undo step, does not travel to a + ;; collaborator and cannot reach an export. Per FACE and not per placement — a + ;; face is the same face wherever it is placed, and it is the face being traced + ;; — so one switch shows it in the take and in its own tab both. + (fn [db [_ face]] + (update-in db [:ui :trace :faces] + #(if (contains? % face) (disj % face) (conj (set %) face))))) + +(rf/reg-event-db + ::trace-faces + ;; Every face the open symbol has, from the bar above the stage: switched on + ;; unless they all already are, which is the one gesture a person wants when + ;; there is exactly one face and when there are five. + (fn [db [_ faces]] + (let [faces (set faces) + on (set (get-in db [:ui :trace :faces]))] + (assoc-in db [:ui :trace :faces] + (if (every? on faces) (reduce disj on faces) (into on faces)))))) + +(rf/reg-event-db + ::trace-opacity + (fn [db [_ opacity]] (assoc-in db [:ui :trace :opacity] opacity))) + (defn- where-new-goes "The row path, from the open symbol down, of the symbol a new thing goes into: INSIDE the selected instance, or BESIDE any other selected node, or at the top diff --git a/frontend/src/arthur/export.cljs b/frontend/src/arthur/export.cljs index 2aee8d2..3db64be 100644 --- a/frontend/src/arthur/export.cljs +++ b/frontend/src/arthur/export.cljs @@ -84,14 +84,14 @@ Four things, and each for its own reason: the node itself; - everything ABOVE it, because a placement's transform is relative to its + everything ABOVE it, because an instance's transform is relative to its parent and dropping the chain would move the thing being isolated; everything BELOW it, because a group instance is its children; any audio track `:linked-to` it, because the link is the statement that this - sound belongs to that placement, and a face exported without its voice is + sound belongs to that instance, and a face exported without its voice is not the thing that was asked for. - Siblings go. That is the whole point: what comes out is one placement, where it + Siblings go. That is the whole point: what comes out is one instance, where it sits, in the symbol it sits in." [nodes id] (let [up (loop [i id acc #{}] @@ -117,9 +117,9 @@ "The symbol with only `id` and its kin kept. `nil` leaves it alone. The FRAME SPACE IS UNTOUCHED, which is what makes this different from exporting - the symbol a placement plays. Rooting at `:sym/face-8625` renders the drawing in - its own time, identically for all seven placements. Isolating one placement - renders the STAGE — its length, its rate, the placement's span, drift and scale + the symbol an instance plays. Rooting at `:sym/face-8625` renders the drawing in + its own time, identically for all seven instances. Isolating one instance + renders the STAGE — its length, its rate, the instance's span, drift and scale — with the other six removed. The first is the drawing; the second is that face on the stage, and they are different deliverables." [sym id] @@ -195,8 +195,7 @@ ;; inside it still resolve — clip/resolver is the function that knows ;; how. doc (assoc-in clip [:symbols sid] sym) - resolve-frame (clip/resolver doc store palette sid - {:picture-fps picture-fps}) + resolve-frame (clip/resolver doc sid store palette {:picture-fps picture-fps}) ras (raster/make width height) bg (get palette :bg 0)] (-> (audio! doc sid store audio-url) diff --git a/frontend/src/arthur/flow/freeze.cljs b/frontend/src/arthur/flow/freeze.cljs index 90b87c3..ab989a4 100644 --- a/frontend/src/arthur/flow/freeze.cljs +++ b/frontend/src/arthur/flow/freeze.cljs @@ -35,6 +35,8 @@ (:require [arthur.domain.channel :as ch] [arthur.domain.feature :as feature] [arthur.domain.geom :as geom] + [arthur.domain.node :as node] + [arthur.domain.pick :as pick] [arthur.domain.ring :as ring] [arthur.domain.trace :as trace] [arthur.flow.address :as address])) @@ -310,7 +312,7 @@ (when (or (zero? f) (not= (nth shown f) (nth shown (dec f)))) [f (nth shown f)]))) - (range (count shown)))) + (range (count shown))) :hold) :generated generated))) (defn- keyed-visibility [values generated] @@ -318,7 +320,7 @@ (when (or (zero? f) (not= (nth values f) (nth values (dec f)))) [f (nth values f)]))) - (range (count values)))) + (range (count values))) :hold) :generated generated)) (def ^:private pose-groups @@ -671,6 +673,46 @@ {}) :store (into (:store head) (mapcat :store) parts)})) +(defn pivoted + "Every node a freeze makes that a hand can transform, pivoting about the + middle of what it draws. + + THE SAME RULE AS EVERYWHERE ELSE, and this is the one place that used to skip + it: `clip/place-symbol` writes an instance's anchor, `paint/new-shape` a + drawing's, `nest/group` a new symbol's, and `face-placement` the source + placement's — and the traced parts underneath it got none, so each of them + turned and scaled about ITS OWN ORIGIN, which for head-local geometry is the + top-left corner of the footage. `freeze_test` already said why that is wrong + for the face; it is no less wrong for the mouth. + + WHAT IT SKIPS IS `node/measured?`, the predicate `gesture/refusal` refuses a + hand edit by — so a node gets a pivot exactly when a hand can use one, which + is the invariant worth having rather than a list of exceptions. It is also + what keeps this off `:head`: the head carries the measured similarity, its + scale is nowhere near 1, and an anchor under a scale does NOT cancel out of + `node/local!` the way it does at the identity, so writing one there would move + the whole face. Skipping it because it draws nothing would be true today and + true by accident. + + A DEFAULT, written once, never followed: an anchor already on a node is left + alone, and nothing updates one when the geometry moves later. On everything it + does write to, rotation and scale are the identity, where the anchor cancels + out — so this changes where a part pivots and not one pixel of what it draws." + [clip store] + (reduce + (fn [c [sid id]] + (let [n (get-in c [:symbols sid :nodes id]) + at [:symbols sid :nodes id :channels [:xform :anchor]]] + (if (or (get-in c at) (node/measured? n)) + c + (if-let [p (pick/pivot c store n (range (get-in c [:symbols sid :frames])))] + (assoc-in c at (ch/framed p)) + c)))) + clip + (for [sid (sort-by str (keys (:symbols clip))) + id (sort-by str (keys (get-in clip [:symbols sid :nodes])))] + [sid id]))) + (defn clip "Subject-id -> conditioned measurements becomes one symbol per face, and a symbol called :main that places them. @@ -709,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 @@ -719,8 +762,10 @@ (= subject (get-in built [:features (feature/owned subject id) :subject]))) (throw (ex-info "presence must name this subject's feature and span the take" {:subject subject :feature id :frames nf :actual (count track)})))) - {:store (merged :store) - :clip (reduce (fn [c [subject inputs]] - (head-mode {:subject subject :trace (get inputs :trace (:trace params))} - {:clip c})) - built ordered)})) + (let [store (merged :store)] + {:store store + :clip (-> (reduce (fn [c [subject inputs]] + (head-mode {:subject subject :trace (get inputs :trace (:trace params))} + {:clip c})) + built ordered) + (pivoted store))}))) diff --git a/frontend/src/arthur/flow/regenerate.cljs b/frontend/src/arthur/flow/regenerate.cljs index 7fe91da..03e58a8 100644 --- a/frontend/src/arthur/flow/regenerate.cljs +++ b/frontend/src/arthur/flow/regenerate.cljs @@ -1,7 +1,8 @@ (ns arthur.flow.regenerate "Recompute a changed feature from retained source tracks, then replace only channels owned by that feature. Upload remains project/save's ordinary job." - (:require [arthur.domain.feature :as feature] + (:require [arthur.domain.channel :as ch] + [arthur.domain.feature :as feature] [arthur.domain.params :as params] [arthur.flow.address :as address] [arthur.flow.freeze :as freeze] @@ -33,6 +34,35 @@ (select-keys (settings clip fid) (address/area-knobs (get-in clip [:features fid :area])))) +(defn- rebased + "`fresh` in place of `old`, carrying `old`'s corrections across. + + THIS IS WHAT A LAYER IS FOR. The base is regenerated and the hand work is not, + which is the whole reason a correction is stored over a channel rather than + written into it. A correction the new base no longer fits is MARKED rather than + dropped or misapplied — `channel/conflicts` is how a view finds it — and one + that fits again has its mark cleared, because a regeneration that restores the + topology has resolved it." + [old fresh] + (if-let [over (seq (:over old))] + (assoc fresh :over + (mapv (fn [l] + (if-let [why (ch/conflict-with fresh l)] + (assoc l :conflict why) + (dissoc l :conflict))) + over)) + fresh)) + +(defn- bases + "A node's channels without their corrections. + + For asking whether the authored channels still ARE the measurement: a + correction is not a hand PLACEMENT. The base under it has to go on following + re-measurement, or the first correction anyone makes would freeze the part it + was meant to adjust." + [channels] + (into {} (map (fn [[p c]] [p (dissoc c :over)])) channels)) + (defn- replace-feature [entry fragment fid] (let [paths (for [id (get-in entry [:clip :features fid :nodes]) [prop channel] (get-in fragment [:nodes id :channels]) @@ -42,9 +72,7 @@ (let [at [:clip :symbols (get-in entry [:clip :features fid :symbol]) :nodes id :channels prop] old (get-in entry at)] - (assoc-in entry at - (cond-> channel - (contains? old :over) (assoc :over (:over old)))))) + (assoc-in entry at (rebased old channel)))) entry paths) (update :store merge (:store fragment))))) @@ -109,8 +137,10 @@ (cond-> (-> entry (assoc-in (conj at :measured) measured) (update :store merge (:store baked))) - (= (:channels old) (:measured old)) - (assoc-in (conj at :channels) measured)))) + (= (bases (:channels old)) (bases (:measured old))) + (assoc-in (conj at :channels) + (into {} (map (fn [[p c]] [p (rebased (get-in old [:channels p]) c)])) + measured))))) (defn change "One scoped static edit. `source-inputs` holds dense landmarks and, when the diff --git a/frontend/src/arthur/footage/store.cljs b/frontend/src/arthur/footage/store.cljs index 3e620a4..c89ba53 100644 --- a/frontend/src/arthur/footage/store.cljs +++ b/frontend/src/arthur/footage/store.cljs @@ -18,14 +18,13 @@ with nothing to evict it, and the timeline that would want several is out of scope. `kind` only names the id — `:footage/3`, `:project/4` — so that a clip's origin is legible in the db without a lookup." - ([entry] (install! entry "footage")) - ([entry kind] - (let [id (keyword kind (str (swap! serial inc)))] - (when-let [old (:audio @loaded)] - (when (and (not= old (:audio entry)) (.startsWith old "blob:")) - (js/URL.revokeObjectURL old))) - (reset! loaded (assoc entry :id id)) - id))) + [entry kind] + (let [id (keyword kind (str (swap! serial inc)))] + (when-let [old (:audio @loaded)] + (when (and (not= old (:audio entry)) (.startsWith old "blob:")) + (js/URL.revokeObjectURL old))) + (reset! loaded (assoc entry :id id)) + id)) (defn entry [id] (if (= id (:id @loaded)) diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index 0454273..43a4384 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] @@ -24,6 +25,7 @@ (rf/reg-sub ::sliding (fn [db _] (get-in db [:ui :sliding]))) (rf/reg-sub ::gesture (fn [db _] (get-in db [:ui :gesture]))) (rf/reg-sub ::solo (fn [db _] (get-in db [:ui :solo (get-in db [:ui :open])]))) +(rf/reg-sub ::tracing (fn [db _] (get-in db [:ui :trace]))) (rf/reg-sub ::clip @@ -72,10 +74,11 @@ ::exposure :<- [::symbol] (fn [sym _] - ;; Exposure lives on the symbol's root node and is INHERITED, so reading it - ;; there is reading it everywhere. The transport shows it so that `exposure 2` - ;; is visibly doing something at the transport rather than only inside the - ;; document. + ;; NOT a cel. This is `:time :expose` — how many frames each step of a + ;; subtree lasts, which is what shooting on twos means — and it lives on the + ;; symbol's root node and is INHERITED, so reading it there is reading it + ;; everywhere. The transport shows it so that `exposure 2` is visibly doing + ;; something rather than only inside the document. (or (get-in sym [:nodes :root :time :expose]) 1))) (rf/reg-sub @@ -113,7 +116,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 a cel 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 @@ -125,7 +132,7 @@ :<- [::playback/display-fps] (fn [[document sid store palette picture-fps] _] (when (and document (clip/symbol document sid)) - (clip/resolver document store palette sid {:picture-fps picture-fps})))) + (clip/resolver document sid store palette {:picture-fps picture-fps})))) (rf/reg-sub ::shown @@ -163,12 +170,19 @@ :<- [::store] :<- [::open] :<- [::solo] - (fn [[id document store open solo] _] - ;; What `ui/underlay` needs to paint the footage under the faces being - ;; traced, besides the resolver that says where they went. A face outside - ;; every soloed row is not on stage, so neither is its footage. + :<- [::tracing] + (fn [[id document store open solo {:keys [faces opacity]}] _] + ;; What `ui/underlay` needs to paint the footage of the faces being traced, + ;; besides the resolver that says where they went. A face outside every soloed + ;; row is not on stage, so neither is its footage. (let [solo (filter #(placed? document open %) solo)] {:document document :store store :footage-id (:footage-id (footage/entry id)) - :traces (cond->> (when document (trace/shown document open)) - (seq solo) (filterv (fn [{:keys [path]}] (some #(= % (take (count %) path)) solo))))}))) + :opacity (or opacity trace/opacity-default) + :traces (cond->> (when document (trace/shown document open faces)) + (seq solo) (filterv (fn [{:keys [path]}] + ;; The open symbol's own face is at the + ;; empty path: it is not under any row, so + ;; soloing a row cannot hide it. + (or (empty? path) + (some #(= % (take (count %) path)) solo)))))}))) diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs index a00825a..4b97d3d 100644 --- a/frontend/src/arthur/subs/ui.cljs +++ b/frontend/src/arthur/subs/ui.cljs @@ -12,6 +12,8 @@ [re-frame.core :as rf])) (rf/reg-sub ::selection (fn [db _] (get-in db [:ui :selection]))) +(rf/reg-sub ::time-view (fn [db _] (get-in db [:ui :time-view] :timeline))) +(rf/reg-sub ::lane-retry (fn [db _] (get-in db [:ui :lane-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]))) @@ -66,7 +68,7 @@ (when n (let [st (:store (store/entry clip-id))] (when-let [pl (nest/placement clip st open (or path [id]) f)] - (assoc pl :node n :bounds (pick/local-bounds clip st n (:frame pl)))))))) + (assoc pl :node n :bounds ((pick/bounds-of clip st n) (:frame pl)))))))) (rf/reg-sub ::project-footage diff --git a/frontend/src/arthur/ui/drag.cljs b/frontend/src/arthur/ui/drag.cljs index 690dfe3..e3b2188 100644 --- a/frontend/src/arthur/ui/drag.cljs +++ b/frontend/src/arthur/ui/drag.cljs @@ -29,7 +29,7 @@ :disc (select-keys op [:kind :cx :cy :r]) :rect (select-keys op [:kind :cx :cy :size]) nil)) - ((clip/resolver document st pal/index-of sid) 0)))) + ((clip/resolver document sid st pal/index-of nil) 0)))) (defn symbol! "Start carrying symbol `sid` of the loaded document into the open symbol." diff --git a/frontend/src/arthur/ui/icon.cljs b/frontend/src/arthur/ui/icon.cljs new file mode 100644 index 0000000..c91f827 --- /dev/null +++ b/frontend/src/arthur/ui/icon.cljs @@ -0,0 +1,55 @@ +(ns arthur.ui.icon + "The handful of glyphs the chrome uses, drawn here rather than imported. + + NOT an icon library, on purpose. Three reasons, in order of weight: + + 1. SIZE. These render at 11px against 11px type. Lucide, Feather and the rest + are drawn on a 24px grid with a 2px stroke and sub-pixel curves; scaled to + 11 they go soft and land off the pixel grid, in a tool whose entire premise + is that you can see individual pixels. These are drawn on a 12px grid with + whole and half coordinates, so the bars and edges fall where the renderer + wants them. + 2. REGISTER. `app.css` argues the chrome is Macromedia-era — flat, hairline, + no bevels, one accent. A modern rounded icon set is a second visual voice + in a strip 21px tall. + 3. COST. Six glyphs is forty lines. The alternative is a dependency, a build + step's worth of tree-shaking, and a license file, for forty lines. + + Icons are used ONLY where the word is worse than the picture: the transport, + where `|<` was ASCII pretending to be a glyph, and the two playback toggles, + which are state rather than actions. Everything else in the strip keeps its + word, because `insert` and `overwrite` have no pictures and inventing some + would be a puzzle rather than a shorthand. Every one carries an `aria-label` + at the call site; nothing here is the only statement of what a control does." + (:require [clojure.string :as str])) + +;; A glyph is its paths on a 12x12 grid. Solid shapes are filled; the two +;; drawn with a line are stroked, which `kind` says. +(def ^:private glyphs + {:start {:fill ["M2 2.5h1.3v7H2z" ; the bar it stops against + "M10 2.5v7L4.2 6z"]} ; and the triangle into it + :play {:fill ["M3.4 2.3L10 6 3.4 9.7z"]} + :pause {:fill ["M3.3 2.4h1.9v7.2H3.3z" + "M6.8 2.4h1.9v7.2H6.8z"]} + ;; Two arrows round a rectangle: the clip's end rejoining its start. + :loop {:stroke ["M3 5.4V4.6A1.2 1.2 0 0 1 4.2 3.4h5" + "M7.8 1.8 9.4 3.4 7.8 5" + "M9 6.6v.8A1.2 1.2 0 0 1 7.8 8.6h-5" + "M4.2 7 2.6 8.6 4.2 10.2"]} + ;; One speaker, with or without the sound coming out of it. + :sound {:fill ["M2.4 4.6h1.8L6.4 2.6v6.8L4.2 7.4H2.4z"] + :stroke ["M8 4.1a2.7 2.7 0 0 1 0 3.8"]} + :muted {:fill ["M2.4 4.6h1.8L6.4 2.6v6.8L4.2 7.4H2.4z"] + :stroke ["M7.9 4.4 10.7 7.6" "M10.7 4.4 7.9 7.6"]}}) + +(defn view + "The glyph named `k`, sized by CSS and inked in `currentColor` — so a button's + `.on` state colours its icon without this knowing the palette exists." + [k] + (let [{:keys [fill stroke]} (glyphs k)] + [:svg {:viewBox "0 0 12 12" :aria-hidden true :focusable false} + (when (seq fill) + [:path {:d (str/join " " fill) :fill "currentColor"}]) + (when (seq stroke) + [:path {:d (str/join " " stroke) :fill "none" :stroke "currentColor" + :stroke-width 1.2 :stroke-linecap "round" :stroke-linejoin "round"}])])) diff --git a/frontend/src/arthur/ui/location.cljs b/frontend/src/arthur/ui/location.cljs new file mode 100644 index 0000000..2e5b874 --- /dev/null +++ b/frontend/src/arthur/ui/location.cljs @@ -0,0 +1,182 @@ +(ns arthur.ui.location + "The location bar: where you are, and therefore where an edit would land. + + `lane-model.md`, under *UX: location, selection, and controls*, asks for a bar + above the timeline carrying three things — the breadcrumb, the creation + controls, and the shared-content context — and this is it. Until now the + answer to \"which symbol am I editing, five levels down a nested take\" was to + read the timeline's indentation and infer, which is exactly the inference the + document can do for you. + + THE TRAIL IS READ OFF THE DOCUMENT, NOT REMEMBERED. It is the selection's row + path — the vector `nest/inside` reduces over for a frame and a matrix, and the + one `ui/select` carries — with each symbol's own parent chain filled in; see + `trail`. So the bar states a fact about where the selection IS rather than + where somebody has clicked, and every crumb on it is a selection in its own + right, which is what makes clicking one go back out to that level. + + THE TIME IT PRINTS IS HONEST OR ABSENT. `lane-model.md` is explicit that holds + and loops need a true description instead of a fictitious unique global frame, + so the readout comes from `nest/inside` — which samples forward through holds + and refuses to invent an invertible map through a loop — and where that has no + answer this says so instead of computing one." + (:require [arthur.domain.clip :as clip] + [arthur.domain.node :as node] + [arthur.domain.symbol :as symbol] + [arthur.events.ui :as ui] + [arthur.subs.render :as render] + [arthur.subs.ui :as sub] + [arthur.ui.menu :as menu] + [re-frame.core :as rf])) + +(defn- crumb-label + "What to call a node in the trail. + + An INSTANCE is named after the symbol it places, because crossing one is how + you got further in and the symbol is what you are now inside — `main ▸ head ▸ + mouth` is the useful sentence, and `main ▸ 3f2a91c0 ▸ …` is not. Everything + else is named as the timeline names it: `:name` when it has one, and a legible + stand-in when it has not." + [clip id n] + (let [of (node/source n)] + (or (when of (:name (clip/symbol clip of))) + (:name n) + (when of (name of)) + (if (keyword? id) (subs (str id) 1) (subs (str id) 0 8))))) + +(defn trail + "The crumbs from symbol `sid` down to the end of row path `path`, the symbol + first. Each carries the selection that names it, so clicking one goes back out + to that level. + + TWO KINDS OF NESTING, and the trail has to show both. A row path crosses + INSTANCES — each step is a symbol entered — but within one symbol it names + only the node at the end, because `rows` presents a symbol's nodes flat: a cel + is addressed `[cel]` and not `[lane cel]`, since a cel is not a row. So inside + each symbol the walk takes the node's own ancestry as well, which is what puts + the lane a cel sits in on the trail — the thing you are most obviously nested + in, and the one the path alone never mentions. + + A path that has gone stale — the node deleted under it — stops the walk where + it stops being true rather than inventing the rest." + [clip sid path] + (loop [in sid, left (seq path), so-far [], out [{:kind :symbol :sid sid + :label (clip/symbol-name clip sid)}]] + (let [id (first left) + n (when id (get-in clip [:symbols in :nodes id]))] + (if-not n + out + (let [nodes (:nodes (clip/symbol clip in)) + ;; Root first, the node itself last. Every one of them is a row of + ;; this symbol in its own right, so each addresses as the path so + ;; far with that id on the end. + chain (rseq (symbol/lineage nodes id))] + (recur (or (node/source n) in) (next left) (conj so-far id) + (into out + (map (fn [a] + (let [m (get nodes a)] + {:kind (:kind m) + :lane? (node/lane? m) + :sid in :id a :of (node/source m) + :label (crumb-label clip a m) + :select [:node in a (conj so-far a)]}))) + chain))))))) + +(defn- whereabouts + "The one line of context beside the trail: which frame of its own the selection + is showing, and whether that mapping is honest. Nil where there is nothing + true to say. + + WORTH SAYING BECAUSE IT IS NOT THE PLAYHEAD. Every instance between the open + symbol and the selection carries a time map, so a shape six levels down is + showing its frame 6 while the transport reads 7 — a difference nobody can do + in their head and the one the transport cannot report, because the transport + belongs to the open symbol." + [clip n inside] + (let [{inner :sid f :frame t :time} inside + held? (and n (= :instance (:kind n)) (zero? (:speed (node/playback-of n)))) + len (when inner (clip/frames clip inner))] + (cond + (nil? n) nil + (nil? inside) "not on screen on this frame" + (number? f) + (str (if held? "held on frame " "frame ") f + ;; A node that places a symbol has that symbol's length to be a frame + ;; OF; a shape has only its own frame, and inventing a denominator for + ;; it would be inventing a fact. + (when len (str " of " len)) + ;; `nest/inside` gives `:time` only where the map back out is + ;; invertible, and a HOLD is one of the things that makes it not — + ;; but "held" has already said that, and repeating it as a caveat + ;; would put the warning on the ordinary case. What is left to warn + ;; about is a cel whose frames come round again, where the frame + ;; above is being drawn more than once and names no single frame of + ;; the open symbol. + (when (and (nil? t) (not held?)) " · repeats; no single frame above")) + :else nil))) + +(defn- shared-with + "How many places in the document use the same drawing as `n`, or nil where it + places none. Counted rather than flagged, because \"used in 4 places\" is the + fact somebody needs before deciding to decouple one — and counted over every + node that places it, not only over cels, because a drawing reused as a plain + instance somewhere else is just as shared and `make unique` is just as much + the answer." + [clip n] + (when-let [of (and n (node/source n))] + (count (for [[_ sym] (:symbols clip) + [_ other] (:nodes sym) + :when (= of (node/source other))] + other)))) + +(defn view [] + (let [clip @(rf/subscribe [::render/clip]) + open @(rf/subscribe [::render/open]) + selection @(rf/subscribe [::sub/selection]) + inside @(rf/subscribe [::sub/selected-local]) + [kind sid id path] selection + n (when (= :node kind) (get-in clip [:symbols sid :nodes id])) + crumbs (trail clip open (if (and n (seq path)) path (when n [id]))) + last-i (dec (count crumbs)) + shared (shared-with clip n) + says (whereabouts clip n inside)] + [:section.loc + [:nav.crumbs {:aria-label "editing location"} + (doall + (for [[i {:keys [label select lane?] crumb-kind :kind}] (map-indexed vector crumbs)] + ^{:key i} + [:<> + (when (pos? i) [:span.crumb-sep "▸"]) + [:button.crumb + {:class (str (when (= i last-i) "on") (when lane? " lane")) + ;; The root crumb is the open symbol, and going out to it is having + ;; nothing selected — which is a real state, not an absence of one. + :title (if (zero? i) + "the open symbol — clear the selection" + (str label " · " (if lane? "lane" (name crumb-kind)))) + :on-click #(rf/dispatch [::ui/select (when (pos? i) select)])} + label]]))] + (when says + [:span.loc-fact {:title "the frame this selection is showing, in its own time"} + says]) + ;; Offered where it means something and nowhere else, which is also what + ;; makes it an indicator: the row only appears when the drawing IS shared. + (when (and shared (< 1 shared)) + [:span.loc-shared + (str "used in " shared " places") + [:button.link {:title "give this cel its own copy; other cels keep sharing" + :on-click #(rf/dispatch [::ui/make-unique])} + "make unique"]]) + [:span.spacer] + ;; CREATION LIVES HERE because this bar is what says where it would land. + ;; `lane-model.md`: "Creation controls next to the breadcrumb act in that + ;; explicit location." Both commands read the selection, and the trail to + ;; the left of them is that selection written out. + [menu/view + {:label "new" :title "add to the document, at the location named on the left" + :items [{:label "symbol" + :sub "empty, inside the selected instance or beside the selected node" + :on-click #(rf/dispatch [::ui/new-symbol])} + {:label "lane" + :sub "a row that holds one drawing after another" + :on-click #(rf/dispatch [::ui/new-lane])}]}]])) diff --git a/frontend/src/arthur/ui/menu.cljs b/frontend/src/arthur/ui/menu.cljs new file mode 100644 index 0000000..93342fe --- /dev/null +++ b/frontend/src/arthur/ui/menu.cljs @@ -0,0 +1,81 @@ +(ns arthur.ui.menu + "A labelled drop of commands, for a pane head that has more commands than room. + + THE POINT IS NOT SPACE, IT IS THE EXPLANATION. A command that applies only to + a selected cel spends most of its life disabled, and a disabled button is a + grey word with its reason hidden in a `title` nobody hovers. The same command + as a menu row gets a second line saying what it does, visible at the moment + somebody is looking for it, which is the moment the menu is open. + + So a menu stays openable even when every row in it is disabled, and says what + is missing instead. Hiding the rows would answer \"where did split go\" with + silence. + + IT IS PLACED IN THE VIEWPORT, NOT IN THE PANE. A pane clips its own overflow — + `.time` must, or the timeline would push the window taller — so a panel laid + out inside one is cut off at its edge. Measured once per opening against the + button it hangs under, which is also what lets it flip above the button when + there is more room there. The measurement is of the BUTTON and not of the + panel, so it does not need the panel to exist yet. + + The scrim is `ui/openmenu`'s, for the reason given there: a catcher behind the + panel dismisses on any outside click without a document listener that has to + be added, removed, and told to ignore the click that opened the menu." + (:require [reagent.core :as r])) + +(def ^:private gap 2) +(def ^:private margin 8) + +(defn- placement + "Where to put the panel, given the button it belongs to. `:bottom` rather than + `:top` when it opens upward, so the panel grows away from the button in both + directions without anything measuring the panel." + [^js button] + (let [r (.getBoundingClientRect button) + vh (.-innerHeight js/window) + vw (.-innerWidth js/window) + below (- vh (.-bottom r) gap margin) + above (- (.-top r) gap margin) + up? (> above below)] + (merge {:left (max margin (min (.-left r) (- vw 280 margin))) + :max-height (max 120 (if up? above below))} + (if up? + {:bottom (+ (- vh (.-top r)) gap)} + {:top (+ (.-bottom r) gap)})))) + +(defn view + "`{:label :title :note :items}`. An item is + `{:label :sub :disabled? :on-click}`; a nil item is dropped, so a caller can + put a row behind a `when`. `note` is shown when nothing in the menu applies." + [_] + (let [at (r/atom nil)] + (fn [{:keys [label title note items]}] + (let [items (remove nil? items) + dead? (every? :disabled? items) + close! #(reset! at nil)] + [:div.menu-wrap + [:button {:class (when @at "on") :title title + :aria-haspopup "menu" :aria-expanded (boolean @at) + :on-click (fn [^js e] + (if @at (close!) (reset! at (placement (.-currentTarget e)))))} + label " ▾"] + (when-let [{:keys [left top bottom max-height]} @at] + [:<> + [:div.menu-scrim {:on-click close!}] + [:div.menu.menu-drop + {:role "menu" + :style (cond-> {:left (str left "px") :max-height (str max-height "px")} + top (assoc :top (str top "px")) + bottom (assoc :bottom (str bottom "px")))} + (when (and dead? note) [:div.dim.menu-note note]) + (doall + (for [{:keys [label sub disabled? on-click]} items] + ^{:key label} + ;; The row's accessible name is the COMMAND, not the command + ;; followed by its explanation — which is what `textContent` is + ;; here, and what a screen reader would otherwise read out. + [:button.menu-item {:role "menuitem" :disabled (boolean disabled?) + :aria-label label :title sub + :on-click (fn [] (close!) (on-click))} + label + (when sub [:span.sub sub])]))]])])))) diff --git a/frontend/src/arthur/ui/palette.cljs b/frontend/src/arthur/ui/palette.cljs index dcd35b3..dcf2f50 100644 --- a/frontend/src/arthur/ui/palette.cljs +++ b/frontend/src/arthur/ui/palette.cljs @@ -11,9 +11,18 @@ Slot 0 is the background, which is why it is shown and not selectable: a polygon filled with index 0 is invisible against a stage cleared to index 0, so - offering it as a fill is offering a shape that vanishes on creation." + offering it as a fill is offering a shape that vanishes on creation. + + The footage switch is here too, because it is the same kind of thing as the + tone and the tool: something you set before you draw and leave alone, in one + place whatever is selected. In the inspector it was a section that appeared + only once the right row had been found — so the way to see the footage you are + tracing depended on what you had clicked, which is not a thing anyone can be + expected to learn." (:require [arthur.domain.palette :as pal] + [arthur.domain.trace :as trace] [arthur.events.ui :as ui] + [arthur.subs.render :as render] [arthur.subs.ui :as sub] [re-frame.core :as rf])) @@ -34,6 +43,31 @@ :disabled (not pick) :on-click #(rf/dispatch [::ui/set-tone slot-tone])}])) +(defn- tracing + "The footage under the faces the open symbol has, on or off and how strongly. + + ONE SWITCH FOR THE FACES THAT ARE HERE. A face's footage is the face's, not a + placement's, so there is nothing to inherit and nothing to set twice; with + several faces in a take the box says how many are showing and switches the rest + on, and one face alone is switched from its own timeline row." + [] + (let [clip @(rf/subscribe [::render/clip]) + open @(rf/subscribe [::render/open]) + {:keys [faces opacity]} @(rf/subscribe [::render/tracing]) + here (trace/traceable-faces clip open) + on (filterv (set faces) here)] + (when (seq here) + [:<> + [:label.dim {:title (str "show the footage these faces were traced from, over the " + "picture · a reference, never exported")} + [:input {:type "checkbox" :checked (= (count on) (count here)) + :on-change #(rf/dispatch [::ui/trace-faces here])}] + (str " footage" (when (< 1 (count here)) (str " " (count on) "/" (count here))))] + [:input.trace-opacity + {:type "range" :min 0 :max 1 :step 0.05 :title "how strongly the footage draws" + :value (or opacity trace/opacity-default) :disabled (empty? on) + :on-change #(rf/dispatch [::ui/trace-opacity (js/parseFloat (.. % -target -value))])}]]))) + (defn bar [] (let [tone @(rf/subscribe [::sub/tone]) tool @(rf/subscribe [::sub/tool]) @@ -42,6 +76,7 @@ [:div.swatches (doall (map #(swatch % tone) (range slots)))] [:span.dim (name tone)] [:span {:style {:flex 1}}] + [tracing] (if (= :polygon tool) [:<> [:span.dim (str (quot (count draft) 2) " points")] diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index b644da3..6d4889e 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -180,7 +180,9 @@ (defn- channel-control [sid id path ch frame] (let [keyed? (some? (:keys ch)) - v (channel/value-at ch (or frame 0)) + ;; No store: the call site below hands this only channels that are not + ;; `:dense`, which are the only ones with anything in tier 2 to read. + v (channel/value-at ch (or frame 0) nil) off? (and keyed? (nil? frame)) deg? (= path [:xform :rot]) ;; A boolean has nothing between true and false to tween through. @@ -213,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 a cel 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)) @@ -236,21 +238,32 @@ ;; --------------------------------------------------------------------------- ;; tracing a face ;; -;; Two owners in one section, and the heading says whose each is. Showing the -;; footage and how strongly is this INSTANCE's, a drawing aid that is not keyed, -;; and it covers every face at or below it — so a take shows its faces' footage. -;; The trace keys and the origin are the FACE's — its symbol's head — so they are -;; the same in every placement of it. See `domain/trace`. +;; THE FACE'S OWN FACTS ONLY, and both of them are keyed to the face rather than +;; to an instance of it: which of its frames its drawings were made over, and +;; what its origin does between those frames. See `domain/trace`. +;; +;; Whether the footage is SHOWING is deliberately not here. It is a viewing aid +;; and it belongs with the other things you look through rather than edit, on the +;; bar above the stage — where it is in the same place whatever is selected, +;; instead of appearing in the inspector only once the right row has been found. -(defn- face-trace [face] - (let [t (trace/of (get-in @(rf/subscribe [::render/clip]) [:symbols face :nodes :head])) - {:keys [frame time]} @(rf/subscribe [::sub/selected-local]) - put #(rf/dispatch [::project/set-trace face %])] +(defn- trace-keys + "The face's trace keys and origin. `frame` is the frame of the FACE that the + playhead is over, and `seek!` goes to one of its frames — both of which depend + on whether the face is open in its own tab or placed in what is." + [face frame seek!] + (let [t (trace/of (get-in @(rf/subscribe [::render/clip]) [:symbols face :nodes :head])) + put #(rf/dispatch [::project/set-trace face %]) + key? (boolean (some #{frame} (:frames t)))] [:<> [:div.row {:style {:margin "5px 0"}} - [:button {:disabled (nil? frame) - :on-click #(put (trace/toggle-frame t frame))} - (if (some #{frame} (:frames t)) "remove trace key" "trace key here")]] + ;; A DEAD BUTTON WITH NO REASON GIVEN is what reads as the feature not + ;; working. There is no frame of this face under the playhead, so say that + ;; rather than greying out the one control in the section. + (if (nil? frame) + [:span.dim "move the playhead over this face to key it"] + [:button {:on-click #(put (trace/toggle-frame t frame))} + (if key? (str "remove trace key at " frame) (str "trace key at " frame))])] (when (seq (:frames t)) [:div.row [:span.dim "keys"] @@ -258,9 +271,8 @@ (for [f (:frames t)] ^{:key f} [:button {:class (when (= frame f) "on") - :disabled (nil? time) - :on-click #(rf/dispatch [::pb/seek (js/Math.round - (+ (:at time) (/ f (:rate time))))])} + :disabled (nil? seek!) + :on-click #(seek! f)} (str f)]))]) [:div.row {:style {:margin-top "5px"}} [:span.dim "origin"] @@ -268,36 +280,38 @@ (for [[o label] (map vector trace/origins ["continuous" "at keys" "start"])] ^{:key o} [:button {:class (when (= o (:origin t)) "on") + :title (case o + :continuous "the head reads the frame it is on" + :keys "the head jumps to each trace key and holds it" + :start "the head holds frame 0 forever") :on-click #(put (assoc t :origin o))} label]))]])) -(defn- tracing-section [[sid id n] faces] - (let [clip @(rf/subscribe [::render/clip]) - open @(rf/subscribe [::render/open]) - [_ _ _ path] @(rf/subscribe [::sub/selection]) - path (or path [id]) - {:keys [on? opacity own?] :or {opacity 0.5} :as u} (trace/underlay-at clip open path) - show #(rf/dispatch [::project/set-underlay sid id (merge {:on? (boolean on?) :opacity opacity} %)])] - [section (str "tracing · " (name (:of n))) - [:div.row - [:label.dim [:input {:type "checkbox" :checked (boolean on?) - :on-change #(show {:on? (.. % -target -checked)})}] - " footage" - (when (and u (not own?)) " · as above")] - [:input {:type "range" :min 0 :max 1 :step 0.05 :value opacity - :disabled (not on?) - :on-focus #(rf/dispatch [::history/hold]) - :on-blur #(rf/dispatch [::history/settle]) - :on-change #(show {:opacity (js/parseFloat (.. % -target -value))})}]] - (when (trace/traceable? clip (:of n)) [face-trace (:of n)]) +(defn- tracing-section + "`face` is the face these facts belong to, `faces` the faces placed inside it + to offer as somewhere to go next, and `path` the row path `faces` are under." + [face faces path] + (let [clip @(rf/subscribe [::render/clip]) + own? (= face @(rf/subscribe [::render/open])) + ;; The face's OWN frame, which is the playhead itself when the face is + ;; the open symbol, and the selected placement's local frame when it is + ;; placed in it. `time` maps the open symbol's frames to that + ;; placement's, so seeking to one of the face's frames is a conversion. + {:keys [frame time]} (when-not own? @(rf/subscribe [::sub/selected-local])) + frame (if own? @(rf/subscribe [::playback/frame]) frame) + seek! (cond own? #(rf/dispatch [::pb/seek %]) + time #(rf/dispatch [::pb/seek (js/Math.round + (+ (:at time) (/ % (:rate time))))]))] + [section (str "tracing · " (name face)) + (when (trace/traceable? clip face) [trace-keys face frame seek!]) (when (seq faces) [:div.row {:style {:margin-top "5px"}} [:span.dim "faces"] (doall - (for [{p :path in :in face :face} faces] + (for [{p :path in :in f :face} faces] ^{:key (str p)} [:button {:on-click #(rf/dispatch [::ui/select [:node in (peek p) (into path p)]])} - (name face)]))])])) + (name f)]))])])) ;; --------------------------------------------------------------------------- ;; a symbol @@ -416,17 +430,32 @@ (defn view [] (let [clip @(rf/subscribe [::render/clip]) + open @(rf/subscribe [::render/open]) selection @(rf/subscribe [::sub/selection]) node @(rf/subscribe [::sub/selected-node]) - tracked? (seq (owners clip))] + tracked? (seq (owners clip)) + ;; The face the tracing section is about: the SELECTED PLACEMENT's symbol, + ;; or, when the selection is not an instance or there is none, the OPEN + ;; 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. + ;; The symbol this cel 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 + ;; inside it can be selected by their own rows. A selection made on the + ;; stage has no path and names a node directly in the open symbol; the + ;; open symbol itself is at no path at all. + path (if placed (or (nth selection 3 nil) [(second node)]) [])] [:section.pane.params [:div.pane-head "inspector"] [:div {:style {:min-height 0}} [clip-section] (when node [node-section node]) - (when-let [of (:of (peek node))] - (let [faces (trace/faces clip of)] - (when (or (trace/traceable? clip of) (seq faces)) - [tracing-section node faces]))) + (when (and face (or (trace/traceable? clip face) (seq faces))) + [tracing-section face faces path]) (when (= :symbol (first selection)) [symbol-section (second selection)]) (when tracked? [tracking-section])]])) diff --git a/frontend/src/arthur/ui/player.cljs b/frontend/src/arthur/ui/player.cljs index 05dfec1..5452f67 100644 --- a/frontend/src/arthur/ui/player.cljs +++ b/frontend/src/arthur/ui/player.cljs @@ -101,8 +101,9 @@ (some-> @tracker ratom/dispose!) (reset! tracker (ratom/run! - (let [was (:resolver @snapshot) - now @(rf/subscribe [::render/shown])] + (let [{was :resolver was-u :underlay} @snapshot + now @(rf/subscribe [::render/shown]) + u @(rf/subscribe [::render/underlay])] (reset! snapshot {:resolver now :palette @(rf/subscribe [::render/palette]) @@ -111,12 +112,23 @@ :frames @(rf/subscribe [::render/frames]) :width @(rf/subscribe [::sub/width]) :height @(rf/subscribe [::sub/height]) - :underlay @(rf/subscribe [::render/underlay]) + :underlay u :frame @(rf/subscribe [::sub/frame]) :playing? @(rf/subscribe [::sub/playing?])}) ;; A new resolver means a new scene or a new palette, and neither ;; moves the playhead — so nothing else would ask for a redraw. - (when-not (identical? was now) (repaint!)))))) + ;; + ;; Switching a face's footage on wants the same redraw for the same + ;; reason, and it needs asking for SEPARATELY: it is a viewing aid + ;; in the editor's own state, so it changes what the canvases should + ;; show without touching the frame number OR the resolver. The + ;; document and the store are left out of the comparison because + ;; they are what a new resolver already means. + ;; Comparing the whole map is as cheap as picking fields out of it: + ;; the document and the store it carries are the same OBJECTS unless + ;; the resolver changed too, and that is tested first. + (when-not (and (identical? was now) (= was-u u)) + (repaint!)))))) (defn set-canvas! [el] (swap! state assoc :canvas el) @@ -146,7 +158,7 @@ rasterised before the next frame is asked for." [f] (let [{:keys [canvas]} @state - {:keys [resolver palette ramp width height underlay]} @snapshot] + {:keys [resolver palette ramp width height underlay playing?]} @snapshot] (when (and canvas resolver width height) ;; User Timing, so a profile in the DevTools performance panel has named ;; spans in the Timings track instead of a wall of anonymous frames. Three @@ -163,7 +175,8 @@ (raster/draw-ops! ops))) (js/performance.mark "arthur/blit:start") (canvas/blit! canvas ras ramp) - (underlay/paint! (assoc underlay :width width) resolver repaint!)) + (underlay/paint! (assoc underlay :width width :playing? playing?) + resolver repaint!)) (js/performance.measure "arthur/resolve+draw" "arthur/paint:start" "arthur/blit:start") (js/performance.measure "arthur/paint" "arthur/paint:start") ;; User Timing entries otherwise accumulate forever in the browser's diff --git a/frontend/src/arthur/ui/shell.cljs b/frontend/src/arthur/ui/shell.cljs index 70a0608..df0b605 100644 --- a/frontend/src/arthur/ui/shell.cljs +++ b/frontend/src/arthur/ui/shell.cljs @@ -1,5 +1,5 @@ (ns arthur.ui.shell - "The window: one grid, five panes, and the audio element. + "The window: one grid, five panes, a location bar, and the audio element. Nothing else. Each pane owns its own subscriptions, so this component re-renders only when the grid itself would change — which is never. The picture is put on @@ -10,6 +10,7 @@ [arthur.events.playback :as pb] [arthur.subs.playback :as playback] [arthur.subs.render :as render] + [arthur.ui.location :as location] [arthur.ui.palette :as palette] [arthur.ui.params :as params] [arthur.ui.pool :as pool] @@ -52,6 +53,10 @@ [palette/bar] [stage/view]] [params/view] + ;; Above the timeline rather than inside it: the bar says where an edit would + ;; land, which is a fact about the SELECTION and not about either temporal + ;; view, and both views are drawn below it unchanged. + [location/view] [timeline/view] [convert/view] [audio]]) diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index dde9f5a..d82a249 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -118,23 +118,40 @@ ;; pointer goes out as `::ui/gesture`, and on the way up as one `::ui/transform`. (defonce ^:private gesture (atom nil)) +(defn- loaded + "The document and its tier-2 store, read WHEN THE POINTER GOES DOWN. + + NOT AT RENDER TIME, which is the bug it does not look like. `footage/store` is + a mutable handle behind an id, and — see `events/edit` — the id does not change + when the document does; `:paint/revision` is what says it did, and nothing this + component subscribes to reads it. So a clip dereferenced while rendering is the + clip as it was BEFORE the last edit, and a drag begun from one starts by + putting the node back where it was two edits ago: the node sits still under the + press and then jumps on the first pointermove, which is the whole of the drag + that follows being right and its first frame being wrong. Every id here is + reactive except the document, and the document is the one read late." + [{:keys [clip-id]}] + (store/entry clip-id)) + (defn- select! "Select the node at row path `path` of the open symbol — the selection a timeline row makes, so the row, the inspector and the stage all show it — or nothing." - [{:keys [document st open f]} path] - (rf/dispatch [::ui/select (when-let [{:keys [sid id]} (when (seq path) - (nest/placement document st open path f))] - [:node sid id path])])) + [{:keys [open f] :as ctx} path] + (let [{document :clip st :store} (loaded ctx)] + (rf/dispatch [::ui/select (when-let [{:keys [sid id]} (when (seq path) + (nest/placement document st open path f))] + [:node sid id path])]))) (defn- begin! "Start dragging `kind` of the node at `path` from stage point `p`." - [{:keys [document st open f]} kind path p] - (when-let [{:keys [sid id frame] :as pl} (nest/placement document st open path f)] - (let [n (get-in document [:symbols sid :nodes id]) - v0 (gesture/values n frame)] - (reset! gesture {:kind kind :pl pl :v0 v0 :p0 p :n n - :a (gesture/angle pl v0 p) :turned 0})))) + [{:keys [open f] :as ctx} kind path p] + (let [{document :clip st :store} (loaded ctx)] + (when-let [{:keys [sid id frame] :as pl} (nest/placement document st open path f)] + (let [n (get-in document [:symbols sid :nodes id]) + v0 (gesture/values n frame st)] + (reset! gesture {:kind kind :pl pl :v0 v0 :p0 p :n n + :a (gesture/angle pl v0 p) :turned 0}))))) (defn- drag! [p ^js event] (let [{:keys [kind pl v0 p0 n a turned values]} @gesture @@ -175,7 +192,7 @@ [ctx] (let [{:keys [world bounds node frame]} @(rf/subscribe [::sub/selected-placement]) [_ _ _ path] @(rf/subscribe [::sub/selection]) - [ax ay] (when world (:anchor (gesture/values node frame))) + [ax ay] (when world (:anchor (gesture/values node frame (:store (loaded ctx))))) [px py] (when world (through world [ax ay])) grab (fn [kind] (fn [^js event] @@ -209,12 +226,13 @@ drawing? (= :polygon tool) [_ _ _ selected] @(rf/subscribe [::sub/selection]) clip-id @(rf/subscribe [::render/clip-id]) - ctx {:document (:clip (store/entry clip-id)) :st (:store (store/entry clip-id)) + ctx {:clip-id clip-id :open @(rf/subscribe [::render/open]) :f @(rf/subscribe [::playback/frame]) :w w :h h} points? @(rf/subscribe [::sub/points]) [sid id geom active editable? frame matrix] (when points? (editing)) - pts (when geom (through matrix (channel/value-at geom frame)))] + pts (when geom (through matrix (channel/value-at geom frame + (:store (store/entry clip-id)))))] [:svg {:class (str "paint-overlay" (when drawing? " drawing")) :width (* zoom w) :height (* zoom h) :view-box (str "0 0 " w " " h) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 2261293..9c57e26 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -22,8 +22,15 @@ happen and not where they happen again." (:require [clojure.string :as str] [arthur.domain.node :as node] + [arthur.domain.nest :as nest] + [arthur.domain.lane :as lane] + [arthur.domain.symbol :as symbol] + [arthur.domain.trace :as trace] [arthur.events.playback :as pb] [arthur.events.ui :as ui] + [arthur.footage.store :as store] + [arthur.ui.icon :as icon] + [arthur.ui.menu :as menu] [arthur.subs.playback :as playback] [arthur.subs.render :as render] [arthur.subs.ui :as sub] @@ -44,17 +51,23 @@ frame 48 of the symbol it is in, and drawing it at 0 puts every placement's keys in the same place however staggered they are. - Exposure is not inverted, because it is a floor and has no inverse: a key on a + Cel is not inverted, because it is a floor and has no inverse: a key on a frame the exposure grid never samples is still authored on that frame, and that is where the row should show it." [n] (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. @@ -104,19 +117,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? @@ -126,60 +143,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 CEL IS NOT A ROW. A lane's drawings are cel + ;; blocks on the lane's own row, so a lane of twelve + ;; cels is one row and not twelve — which is the + ;; vertical growth that made a keyed source look + ;; necessary. The cel is still the thing selected + ;; and addressed; only its presentation is shared. + (if (node/lane? (get-in sym [:nodes (:parent n)])) + [] + (let [row (cond-> row + (node/lane? 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))]}) + (symbol/lane-cels (: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, cel 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 @@ -203,38 +225,166 @@ (defn- transport [] (let [playing? @(rf/subscribe [::playback/playing?]) rate @(rf/subscribe [::playback/rate]) + loop? @(rf/subscribe [::playback/loop?]) + muted? @(rf/subscribe [::playback/muted?]) + view @(rf/subscribe [::sub/time-view]) 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]) + clip-id @(rf/subscribe [::render/clip-id]) + selection @(rf/subscribe [::sub/selection]) + [_ sid id] selection + open @(rf/subscribe [::render/open]) + st (:store (store/entry clip-id)) + n (get-in clip [:symbols sid :nodes id]) + lane (if (node/lane? n) n (get-in clip [:symbols sid :nodes (:parent n)])) + lane? (node/lane? lane) + cel? (and lane? (= :instance (:kind n)) (some? (node/source n))) + held? (and cel? (zero? (:speed (node/playback-of n)))) + ;; Shared use is shown rather than discovered: the row that decouples + ;; a cel is only offered where there is something to decouple from. + shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip) + [_ other] (:nodes sym) + :when (= (node/source n) (node/source other))] + other)))) + ;; Both act at the playhead, so both are offered only where the playhead + ;; is somewhere they mean something. + owner-frame (ui/selection-frame clip st open selection frame) + at (when (and lane? (number? owner-frame)) + (lane/lane-frame clip sid (:id lane) owner-frame)) + insertable? (and lane? (integer? at)) + splittable? (and cel? (integer? at) + (let [[lo hi] (node/placed-span n)] (< lo at hi))) + act (fn [event] #(rf/dispatch event))] [:div.pane-head - [:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")] - [:button {:on-click #(rf/dispatch [::pb/seek 0])} "|<"] - [:button {:on-click #(rf/dispatch [::pb/step -1])} "-1"] - [:button {:on-click #(rf/dispatch [::pb/step 1])} "+1"] - [:button {:class (when @(rf/subscribe [::playback/loop?]) "on") - :on-click #(rf/dispatch [::pb/toggle-loop])} "loop"] - [:button {:class (when @(rf/subscribe [::playback/muted?]) "on") - :on-click #(rf/dispatch [::pb/toggle-mute])} "mute"] - (doall - (for [r [0.25 0.5 1.0 2.0 4.0]] - ^{:key r} - ;; playbackRate on the audio element and nothing else: the sound slows, - ;; currentTime advances proportionally, and the derived frame follows. Slow - ;; motion cannot desync by construction. - [:button {:class (when (== r rate) "on") - :on-click #(rf/dispatch [::pb/set-rate r])} - (case r 1.0 "1x" 0.5 "½" 0.25 "¼" 2.0 "2x" 4.0 "4x" (str r))])) - [: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"] + ;; ----------------------------------------------------------------- time + ;; Where the playhead is and how it moves. Nothing here depends on a + ;; selection, which is why it is the one group that is never greyed. + [:div.group + [:button.ico {:aria-label "go to start" :title "go to the first frame" + :on-click (act [::pb/seek 0])} [icon/view :start]] + [:button {:aria-label "−1" :title "step back one frame" + :on-click (act [::pb/step -1])} "−1"] + [:button.ico.ico-play {:aria-label (if playing? "pause" "play") + :title (if playing? "pause" "play") + :on-click (act [::pb/toggle])} + [icon/view (if playing? :pause :play)]] + [:button {:aria-label "+1" :title "step forward one frame" + :on-click (act [::pb/step 1])} "+1"]] + [:div.group + [:button.ico {:class (when loop? "on") :aria-label "loop" + :title "loop playback" :on-click (act [::pb/toggle-loop])} + [icon/view :loop]] + [:button.ico {:class (when muted? "on") :aria-label (if muted? "unmute" "mute") + :title (if muted? "unmute" "mute") :on-click (act [::pb/toggle-mute])} + [icon/view (if muted? :muted :sound)]]] + ;; Beside the transport rather than off at the right margin: it is what you + ;; read WHILE scrubbing, and tabular figures keep it from twitching as it + ;; counts. + [:span.readout.at-frame (str frame " / " frames)] + ;; Five buttons for one mutually exclusive choice that is 1× almost always. + ;; A select says "pick one of these" in the width of the answer. + ;; playbackRate on the audio element and nothing else: the sound slows, + ;; currentTime advances proportionally, and the derived frame follows. Slow + ;; motion cannot desync by construction. + [:span.pick + [:select {:value rate :aria-label "playback speed" :title "playback speed" + :on-change #(rf/dispatch [::pb/set-rate + (js/parseFloat (.. % -target -value))])} + (doall (for [[r label] [[0.25 "¼×"] [0.5 "½×"] [1.0 "1×"] [2.0 "2×"] [4.0 "4×"]]] + ^{:key r} [:option {:value r} label]))]] + [:span.sep] + ;; ----------------------------------------------------------------- view + ;; TWO VIEWS OF ONE THING, and you are always in exactly one. Two separate + ;; toggles said neither half of that; joined, with the one in force filled, + ;; the control is the statement. + [:div.seg {:role "radiogroup" :aria-label "time view"} + (doall + (for [[k label title] [[:timeline "timeline" "lanes across, frames left to right"] + [:cel-sheet "cel sheet" "frames down, one column per lane"]]] + ^{:key k} + [:button {:class (when (= k view) "on") :role "radio" + :aria-checked (= k view) :title title + :on-click (act [::ui/set-time-view k])} + label]))] + [:span.sep] + ;; ------------------------------------------------------------- commands + ;; Fourteen of these, and all but two mean nothing without a selected lane + ;; or cel — so as buttons they were a permanent grey hedge across the strip + ;; with their explanations hidden in `title`. Grouped by what they change, + ;; with the explanation on the row, they cost three slots and read as a + ;; vocabulary. There is also room here for the correction commands, which + ;; `docs/lane-handoff.md` says are next. + ;; `new` is NOT here. Creating a symbol or a lane acts at the location the + ;; breadcrumb names, so it lives on the location bar beside it rather than + ;; among the commands that act on a cel. `ui/location`. + [menu/view + {:label "drawing" :title "what the lane exposes" + :note "select a lane, or a cel in one" + :items [{:label "new drawing" :disabled? (not lane?) + :sub "append a new independent drawing to the selected lane" + :on-click (act [::ui/append-drawing])} + {:label "insert" :disabled? (not insertable?) + :sub "a new drawing at the playhead; later drawings ripple later" + :on-click (act [::ui/insert-drawing])} + {:label "overwrite" :disabled? (not insertable?) + :sub "replace the drawing at the playhead; later drawings stay put" + :on-click (act [::ui/overwrite-drawing])} + {:label "reuse" :disabled? (not cel?) + :sub "expose this same drawing again — one drawing, two cels" + :on-click (act [::ui/reuse-drawing])} + {:label "duplicate" :disabled? (not cel?) + :sub "append a copy of this drawing, to draw the next one over it" + :on-click (act [::ui/duplicate-drawing])} + {:label "make unique" :disabled? (not shared?) + :sub "give this cel its own copy; other cels keep sharing" + :on-click (act [::ui/make-unique])}]}] + [menu/view + {:label "cel" :title "where this cel sits and how long it lasts" + :note "select a cel in the timeline or the cel sheet" + :items [{:label "split" :disabled? (not splittable?) + :sub "cut this cel in two at the playhead; the picture does not change" + :on-click (act [::ui/split-cel])} + {:label "trim in" :disabled? (not splittable?) + :sub "start this cel at the playhead; nothing else moves" + :on-click (act [::ui/trim-cel :in])} + {:label "trim out" :disabled? (not splittable?) + :sub "end this cel at the playhead; nothing else moves" + :on-click (act [::ui/trim-cel :out])} + {:label "move here" :disabled? (not (and cel? (integer? at))) + :sub "put this cel at the playhead; refused if something is there" + :on-click (act [::ui/move-cel])} + {:label "blank" :disabled? (not cel?) + :sub "clear this cel's frames, leaving a gap; later drawings stay put" + :on-click (act [::ui/blank-cel])}]}] + ;; THE ONE PAIR THAT STAYS A BUTTON. Deciding how many frames a drawing is + ;; held for is shooting on ones or twos — the most repeated edit in the + ;; list, done by eye, a frame at a time. Two clicks into a menu per frame + ;; would be the one place this consolidation made the tool worse. + [:span.stepper + [:span.stepper-label "hold"] + [:span.group + [:button {:disabled (not held?) :aria-label "hold −" + :title "shorten this cel; ripple later drawings, keeping lane keys fixed" + :on-click (act [::ui/extend-hold -1])} "−"] + [:button {:disabled (not held?) :aria-label "hold +" + :title "extend this cel; ripple later drawings, keeping lane keys fixed" + :on-click (act [::ui/extend-hold 1])} "+"]]] [:span.spacer] - [:span.dim (str frame " / " frames)] + ;; After the spacer, both of them: an offer that appears and a reading that + ;; comes and goes must not shove the fixed controls sideways when they do. + (when @(rf/subscribe [::sub/lane-retry]) + [:button.retry {:title "the command was refused because the shot is too short" + :on-click (act [::ui/lane-retry])} "extend shot and apply"]) ;; Measured in the loop, not derived from the clock — the whole question ;; while profiling is whether the painting keeps up with the clock, and a - ;; number computed FROM the clock would answer itself. - [:span {:class (if (and drop (> drop 1.35)) "warn" "dim")} - (str (.toFixed (or fps 0) 1) " paint/s" - (when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))]])) + ;; number computed FROM the clock would answer itself. Shown only while it + ;; has something to say: paused, it read "0.0 paint/s" for ever. + (when (pos? (or fps 0)) + [:span.readout {:class (if (and drop (> drop 1.35)) "warn" "dim")} + (str (.toFixed fps 1) " paint/s" + (when (and drop (pos? drop)) (str " · " (.toFixed drop 2) " f/paint")))])])) (defn- takes? "Whether the row at `target`, a `target-kind` node, can take the row being @@ -266,7 +416,7 @@ (memoize (fn [_selection] (fn [el] (some-> el (.scrollIntoView #js {:block "nearest"})))))) (defn- label-cell [{:keys [path depth label kind node-kind select expandable? expanded? of via]} - selection over solo] + selection over solo tracing] (let [node? (= :node kind) [over-path where] @over] [:div (cond-> {:class (str "tl-label" (when (and select (= select selection)) " on") @@ -323,6 +473,17 @@ (when expandable? (if expanded? "▾" "▸"))] [:span.name label] (when node? [:span.kind (if via (str "· in " via) (str "·" (name node-kind)))]) + ;; A face's row is where its own footage is switched on, next to solo + ;; because the two are the same kind of thing: what this row shows, here, + ;; now, and nothing the picture keeps. The bar above the stage does all of + ;; them at once; this is how one face out of a take is singled out. + (when (contains? (:faces tracing) of) + [:button {:class (str "tl-trace" (when (contains? (:on tracing) of) " on")) + :title "show the footage this face was traced from" + :on-click (fn [^js e] + (.stopPropagation e) + (rf/dispatch [::ui/trace-face of]))} + "T"]) (when (= :instance node-kind) [:button {:class (str "tl-solo" (when (contains? solo path) " on")) :title "show only this on the stage (⇧ for more than one)" @@ -341,7 +502,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) @@ -362,7 +523,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") @@ -381,6 +542,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 cel; 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? @@ -388,7 +565,7 @@ (for [f keys :when (and (<= 0 f) (< f frames))] ^{:key f} [:div.tl-key {:style {:left (at% f frames)}}])))])) -(defn view [] +(defn- timeline-view [] (r/with-let [scrubbing (r/atom false) ;; The row a carried row is over and which part of it, for the ;; highlight. @@ -402,6 +579,11 @@ drop @(rf/subscribe [::sub/drop]) solo (set @(rf/subscribe [::render/solo])) open @(rf/subscribe [::render/open]) + ;; Which rows offer a footage switch, and which of them are switched + ;; on. A set rather than a lookup per row: the answer is the same for + ;; every placement of a face, because the switch is the face's. + tracing {:faces (set (trace/traceable-faces clip open)) + :on (set (:faces @(rf/subscribe [::render/tracing])))} ;; Where a drag out of the pool would land, as a row of its own at the ;; top of its section: its own length, starting on the frame it would ;; start on. The stage's drop shows it too, at the playhead. @@ -441,7 +623,7 @@ (doall (for [row visible] (with-meta (if (= :section (:kind row)) [:div.tl-label.tl-section (:label row)] - [label-cell row selection over solo]) + [label-cell row selection over solo tracing]) {:key (str (:path row))})))] [:div.tl-tracks {:on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e))) @@ -494,3 +676,56 @@ {:key (str (:path row))}))) [:div.tl-empty "nothing in this symbol"]) [:div.tl-playhead {:style {:left (at% frame frames)}}]]]]))) + +(defn cel-sheet + "The cel-sheet projection of one symbol: frames down, one column per lane. + It reuses `rows`, so its spans and selection addresses are exactly the ones + the timeline presents rather than a second interpretation of the document." + [clip sid frames] + (mapv (fn [{:keys [path label cels]}] + {:id (peek path) + :label label + :cells (mapv (fn [f] + (let [cel (some (fn [{[in out] :span :as cel}] + (when (and (<= in f) (< f out)) cel)) + cels)] + {:frame f :lane (peek path) :cel cel})) + (range frames))}) + (filter :cels (rows clip sid #{})))) + +(defn- cel-sheet-view [] + (let [clip @(rf/subscribe [::render/clip]) + sid @(rf/subscribe [::render/open]) + frames (max 1 (or @(rf/subscribe [::render/frames]) 1)) + frame @(rf/subscribe [::playback/frame]) + selection @(rf/subscribe [::sub/selection]) + columns (cel-sheet clip sid frames) + style {:grid-template-columns + (str "52px repeat(" (max 1 (count columns)) ", minmax(110px, 1fr))")}] + [:div.cel-sheet {:style style} + [:div.cs-head.cs-frame "frame"] + (doall (for [{:keys [id label]} columns] + ^{:key (str "head-" id)} [:div.cs-head label])) + (doall + (for [f (range frames) + item (cons {:frame-label? true} + (map #(get-in % [:cells f]) columns))] + (if (:frame-label? item) + ^{:key (str "frame-" f)} + [:button.cs-frame {:class (when (= f frame) "on") + :on-click #(rf/dispatch [::pb/seek f])} f] + (let [{:keys [id label select]} (:cel item)] + ^{:key (str f "-" (:lane item) "-" (or id "gap"))} + [:button.cs-cell + {:class (str (when (= f frame) " current") + (when (and select (= select selection)) " selected")) + :title (if id (str label " · frame " f) (str "gap · frame " f)) + :on-click (fn [] + (rf/dispatch [::pb/seek f]) + (when select (rf/dispatch [::ui/select select])))} + (or label "—")]))))])) + +(defn view [] + (if (= :cel-sheet @(rf/subscribe [::sub/time-view])) + [:section.pane.time [transport] [cel-sheet-view]] + [timeline-view])) diff --git a/frontend/src/arthur/ui/underlay.cljs b/frontend/src/arthur/ui/underlay.cljs index 92c901b..73c6525 100644 --- a/frontend/src/arthur/ui/underlay.cljs +++ b/frontend/src/arthur/ui/underlay.cljs @@ -2,18 +2,27 @@ "The footage a face is traced over, on its own canvas above the stage. A REFERENCE, NOT OUTPUT. The tracing still never enters the indexed raster, so - it cannot reach an export, and it is drawn OVER the picture at the instance's + it cannot reach an export, and it is drawn OVER the picture at the face's opacity rather than under it, because the raster clears to an opaque ground. The canvas is the stage's size on screen, not the raster's, so a 1280px still is not squeezed through a 320px stage on its way to being seen. Painted by `ui/player` straight after each frame, from the same snapshot, so - it moves with the face it registers to — see `domain/trace/photo-matrix`." + it moves with the face it registers to — see `domain/trace/photo-matrix`. + + THREE THINGS KEEP IT STEADY, because a still is decoded asynchronously and the + frame it belongs to is already on screen by the time it arrives: the cache drops + the stills used longest ago rather than emptying itself, a face keeps showing + the still it last showed until its next one has decoded, and the stills a little + way ahead of the playhead are asked for before they are needed. Each is a + separate cause of the same symptom — the tracing blinking out for a frame or two + — and none of them covers the others: the hold is what survives a seek, the + reading ahead is what keeps playback from being a frame behind for good." (:require [arthur.domain.symbol :as symbol] [arthur.domain.trace :as trace] [arthur.flow.ingest :as ingest])) -(defonce ^:private state (atom {:canvas nil :urls {} :images {}})) +(defonce ^:private state (atom {:canvas nil :urls {} :images {} :order [] :last {}})) (defn set-canvas! [el] (swap! state assoc :canvas el)) @@ -37,45 +46,134 @@ scrubbing a face with no trace keys asks for every frame of the take." 48) +(def ^:private ^:const ahead + "Frames past the playhead whose stills are asked for while the current one is + drawn. A still cannot be decoded in the animation frame that wants it, so + playing a continuously traced face would otherwise always be a frame or two + behind its own footage." + 8) + +(defn- decoded + "`img` if the still has arrived and can be drawn." + [^js img] + (when (and (.-complete img) (pos? (.-naturalHeight img))) img)) + +(defn- touch! + "Hold `img` under `url` as the most recently used still, dropping the ones used + longest ago once there are more than `kept`. + + LEAST RECENTLY USED, touched when it arrives and again on every frame it is + DRAWN, which is what makes it safe without a list of exceptions: the still on + screen is the newest thing in the cache and cannot be the one dropped — a still + held at a trace key for a hundred frames included. Read-and-not-drawn does not + touch, because a still read only to be warmed was just inserted anyway, and + reordering the whole list ten times per face per frame in the draw loop is the + kind of allocation `ui/player` exists to avoid. What this replaces emptied the + cache on the frame it filled, so every still on screen had to be fetched and + decoded again: a blink every `kept` frames of a scrub, and with two faces and a + full cache a permanent one." + [url img] + (swap! state + (fn [{:keys [images order] :as s}] + (let [images (assoc images url img) + order (conj (into [] (remove #{url}) order) url) + over (- (count order) kept)] + (if (pos? over) + (assoc s :images (apply dissoc images (subvec order 0 over)) + :order (subvec order over)) + (assoc s :images images :order order)))))) + (defn- image - "The still at `url`, or nil until it has loaded." + "The still at `url`, or nil until it has loaded. Asks for it the first time it + is wanted; `on-ready` paints again once it is there. + + A still that fails is remembered as having failed, so a broken URL is one + console line and one request rather than one of each per animation frame." [url on-ready] - (let [img (or (get-in @state [:images url]) - (let [img (js/Image.)] - (set! (.-onload img) on-ready) - (set! (.-src img) url) - (swap! state update :images - #(assoc (if (< (count %) kept) % {}) url img)) - img))] - (when (and (.-complete img) (pos? (.-naturalHeight img))) img))) + (when url + (let [held (get-in @state [:images url])] + (cond + (= :failed held) nil + held (decoded held) + :else (let [img (js/Image.)] + (set! (.-onload img) on-ready) + (set! (.-onerror img) + (fn [_] + (swap! state assoc-in [:images url] :failed) + (js/console.error "arthur: a tracing still did not load" url))) + (set! (.-src img) url) + (touch! url img) + nil))))) + +(defn- still + "The URL of the still showing the face's own frame `p`. The manifest is the + whole footage's and `p` is an index into the analysed range, so the range's + start is where the face's frame 0 was filmed." + [us start p] + (get us (+ start p))) + +(defn- warm! + "Ask for the stills the next `ahead` frames will want. Held trace frames + collapse to the one still, so a face traced at keys asks for almost nothing. + + WHILE PLAYING ONLY, because that is the only time the next frame is the one + after this one. A scrub asks for a different `ahead` frames at every step, so + reading ahead through one would be `ahead` requests per step for stills the + pointer has already gone past — and a cache thrashed by its own guesses." + [us start t lf on-ready] + (doseq [p (distinct (map #(trace/photo-frame t %) (range (inc lf) (+ lf 1 ahead))))] + (image (still us start p) on-ready))) (defn paint! - "Draw every switched-on underlay on the frame `resolver` last resolved. It + "Draw every switched-on face's footage on the frame `resolver` last resolved. It says where each face's head went and which of its frames it was on, so this reads the frame rather than resolving it again. `on-ready` is called when a still or a manifest that was missing arrives, to paint again." - [{:keys [document store footage-id traces width]} resolver on-ready] + [{:keys [document store footage-id traces opacity width playing?]} resolver on-ready] (when-let [^js canvas (:canvas @state)] (let [ctx (.getContext canvas "2d") zoom (/ (.-width canvas) width)] (.setTransform ctx 1 0 0 1 0 0) (.clearRect ctx 0 0 (.-width canvas) (.-height canvas)) + ;; What each face is showing, forgotten for the faces switched off. Before + ;; the early exits, so switching them all off forgets all of them. + (swap! state update :last select-keys (map :path traces)) (when-let [us (and (seq traces) footage-id (urls footage-id on-ready))] - (let [start (first (get-in document [:analysis :range] [0]))] - (doseq [{:keys [path face opacity]} traces - :let [at (conj path :head) - world (symbol/world-of resolver at) - frame (symbol/frame-of resolver at)] - :when (and world (number? frame)) - :let [head (get-in document [:symbols face :nodes :head]) - p (trace/photo-frame (trace/of head) (js/Math.floor frame)) - img (some-> (get us (+ start p)) (image on-ready)) - m (when img - (trace/photo-matrix world head store p (.-naturalHeight img)))] - :when m] - (set! (.-globalAlpha ctx) opacity) - (.setTransform ctx - (* zoom (aget m 0)) (* zoom (aget m 1)) - (* zoom (aget m 2)) (* zoom (aget m 3)) - (* zoom (aget m 4)) (* zoom (aget m 5))) - (.drawImage ctx img 0 0))))))) + (let [start (first (get-in document [:analysis :range] [0])) + ;; Read once: each face writes only its own entry below, and what it + ;; was showing is what it falls back to. A face that has been off + ;; stage for a few frames still has the still it went away with. + was (:last @state)] + ;; ONE OPACITY for all of them, set once: how strongly the reference + ;; draws is a property of looking at the stage, not of a face. + (set! (.-globalAlpha ctx) opacity) + (doseq [{:keys [path face]} traces] + (let [at (conj path :head) + world (symbol/world-of resolver at) + frame (symbol/frame-of resolver at)] + (when (and world (number? frame)) + (let [head (get-in document [:symbols face :nodes :head]) + t (trace/of head) + lf (js/Math.floor frame) + want (trace/photo-frame t lf) + ;; THE STILL IT HAS, not nothing. A frame whose still is + ;; still decoding keeps the one before it — a reference a + ;; frame stale, registered where that frame's face was, + ;; rather than a face with its footage blinking off. + [img url p] (or (let [u (still us start want)] + (when-let [i (image u on-ready)] [i u want])) + (let [{:keys [url p]} (get was path)] + (when-let [i (image url on-ready)] [i url p]))) + m (when img + (trace/photo-matrix world head store p (.-naturalHeight img)))] + (when m + ;; Drawn, so it is the newest still in the cache and what + ;; this face falls back to while its next one decodes. + (touch! url img) + (swap! state assoc-in [:last path] {:url url :p p}) + (.setTransform ctx + (* zoom (aget m 0)) (* zoom (aget m 1)) + (* zoom (aget m 2)) (* zoom (aget m 3)) + (* zoom (aget m 4)) (* zoom (aget m 5))) + (.drawImage ctx img 0 0)) + (when playing? (warm! us start t lf on-ready))))))))))) diff --git a/frontend/test/arthur/bench_test.cljs b/frontend/test/arthur/bench_test.cljs index 23ac02f..84ca2a3 100644 --- a/frontend/test/arthur/bench_test.cljs +++ b/frontend/test/arthur/bench_test.cljs @@ -24,7 +24,7 @@ (/ dt n)))) (deftest bench - (let [res (symbol/resolver (clip/symbol @swarm/clip :main) @swarm/store pal/index-of) + (let [res (symbol/resolver (clip/symbol @swarm/clip :main) @swarm/store pal/index-of nil) ras (raster/make 320 200) dest (js/Uint8ClampedArray. (* 320 200 4)) n 120] 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/channel_test.cljs b/frontend/test/arthur/domain/channel_test.cljs index 6ac5565..854ec64 100644 --- a/frontend/test/arthur/domain/channel_test.cljs +++ b/frontend/test/arthur/domain/channel_test.cljs @@ -11,55 +11,55 @@ (deftest framed-is-the-same-value-at-every-frame (let [c (ch/framed :skin-dark)] (is (= :framed (ch/describe c))) - (is (every? #(= :skin-dark (ch/value-at c %)) (range -5 20))))) + (is (every? #(= :skin-dark (ch/value-at c % nil)) (range -5 20))))) (deftest keyed-holds-until-the-next-key ;; Hold is the DEFAULT, not a special case: docs/design.md requires it of every ;; cut part, and a tweened mouth reads as puppet software. - (let [c (ch/keyed {0 :a, 4 :b, 12 :c})] + (let [c (ch/keyed {0 :a, 4 :b, 12 :c} :hold)] (is (= :keyed (ch/describe c))) (is (= [:a :a :a :a :b :b :b :b :b :b :b :b :c :c] - (mapv #(ch/value-at c %) (range 0 14)))))) + (mapv #(ch/value-at c % nil) (range 0 14)))))) (deftest linear-vector-keys-interpolate-each-component (let [c (ch/keyed {0 [0.4 0.6], 10 [0.6 0.4]} :linear) frames [0 5 10 5 2] - cursor (ch/cursor c)] + cursor (ch/cursor c nil)] (is (empty? (ch/problems c))) - (is (= [0.5 0.5] (ch/value-at c 5))) - (is (= (mapv #(ch/value-at c %) frames) + (is (= [0.5 0.5] (ch/value-at c 5 nil))) + (is (= (mapv #(ch/value-at c % nil) frames) (mapv #(ch/sample! cursor %) frames))))) (deftest one-channel-can-cut-then-tween - (let [c (assoc (ch/keyed {0 [0 0], 4 [4 0], 8 [8 0]}) + (let [c (assoc (ch/keyed {0 [0 0], 4 [4 0], 8 [8 0]} :hold) :segments {4 :linear}) - cursor (ch/cursor c)] - (is (= [0 0] (ch/value-at c 2))) - (is (= [4 0] (ch/value-at c 4))) - (is (= [6 0] (ch/value-at c 6))) - (is (= (mapv #(ch/value-at c %) [0 2 4 6 8 3 7]) + cursor (ch/cursor c nil)] + (is (= [0 0] (ch/value-at c 2 nil))) + (is (= [4 0] (ch/value-at c 4 nil))) + (is (= [6 0] (ch/value-at c 6 nil))) + (is (= (mapv #(ch/value-at c % nil) [0 2 4 6 8 3 7]) (mapv #(ch/sample! cursor %) [0 2 4 6 8 3 7]))))) (deftest a-frame-before-the-first-key-reads-the-first-key ;; The JS activeKey clamps low, and that is kept: a channel's first key is the ;; pose the part starts in. Having NO value is a different question — it is a ;; state bit, not an empty region of the key map. - (let [c (ch/keyed {10 :a, 20 :b})] - (is (= :a (ch/value-at c 0))) - (is (= :a (ch/value-at c 9))) - (is (= :b (ch/value-at c 999)) "and clamps high by holding the last key"))) + (let [c (ch/keyed {10 :a, 20 :b} :hold)] + (is (= :a (ch/value-at c 0 nil))) + (is (= :a (ch/value-at c 9 nil))) + (is (= :b (ch/value-at c 999 nil)) "and clamps high by holding the last key"))) (deftest keys-are-a-map-so-frame-order-in-the-literal-cannot-matter ;; Transit and JSON both lose sortedness, so the sorted index is built at read ;; time. A resolver that trusted insertion order would work in the REPL and ;; fail after a round trip through the server, which is the worst possible way ;; to find out. - (let [forward (ch/keyed (array-map 0 :a, 4 :b, 12 :c)) - backward (ch/keyed (array-map 12 :c, 4 :b, 0 :a)) - shuffled (ch/keyed (array-map 4 :b, 12 :c, 0 :a))] + (let [forward (ch/keyed (array-map 0 :a, 4 :b, 12 :c) :hold) + backward (ch/keyed (array-map 12 :c, 4 :b, 0 :a) :hold) + shuffled (ch/keyed (array-map 4 :b, 12 :c, 0 :a) :hold)] (doseq [c [backward shuffled]] - (is (= (mapv #(ch/value-at forward %) (range 0 16)) - (mapv #(ch/value-at c %) (range 0 16))))))) + (is (= (mapv #(ch/value-at forward % nil) (range 0 16)) + (mapv #(ch/value-at c % nil) (range 0 16))))))) (deftest dense-reads-one-value-per-frame-out-of-a-typed-array (let [store {"blk" {:data (js/Int16Array. #js [0 0, 10 20, 30 40, 50 60]) :state nil}} @@ -148,7 +148,7 @@ "Sample one cursor at each of `fs` in the order given, which is the point: a cursor carries state between calls." [c fs] - (let [cur (ch/cursor c)] + (let [cur (ch/cursor c nil)] (mapv #(ch/sample! cur %) fs))) (deftest the-cursor-agrees-with-the-specification-in-any-frame-order @@ -156,11 +156,30 @@ ;; the WRONG POSE rather than an error, so nothing would report it: the mouth ;; would simply be a beat behind on some frames and not others, which reads as ;; a bad take. - (doseq [[label c] [["sparse" (ch/keyed {0 :a, 4 :b, 12 :c, 13 :d, 40 :e})] - ["one key" (ch/keyed {7 :only})] - ["dense-ish" (ch/keyed (into {} (map (juxt identity #(* 10 %))) (range 40)))] - ["framed" (ch/framed :static)]]] - (let [spec #(ch/value-at c %) + (doseq [[label c] [["sparse" (ch/keyed {0 :a, 4 :b, 12 :c, 13 :d, 40 :e} :hold)] + ["one key" (ch/keyed {7 :only} :hold)] + ["dense-ish" (ch/keyed (into {} (map (juxt identity #(* 10 %))) (range 40)) :hold)] + ["framed" (ch/framed :static)] + ;; A stacked channel has a reading head per key map, and a + ;; layer's head is asked for nothing at all across the long + ;; stretches outside its support — then asked again. That is + ;; the drift this test exists to catch, now squared. + ["corrected" + (assoc (ch/keyed {0 0, 20 200, 44 440} :linear) + :over [(ch/layer :a [10 20] :offset + (ch/keyed {10 1, 19 90} :linear))])] + ["corrected twice, and over a constant" + (assoc (ch/keyed {0 0, 30 300} :linear) + :over [(ch/layer :a [5 15] :offset (ch/framed 7)) + (ch/layer :b [12 25] :replace + (ch/keyed {12 -1, 24 -12} :linear)) + (ch/layer :c [20 44] :offset + (ch/keyed {20 0, 43 23} :linear))])] + ["a corrected framed base" + (assoc (ch/framed 3) + :over [(ch/layer :a [8 36] :offset + (ch/keyed {8 0, 35 27} :linear))])]]] + (let [spec #(ch/value-at c % nil) forward (range 0 45) back (reverse forward) jumpy [0 44 1 43 12 12 13 3 40 7 0 22 22 21 44]] @@ -213,27 +232,122 @@ (is (= [1 2 3 4 5] (mapv #(ch/sample! cur %) (range 5)))) (is (= [5 1] (mapv #(ch/sample! cur %) [4 0])) "and seeks"))) -;; ---- what is deliberately not built has to fail loudly ---- +;; ---- correction layers ---- -(deftest an-override-layer-is-refused-rather-than-ignored - ;; :over is specified in docs/animation-model.md and out of scope for this - ;; step. Dropping one silently would present as a hand correction that did not - ;; take — a correction the user made once, watched fail, and has no reason to - ;; trust again. - (let [c (assoc (ch/keyed {0 [0 0]}) :over [{:blend :offset :keys {0 [2 0]}}])] - (is (thrown-with-msg? ExceptionInfo #":over" (ch/value-at c 0))) - (is (thrown-with-msg? ExceptionInfo #":over" (ch/cursor c))) - (is (seq (ch/problems c))))) +(defn- corrected + "A base channel with layers over it." + [base & layers] + (assoc base :over (vec layers))) + +(deftest a-correction-applies-only-over-its-support + ;; THE property. A range says where an edit applies, so outside it the + ;; underlying animation must evaluate exactly as it did before — which is what + ;; makes a bounded correction different from inserting boundary keys, and the + ;; reason the stack exists at all. + (let [base (ch/keyed {0 0, 10 100} :linear) + c (corrected base (ch/layer :nudge [3 6] :offset (ch/framed 5))) + plain (mapv #(ch/value-at base % nil) (range 11)) + with (mapv #(ch/value-at c % nil) (range 11))] + (is (= (concat (take 3 plain) [35 45 55] (drop 6 plain)) with)) + (is (= (assoc plain 3 35 4 45 5 55) with) "and nothing else moved at all"))) + +(deftest a-return-motion-is-a-keyed-layer-and-a-constant-is-a-framed-one + ;; The three commands the lane model asks for over one selected range, and + ;; none of them needs a new way to say what a value is over time. + (let [base (ch/keyed {0 0} :hold) + at (fn [c] (mapv #(ch/value-at c % nil) (range 6)))] + (is (= [0 0 10 10 10 0] + (at (corrected base (ch/layer :flat [2 5] :offset (ch/framed 10))))) + "a constant adjustment") + (is (= [0 0 0 5 10 0] + (at (corrected base (ch/layer :ramp [2 5] :offset + (ch/keyed {2 0, 4 10} :linear))))) + "a ramp") + (is (= [0 0 0 10 0 0] + (at (corrected base (ch/layer :return [2 5] :offset + (ch/keyed {2 0, 3 10, 4 0} :linear))))) + "and a return motion, which is three samples and no new mechanism"))) + +(deftest the-stack-is-ordered-and-replace-wins-where-it-covers + (let [base (ch/keyed {0 1} :hold) + two (fn [a b] (mapv #(ch/value-at (corrected base a b) % nil) (range 4))) + add2 (ch/layer :a [0 4] :offset (ch/framed 2)) + put9 (ch/layer :b [1 3] :replace (ch/framed 9))] + (is (= [3 9 9 3] (two add2 put9)) "the later layer sees the earlier one's result") + (is (= [3 11 11 3] (two put9 add2)) "and order therefore matters"))) + +(deftest validation-follows-the-shape-produced-by-an-ordered-stack + (let [base (ch/framed [0 0]) + put3 (ch/layer :put3 [0 2] :replace (ch/framed [1 2 3])) + add2 (ch/layer :add2 [0 2] :offset (ch/framed [1 1])) + add3 (ch/layer :add3 [0 2] :offset (ch/framed [1 1 1]))] + (is (seq (ch/problems (corrected base put3 add2))) + "validation rejects a stack that would throw while reading") + (is (empty? (ch/problems (corrected base put3 add3))) + "a covering replacement establishes the shape seen by later layers") + (is (= [2 3 4] (ch/value-at (corrected base put3 add3) 0 nil))))) + +(deftest generated-base-time-and-authored-correction-time-can-differ + (let [c (corrected (ch/keyed {0 0, 2 20} :hold) + (ch/layer :nudge [1 2] :offset (ch/framed 3))) + cursor (ch/cursor c nil)] + (is (= 3 (ch/value-at c 0 1 nil))) + (is (= 3 (ch/sample! cursor 0 1))) + (is (= 23 (ch/value-at c 2 1 nil))) + (is (= 20 (ch/sample! cursor 2 0))))) + +(deftest replace-can-supply-a-value-where-offset-has-nothing-to-add-to + ;; An absent value is not zero. `replace` states a pose; `offset` cannot + ;; offset a pose that was never measured, and must not invent one. + (let [gone (ch/keyed {} :hold)] + (is (ch/nothing? (ch/value-at gone 0 nil))) + (is (ch/nothing? (ch/value-at (corrected gone (ch/layer :o [0 2] :offset (ch/framed 5))) 0 nil))) + (is (= 5 (ch/value-at (corrected gone (ch/layer :r [0 2] :replace (ch/framed 5))) 0 nil))) + ;; And outside the support it is still absent, not the layer's value. + (is (ch/nothing? (ch/value-at (corrected gone (ch/layer :r [0 2] :replace (ch/framed 5))) 7 nil))))) + +(deftest a-correction-offsets-geometry-component-wise-over-a-dense-base + ;; The base a correction matters most for is generated, and a dense value is a + ;; VIEW onto the block: offsetting must not write into it. + (let [data (js/Int16Array. #js [10 20, 30 40]) + store {"blk" {:data data :state nil}} + base {:animated? true :dense {:store "blk" :offset 0 :stride 2 :frames 2}} + c (corrected base (ch/layer :nudge [1 2] :offset (ch/framed [5 -5])))] + (is (= [[10 20] [35 35]] + (mapv (fn [f] (let [v (ch/value-at c f store)] + [(ch/component v 0) (ch/component v 1)])) + (range 2)))) + (is (= [10 20 30 40] (vec data)) "the block itself is untouched") + (is (thrown-with-msg? + ExceptionInfo #"different shape" + (ch/value-at (corrected base (ch/layer :bad [0 2] :offset (ch/framed 1))) 0 store)) + "a correction of the wrong shape is loud, not silently dropped"))) + +(deftest a-malformed-correction-is-reported-rather-than-read + (let [ok (ch/layer :a [0 2] :offset (ch/framed 1)) + base (ch/keyed {0 1} :hold)] + (is (empty? (ch/problems (corrected base ok)))) + (is (seq (ch/problems (assoc base :over (list ok)))) "an ordered stack is a vector") + (doseq [[label bad] [["no id" (dissoc ok :id)] + ["backwards support" (assoc ok :support [5 2])] + ["open-ended support" (assoc ok :support [0 ##Inf])] + ["a support that is not a pair" (assoc ok :support 3)] + ["an unknown op" (assoc ok :op :multiply)] + ["no values" (dissoc ok :values)] + ["values that are not a channel" (assoc ok :values {:keys {0 1}})] + ["layers under a layer" + (assoc ok :values (corrected base ok))]]] + (is (seq (ch/problems (corrected base bad))) label)))) (deftest an-empty-over-is-fine-and-is-what-scenes-carry - (is (empty? (ch/problems (ch/keyed {0 1})))) - (is (= 1 (ch/value-at (ch/keyed {0 1}) 0)))) + (is (empty? (ch/problems (ch/keyed {0 1} :hold)))) + (is (= 1 (ch/value-at (ch/keyed {0 1} :hold) 0 nil)))) ;; ---- shape validation ---- (deftest problems-names-the-ways-a-channel-is-malformed (is (empty? (ch/problems (ch/framed 1)))) - (is (empty? (ch/problems (ch/keyed {0 1})))) + (is (empty? (ch/problems (ch/keyed {0 1} :hold)))) (testing "keys as a vector is the mistake most worth catching" (is (seq (ch/problems {:animated? true :keys [[0 1]]})))) (is (seq (ch/problems {:value 1})) "no :animated?") @@ -248,9 +362,9 @@ (deftest numeric-channels-can-ramp-between-keys (let [c (ch/keyed {0 0.0, 10 1.0} :linear) - cursor (ch/cursor c)] + cursor (ch/cursor c nil)] (is (= [0.0 0.5 1.0 1.0] - (mapv #(ch/value-at c %) [0 5 10 15]))) + (mapv #(ch/value-at c % nil) [0 5 10 15]))) (is (= [0.0 0.5 1.0 0.2] (mapv #(ch/sample! cursor %) [0 5 10 2]))))) @@ -270,3 +384,45 @@ with (assoc base :generated {:by :roto/lips-outer :params {:verts 8}})] (is (= (mapv #(ch/value-at base % store) (range 3)) (mapv #(ch/value-at with % store) (range 3)))))) + +(deftest a-correction-the-base-outgrew-is-a-conflict-and-not-a-problem + ;; What a topology change does: a re-freeze gives the mouth a different number + ;; of points, and a correction that was right when it was made can no longer + ;; be added component by component. That is not a broken document — it is a + ;; decision waiting for a person. + (let [pts (fn [n] {:animated? true :interp :hold :keys {0 (vec (repeat n 1))}}) + nudge (ch/layer :nudge [0 4] :offset (ch/framed [1 1 1 1])) + fits (assoc (pts 4) :over [nudge]) + outgrown (assoc (pts 6) :over [nudge])] + (is (nil? (ch/conflict-with (pts 4) nudge))) + (is (empty? (ch/conflicts fits))) + (is (empty? (ch/problems fits))) + (is (re-find #"different shape" (ch/conflict-with (pts 6) nudge))) + ;; Unrecorded, it is an authoring bug and says so. + (is (seq (ch/problems outgrown))) + (is (= [:nudge] (mapv :id (ch/conflicts outgrown)))) + ;; Recorded, the document is sound and the correction is simply not applied. + (let [marked (assoc (pts 6) :over [(assoc nudge :conflict "outgrown")])] + (is (empty? (ch/problems marked)) "a recorded conflict is not a reason not to load") + (is (= [:nudge] (mapv :id (ch/conflicts marked)))) + (is (= [1 1 1 1 1 1] (vec (ch/value-at marked 0 nil))) + "the base alone — neither misapplied nor silently dropped") + (is (= (ch/value-at marked 0 nil) (first (via-cursor marked [0]))) + "and the cursor skips it too")))) + +(deftest replace-never-conflicts-and-an-opaque-value-cannot-be-offset + (let [flag (ch/keyed {0 true} :hold)] + (is (nil? (ch/conflict-with flag (ch/layer :r [0 2] :replace (ch/framed false)))) + "replace states a whole value, so it has nothing to agree with") + (is (re-find #"not a number" (ch/conflict-with flag (ch/layer :o [0 2] :offset (ch/framed 1))))) + (is (nil? (ch/conflict-with (ch/keyed {} :hold) (ch/layer :o [0 2] :offset (ch/framed 1)))) + "an empty key map is not a disagreement"))) + +(deftest the-shape-of-a-channels-values-is-readable-without-sampling-it + (is (= :scalar (ch/value-shape (ch/framed 3)))) + (is (= 2 (ch/value-shape (ch/framed [1 2])))) + (is (= :scalar (ch/value-shape {:animated? true :dense {:stride 1}}))) + (is (= 40 (ch/value-shape {:animated? true :dense {:stride 40}}))) + (is (= 2 (ch/value-shape (ch/keyed {0 [1 2], 4 [3 4]} :linear)))) + (is (= :opaque (ch/value-shape (ch/keyed {0 :a} :hold)))) + (is (nil? (ch/value-shape (ch/keyed {} :hold))) "nothing to read it off")) diff --git a/frontend/test/arthur/domain/gesture_test.cljs b/frontend/test/arthur/domain/gesture_test.cljs index 96ee6e4..eb3fae6 100644 --- a/frontend/test/arthur/domain/gesture_test.cljs +++ b/frontend/test/arthur/domain/gesture_test.cljs @@ -1,5 +1,6 @@ (ns arthur.domain.gesture-test (:require [cljs.test :refer [deftest is testing]] + [arthur.demo.take :as take] [arthur.domain.channel :as ch] [arthur.domain.clip :as clip] [arthur.domain.gesture :as gesture] @@ -33,13 +34,13 @@ (defn- drawn [c path] (partition 2 (take 6 (array-seq (:pts (first (filter #(= path (:node %)) - ((clip/resolver c nil pal/index-of :main) 16)))))))) + ((clip/resolver c :main nil pal/index-of nil) 16)))))))) (defn- near? [a b] (every? #(< (js/Math.abs %) 1e-9) (map - (flatten a) (flatten b)))) (defn- dragged [c path f vs-fn] (let [{:keys [sid id frame] :as pl} (nest/placement c nil :main path 16) - v0 (gesture/values (get-in c [:symbols sid :nodes id]) frame)] + v0 (gesture/values (get-in c [:symbols sid :nodes id]) frame nil)] (gesture/apply-values c sid id frame (vs-fn pl v0)))) (deftest a-shape-two-symbols-down-moves-under-the-pointer @@ -75,6 +76,174 @@ "the corner grabbed is under the pointer, through a turned, unevenly scaled parent") (is (near? (at world 5 3) (at w2 5 3)) "about the pivot"))) +;; --------------------------------------------------------------------------- +;; which way a corner drag goes +;; +;; `scale` takes the point under the pointer to the pointer, about the node's +;; PIVOT, and that is the whole of it — so which way a corner drag goes is +;; decided entirely by where the pivot is. With it in the middle of what the node +;; draws, where `clip/place-symbol`, `paint/new-shape` and now `flow/freeze` all +;; put it, pulling a corner away from the middle makes the node bigger. With it +;; at the node's coordinate ORIGIN, which is what a node with no anchor gets, +;; every corner drag is a drag away from some point off in the corner of the +;; footage: the shape shrinks and slides while the corner dutifully follows the +;; pointer, which is what the bug looked like from the outside. + +(defn- at [m p] (let [out (js/Float64Array. 2)] + (vec (array-seq (node/apply-pt! out 0 m (first p) (second p)))))) + +(defn- handles + "Where the stage would draw this node's box and pivot: its own bounds and + anchor through its `:world`, which is what `ui/stage`'s `handles` does." + [c st open path f] + (let [{:keys [sid id world frame]} (nest/placement c st open path f) + n (get-in c [:symbols sid :nodes id]) + [x0 y0 x1 y1] ((pick/bounds-of c st n) frame)] + {:corners (mapv #(at world %) [[x0 y0] [x1 y0] [x1 y1] [x0 y1]]) + :pivot (at world (:anchor (gesture/values n frame st)))})) + +(defn- span + "How big the box is, as the length of its diagonals — which does not care that + a turned parent leaves it off the screen's axes." + [{[a b c* d] :corners}] + (+ (js/Math.hypot (- (first c*) (first a)) (- (second c*) (second a))) + (js/Math.hypot (- (first d) (first b)) (- (second d) (second b))))) + +(defn- corner-drag + "Drag corner `i` of the node's box by `d`, exactly as the stage does: read the + placement and the transform off the document AS IT IS NOW, scale from where + the pointer went down to where it is." + [c st open path f i d] + (let [{:keys [sid id frame] :as pl} (nest/placement c st open path f) + v0 (gesture/values (get-in c [:symbols sid :nodes id]) frame st) + p0 (nth (:corners (handles c st open path f)) i) + p1 (mapv + p0 d)] + {:clip (gesture/apply-values c sid id frame (gesture/scale pl v0 p0 p1 false)) + :p0 p0 :p1 p1})) + +(defn- away + "A pull of `k` stage pixels straight away from the pivot, from corner `i`." + [{:keys [corners pivot]} i k] + (let [[dx dy] (mapv - (nth corners i) pivot) + len (max 1e-9 (js/Math.hypot dx dy))] + [(* k (/ dx len)) (* k (/ dy len))])) + +(defn- middled + "`two-down` with the shape pivoting about the middle of what it draws — which + is what `paint/new-shape` writes and what `two-down` deliberately moves off, so + that the rest of this file is not accidentally testing the easy case." + [] + (let [c (two-down)] + (assoc-in c [:symbols :box :nodes :shape :channels [:xform :anchor]] + (ch/framed (pick/pivot c nil (get-in c [:symbols :box :nodes :shape]) [4]))))) + +(deftest dragging-a-corner-away-from-the-middle-makes-it-bigger + (doseq [[what c path f] + [["a shape on its own" + (paint/new-shape (clip/blank) :main :shape 4 [100 80 140 80 140 110 100 110] :brow) + [:shape] 4] + ["a shape two symbols down, through a turned and unevenly scaled parent" + (middled) [u v :shape] 16]] + i (range 4)] + (let [before (handles c nil :main path f) + out (corner-drag c nil :main path f i (away before i 6)) + in (corner-drag c nil :main path f i (away before i -6)) + grown (handles (:clip out) nil :main path f) + shrunk (handles (:clip in) nil :main path f)] + (is (< (span before) (span grown)) + (str what ", corner " i ": pulled away from the middle it got smaller")) + (is (> (span before) (span shrunk)) + (str what ", corner " i ": pushed towards the middle it got bigger")) + (is (near? (:p1 out) (nth (:corners grown) i)) + (str what ", corner " i ": the corner grabbed is not under the pointer")) + (is (near? (:pivot before) (:pivot grown)) + (str what ", corner " i ": the pivot moved"))))) + +(deftest a-corner-pulled-up-and-left-grows-up-and-left + ;; The report, literally: no turned parent in the way, so the box's corners are + ;; the screen's, and the top-left one dragged further top-left has to make the + ;; box bigger rather than smaller. + (let [c (paint/new-shape (clip/blank) :main :shape 4 [100 80 140 80 140 110 100 110] :brow) + before (handles c nil :main [:shape] 4) + after (handles (:clip (corner-drag c nil :main [:shape] 4 0 [-10 -10])) + nil :main [:shape] 4) + [tl _ br _] (:corners after)] + (is (= [100 80] (first (:corners before))) "corner 0 is the top left") + (is (near? [90 70] tl) "the corner grabbed is under the pointer") + (is (and (> (first br) 140) (> (second br) 110)) + "and the far corner went the other way, about the middle") + (is (< (span before) (span after)) "so the shape is bigger"))) + +(deftest a-face-part-scales-about-its-own-middle + ;; The whole measured rig, from `demo/take`: source-space geometry under a + ;; dense head similarity, under the authored source placement, inside an + ;; instance. Before `freeze/pivoted` the mouth's pivot sat at (-234, -395) on a + ;; 320x200 stage — the top-left corner of the FOOTAGE, carried onto the stage — + ;; and every corner drag was a drag away from it. + (let [{c :clip st :store} @take/frozen + f 10] + (doseq [path [[:face-1] [:face-1 :mouth] [:face-1 :eye-r]]] + (let [before (handles c st :main path f) + [px py] (:pivot before)] + (is (and (< 0 px (:width c)) (< 0 py (:height c))) + (str path "'s pivot " (pr-str (:pivot before)) " is off the stage")) + (doseq [i (range 4)] + (let [out (corner-drag c st :main path f i (away before i 8)) + grown (handles (:clip out) st :main path f)] + (is (< (span before) (span grown)) + (str path ", corner " i " pulled away from the middle got smaller")) + (is (near? (:p1 out) (nth (:corners grown) i)) + (str path ", corner " i " is not under the pointer")) + (is (near? (:pivot before) (:pivot grown)) + (str path ", corner " i ": the pivot moved")))))))) + +(deftest every-node-in-a-face-can-have-its-transform-read + ;; What a click near the eyes hit, and it took the stage down with it — in + ;; `begin!` selecting the node, and again in `handles` drawing its pivot + ;; cross. `values` asked its channels for a value WITHOUT the tier-2 store, + ;; which is fine for every hand-placed node and throws the moment one of them + ;; is dense: an iris follows the gaze, a brow follows the raise, a head + ;; follows the measured similarity, and all three are ordinary things to + ;; select. Over every node of a real take, so a new dense channel on any of + ;; them is covered the day it is added. + (let [{c :clip st :store} @take/frozen] + (doseq [sid [:main :face-1] + [id n] (get-in c [:symbols sid :nodes])] + (let [v (gesture/values n 10 st)] + (is (map? v) (str sid "/" id " could not be read at all")) + (is (every? #(or (number? %) (ch/nothing? %)) + (concat (:pos v) (:scale v) (:anchor v) [(:rot v)])) + (str sid "/" id " did not read as numbers: " (pr-str v)))) + (when (node/measured? n) + (is (thrown? js/Error (gesture/values n 10 nil)) + (str sid "/" id " has a dense transform, so reading it storeless" + " has to throw — otherwise this test proves nothing")) + (is (string? (gesture/refusal n)) + (str sid "/" id " is measured but a drag on it is not refused")))))) + +(deftest a-drag-starts-from-the-document-as-it-is-now + ;; What `ui/stage`'s `loaded` is for. A drag reads the placement and the + ;; transform when the pointer goes DOWN; reading them when the overlay last + ;; rendered is the same code with a clip in it that predates the last edit, and + ;; this is what that costs — the node is put back where it was before the + ;; previous drag and only then moved, so it sits still under the press and + ;; jumps on the first pointermove. Two drags have to compose. + (let [c (two-down) + path [u v :shape] + once (dragged c path 16 #(gesture/move %1 %2 [30 30] [37 26])) + twice (dragged once path 16 #(gesture/move %1 %2 [30 30] [33 31])) + ;; The same second drag, begun from the clip as it was BEFORE the first. + stale (let [{:keys [sid id frame] :as pl} (nest/placement c nil :main path 16) + v0 (gesture/values (get-in c [:symbols sid :nodes id]) frame nil)] + (gesture/apply-values once sid id frame + (gesture/move pl v0 [30 30] [33 31])))] + (is (near? (map (fn [[x y]] [(+ x 10) (- y 3)]) (drawn c path)) (drawn twice path)) + "(7, -4) then (3, 1) leaves the shape moved by (10, -3)") + (is (near? (map (fn [[x y]] [(+ x 3) (+ y 1)]) (drawn c path)) (drawn stale path)) + "begun from the stale clip it lands where the FIRST drag never happened") + (is (not (near? (drawn twice path) (drawn stale path))) + "which is the jump, and it is the whole of the difference"))) + (deftest a-drag-keys-a-keyed-channel-and-sets-a-framed-one (let [c (update-in (two-down) [:symbols :mid :nodes v :channels [:xform :pos]] (constantly (ch/keyed {0 [5 -3] 20 [9 -3]} :linear))) @@ -90,7 +259,7 @@ (deftest a-measured-transform-is-not-set-by-hand (is (string? (gesture/refusal {:channels {[:xform :pos] {:animated? true :dense {:stride 2}}}}))) - (is (nil? (gesture/refusal {:channels {[:xform :pos] (ch/keyed {0 [1 1]})}})))) + (is (nil? (gesture/refusal {:channels {[:xform :pos] (ch/keyed {0 [1 1]} :hold)}})))) (deftest a-click-selects-the-level-figma-would (let [hit [:a :b :c :shape]] @@ -118,5 +287,5 @@ (deftest an-instances-box-is-what-its-symbol-draws (let [c (two-down) {:keys [frame]} (nest/placement c nil :main [u v] 16)] - (is (= [0 0 10 10] (pick/local-bounds c nil (get-in c [:symbols :mid :nodes v]) frame))) - (is (= [0 0 10 10] (pick/local-bounds c nil (get-in c [:symbols :box :nodes :shape]) 4))))) + (is (= [0 0 10 10] ((pick/bounds-of c nil (get-in c [:symbols :mid :nodes v])) frame))) + (is (= [0 0 10 10] ((pick/bounds-of c nil (get-in c [:symbols :box :nodes :shape])) 4))))) diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index fd293c1..a5414a3 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -17,7 +17,7 @@ :nodes {:root {:id :root :kind :group :z "a1"} :mark {:id :mark :kind :rect :parent :root :z "a1" :channels {[:xform :pos] (ch/keyed {0 [0 0] 1 [10 0] - 2 [20 0] 3 [30 0]}) + 2 [20 0] 3 [30 0]} :hold) [:geom :size] (ch/framed 4) [:style :color] (ch/framed :brow)}}}}}}) @@ -27,16 +27,18 @@ (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))) - resolve (clip/resolver document nil pal/index-of :main) + resolve (clip/resolver document :main nil pal/index-of nil) at (fn [f] (mapv (juxt :node :cx) (resolve f)))] (is (empty? (clip/problems document))) (is (= [[[:left :mark] 110]] (at 1))) @@ -46,13 +48,14 @@ (deftest a-placement-holds-and-cuts-each-generated-shape-independently (let [values (js/Int16Array. (clj->js (range 2 32))) - visible (ch/keyed {0 true 20 true 21 false}) + visible (ch/keyed {0 true 20 true 21 false} :hold) dense {:animated? true :interp :hold :dense {:store "sizes" :offset 0 :stride 1 :frames 30} - :pose-sampled? true} + :pose-sampled? true + :over [(ch/layer :nudge [7 8] :offset (ch/framed 100))]} shape (fn [id z group] {:id id :kind :rect :parent :root :z z :pose-group group - :channels {[:xform :pos] (ch/keyed {0 [0 0] 8 [8 0]}) + :channels {[:xform :pos] (ch/keyed {0 [0 0] 8 [8 0]} :hold) [:geom :size] dense [:vis] (assoc visible :pose-sampled? true) [:style :color] (ch/framed :brow)}}) @@ -66,53 +69,54 @@ :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}} - resolve (clip/resolver document {"sizes" {:data values}} pal/index-of :main) - low-resolve (clip/resolver document {"sizes" {:data values}} - pal/index-of :main {:picture-fps 8}) + store {"sizes" {:data values}} + resolve (clip/resolver document :main store pal/index-of nil) + low-resolve (clip/resolver document :main store pal/index-of {:picture-fps 8}) at (fn [f] (into {} (map (fn [op] [(:node op) op])) (resolve f))) low-at (fn [f] (into {} (map (fn [op] [(:node op) op])) (low-resolve f)))] (is (empty? (clip/problems document))) (is (= document (leaf/clip "stage" (leaf/leaves "stage" document)))) - (is (= 2 (:size (get (at 7) [:first :mouth]))) "eight static frames") + (is (= 102 (:size (get (at 7) [:first :mouth]))) "eight static frames, plus its correction") (is (= 22 (:size (get (at 8) [:first :mouth]))) "cut to source pose 20") (is (= 6 (:size (get (at 8) [:first :mouth-detail]))) "one node may depart from its shared mouth group") (is (= 10 (:size (get (at 8) [:second :mouth]))) "other instance chooses pose 8") - (is (= 6 (:size (get (at 7) [:first :eye]))) "eye has its own timing") + (is (= 106 (:size (get (at 7) [:first :eye]))) "eye has its own timing") (is (= 8 (:cx (get (at 8) [:first :eye]))) "authored position still reads stage time") (is (nil? (get (at 9) [:first :mouth])) "generated visibility is read from the same selected pose") (is (some? (get (at 9) [:second :mouth]))) - (is (= 5 (:size (get (low-at 7) [:first :brow]))) - "picture rate samples only generated motion") + (is (= 105 (:size (get (low-at 7) [:first :brow]))) + "picture rate samples the generated base and leaves correction time alone") (is (= 22 (:size (get (low-at 8) [:first :mouth]))) "an explicit cut occurs at its exact local frame, even off the picture grid") (is (= 8 (:cx (get (low-at 8) [:first :eye]))) "authored position ignores the picture grid") - (let [sym (get-in document [:symbols :sym/poses]) - opts {:source-fps 30 :picture-fps 8}] + (let [sym (get-in document [:symbols :sym/poses]) + opts {:source-fps 30 :picture-fps 8 :pose-tracks {:mouth {0 0, 8 20}}}] (is (= (mapv #(select-keys % [:node :cx :size]) - (symbol/eval-frame sym 8 {"sizes" {:data values}} - pal/index-of {:mouth {0 0, 8 20}} opts)) + (symbol/eval-frame sym 8 store pal/index-of opts)) (mapv #(select-keys % [:node :cx :size]) - ((symbol/resolver sym {"sizes" {:data values}} - pal/index-of {:mouth {0 0, 8 20}} opts) 8))) + ((symbol/resolver sym store pal/index-of opts) 8))) "pure evaluation and playback apply the same pose choice")))) (deftest stage-pose-edits-preserve-earlier-motion-and-survive-save (let [document (-> source (assoc-in [:symbols :main :nodes :placed] - {:id :placed :kind :instance :of :sym/test :parent :root - :z "a2"}) + {: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"} @@ -155,8 +159,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 @@ -167,7 +171,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 %)) @@ -178,15 +182,15 @@ scale (get-in left [:channels [:xform :scale]]) anchor (get-in left [:channels [:xform :anchor] :value]) pos (get-in left [:channels [:xform :pos]]) - start-pos (ch/value-at pos 0)] + start-pos (ch/value-at pos 0 nil)] (is (= [160 100] anchor) "the source center becomes a stored pivot") (is (= [-120 -60] start-pos)) - (is (not= start-pos (ch/value-at pos 40)) "the face drifts during playback") - (is (= [0.4 0.4] (ch/value-at scale 0))) - (is (= [0.56 0.56] (ch/value-at scale 12))) - (is (= [0.52 0.52] (ch/value-at scale 48))) + (is (not= start-pos (ch/value-at pos 40 nil)) "the face drifts during playback") + (is (= [0.4 0.4] (ch/value-at scale 0 nil))) + (is (= [0.56 0.56] (ch/value-at scale 12 nil))) + (is (= [0.52 0.52] (ch/value-at scale 48 nil))) (doseq [f [0 12 48]] - (let [m (node/local! (node/mat) start-pos 0 (ch/value-at scale f) [0 0] anchor) + (let [m (node/local! (node/mat) start-pos 0 (ch/value-at scale f nil) [0 0] anchor) out (js/Float64Array. 2)] (node/apply-pt! out 0 m 160 100) (is (= [40 40] [(aget out 0) (aget out 1)]) @@ -200,10 +204,10 @@ (is (= [48 260] (node/placed-span (placement document :voice-right)))) (is (= 0.5 (ch/value-at (get-in (placement document :voice-right) - [:channels [:audio :gain]]) 54))) + [:channels [:audio :gain]]) 54 nil))) (is (< -0.8 (ch/value-at (get-in (placement document :voice-right) - [:channels [:audio :pan]]) 110) 0.7)) + [:channels [:audio :pan]]) 110 nil) 0.7)) (is (= document (leaf/clip "stage" (leaf/leaves "stage" document)))))) (defn- nested @@ -223,7 +227,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" @@ -248,14 +252,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..eb8d2d1 --- /dev/null +++ b/frontend/test/arthur/domain/lane_test.cljs @@ -0,0 +1,622 @@ +(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.lane :as lane] + [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 cel [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 (cel :a :drawing-a 0 4 0) + b (assoc-in (cel :b :drawing-b 4 4 0) + [:channels [:xform :pos]] (ch/keyed {0 [0 0] 1 [2 0]} :hold)) + insert (assoc-in (cel :insert :wave 8 4 1) [:playback :in] 3)] + {:name "cels" :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 cel-ripple-keeps-lane-keys-and-moves-cel-corrections + (let [doc (document) + result (lane/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 (lane/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 (lane/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 (lane/extend-hold doc :main :a delta {})))) + (is (:refused (lane/extend-hold doc :main :insert 1 {}))) + (is (:refused (lane/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 cel-identities-and-playback-round-trip + (let [doc (:clip (lane/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 (lane/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 (lane/add-lane (clip/blank) :main :girl)) + a (:clip (lane/append-drawing doc :main :girl :a :drawing-a {})) + b (:clip (lane/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 (lane/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 (lane/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-cel + (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 (lane/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 (lane/extend-hold doc :main :a 2 {})] + (is (= 15 (:required-frames result))) + (is (nil? (:clip result))) + (is (= 15 (get-in (lane/extend-hold doc :main :a 2 {:extent :grow-symbol}) + [:clip :symbols :main :frames]))))) + +(deftest reuse-shares-content-and-make-unique-decouples-one-cel + (let [doc (document) + shared (:clip (lane/reuse-drawing doc :main :girl :c :drawing-a + {:extent :grow-symbol})) + edit (fn [c sym x] + (assoc-in c [:symbols sym :nodes :mark :channels [:xform :pos]] + (ch/framed [x 0])))] + (is (:refused (lane/reuse-drawing doc :main :girl :c :drawing-a {})) + "the shot has to be extended on purpose") + (is (= :drawing-a (node/source (get-in shared [:symbols :main :nodes :c])))) + (is (= [12 13] (node/placed-span (get-in shared [:symbols :main :nodes :c])))) + (is (empty? (clip/problems shared))) + ;; One drawing, two cels: the edit arrives at both. + (let [at (sample (edit shared :drawing-a 99) [0 12])] + (is (= 99 (get-in at [0 [:a :mark]]))) + (is (= 99 (get-in at [12 [:c :mark]])))) + (let [unique (:clip (lane/make-unique shared :main :c {}))] + (is (= :drawing-a-2 (node/source (get-in unique [:symbols :main :nodes :c])))) + (is (= (:nodes (get-in shared [:symbols :drawing-a])) + (:nodes (get-in unique [:symbols :drawing-a-2]))) + "a copy of the same drawing, not an empty one") + (is (= :drawing-a (node/source (get-in unique [:symbols :main :nodes :a]))) + "the other cel keeps the original") + (let [at (sample (edit unique :drawing-a 99) [0 12])] + (is (= 99 (get-in at [0 [:a :mark]]))) + (is (= 10 (get-in at [12 [:c :mark]])) "the cel made unique is untouched")) + (let [at (sample (edit unique :drawing-a-2 99) [0 12])] + (is (= 10 (get-in at [0 [:a :mark]])) "and does not reach back")) + (is (empty? (clip/problems unique)))) + ;; Nothing else places drawing-b, so there is nothing to decouple from. + (is (:refused (lane/make-unique doc :main :b {}))) + (is (:refused (lane/make-unique doc :main :girl {})) + "a lane places nothing itself"))) + +(deftest duplicate-copies-the-drawing-and-not-the-cel + (let [doc (document) + made (:clip (lane/duplicate-drawing doc :main :b :d {:extent :grow-symbol})) + n (get-in made [:symbols :main :nodes :d])] + (is (= :drawing-b-2 (node/source n))) + (is (= (:nodes (get-in doc [:symbols :drawing-b])) + (:nodes (get-in made [:symbols :drawing-b-2])))) + (is (= [12 13] (node/placed-span n))) + (is (= {:in 0 :speed 0 :end :stop} (:playback n))) + (is (nil? (:channels n)) "B's own position correction belongs to B's cel") + (is (= (get-in doc [:symbols :main :nodes :b]) + (get-in made [:symbols :main :nodes :b])) + "the drawing duplicated is left as it was") + (is (empty? (clip/problems made))))) + +(deftest a-shallow-copy-keeps-its-parts-and-a-deep-copy-owns-them + ;; A drawing assembled from another symbol: copying it shallowly must keep + ;; using that part, and only an explicit deep copy may promise independence. + (let [doc (assoc-in (document) [:symbols :drawing-a :nodes :part] + {:id :part :kind :instance :z "b" :span [0 1] + :time {:at 0 :rate 1} :source {:symbol :wave} + :playback {:in 0 :speed 0 :end :stop}}) + copy (fn [opts] (:clip (lane/duplicate-drawing + doc :main :a :d (merge {:extent :grow-symbol} opts)))) + shallow (copy {}) + deep (copy {:deep? true})] + (is (= :wave (node/source (get-in shallow [:symbols :drawing-a-2 :nodes :part])))) + (is (nil? (get-in shallow [:symbols :wave-2]))) + (is (= :wave-2 (node/source (get-in deep [:symbols :drawing-a-2 :nodes :part])))) + (is (= (:nodes (get-in doc [:symbols :wave])) (:nodes (get-in deep [:symbols :wave-2])))) + (is (empty? (clip/problems shallow))) + (is (empty? (clip/problems deep))))) + +(deftest reuse-refuses-what-would-not-be-a-document + (let [doc (document)] + (is (:refused (lane/reuse-drawing doc :main :girl :c :nothing-here {}))) + (is (:refused (lane/reuse-drawing doc :main :girl :c :main {:extent :grow-symbol})) + "a symbol cannot go inside itself") + (is (:refused (lane/reuse-drawing doc :main :girl :a :drawing-a {:extent :grow-symbol})) + "a cel ID in use is not free") + (is (:refused (lane/reuse-drawing doc :main :plate :c :drawing-a {}))) + (is (:refused (lane/duplicate-drawing doc :main :girl :d {}))))) + +(deftest drawing-on-twos-does-not-quantize-the-lane-transform + ;; Cel length IS the drawing cadence, and it is the only thing on twos + ;; here: the lane's transform has its own clock and keeps moving every frame. + ;; Stepping it would be the cel cadence leaking into continuous motion. + (let [cel (fn [id source at] (cel id source at 2 0)) + doc (-> (document) + (update-in [:symbols :main :nodes] dissoc :a :b :insert) + (update-in [:symbols :main :nodes] merge + {:c0 (cel :c0 :drawing-a 0) + :c1 (cel :c1 :drawing-b 2) + :c2 (cel :c2 :drawing-a 4)})) + xs {:c0 10 :c1 20 :c2 10} + at (sample doc (range 6)) + showing (fn [f] (first (dissoc (at f) :plate)))] + (is (empty? (clip/problems doc))) + (is (= [:c0 :c0 :c1 :c1 :c2 :c2] (mapv #(first (key (showing %))) (range 6))) + "the drawing showing changes every second frame") + (is (= [0 10 20 30 40 50] + (mapv (fn [f] (let [[[id _] cx] (showing f)] (- cx (xs id)))) (range 6))) + "and the lane moves on every frame, odd ones included"))) + +(defn- drawn + "What every frame draws, as sorted values, so a picture can be compared + without naming the cels that produced it." + [doc fs] + (let [at (sample doc fs)] + (mapv #(sort (vals (get at %))) fs))) + +(deftest a-drawing-goes-anywhere-in-the-lane-and-ripples-what-follows + (let [doc (document) + keys-of #(get-in % [:symbols :main :nodes :girl :channels [:xform :pos] :keys]) + spans #(mapv (fn [id] (node/placed-span (get-in % [:symbols :main :nodes id]))) + [:a :n :b :insert]) + r (lane/append-drawing doc :main :girl :n :drawing-n + {:at 4 :extent :grow-symbol})] + (is (= [[0 4] [4 5] [5 9] [9 13]] (spans (:clip r)))) + (is (= 13 (get-in r [:clip :symbols :main :frames]))) + (is (= (keys-of doc) (keys-of (:clip r))) "lane keys stay where they were authored") + (is (= :n (:selection r))) + (is (= 4 (:frame r))) + (is (empty? (clip/problems (:clip r)))) + ;; The same command with no room refuses, and says how much it needs. + (is (= 13 (:required-frames (lane/append-drawing doc :main :girl :n :drawing-n {:at 4})))) + ;; At the very front everything moves. + (is (= [[1 5] [0 1] [5 9] [9 13]] + (spans (:clip (lane/append-drawing doc :main :girl :n :drawing-n + {:at 0 :extent :grow-symbol}))))) + ;; Inside a cel is not a position for another one. + (is (re-find #"split it first" + (:refused (lane/append-drawing doc :main :girl :n :drawing-n + {:at 2 :extent :grow-symbol})))) + (is (:refused (lane/append-drawing doc :main :girl :n :drawing-n + {:at -1 :extent :grow-symbol}))) + (is (:refused (lane/append-drawing doc :main :girl :n :drawing-n + {:at ##Inf :extent :grow-symbol}))) + ;; Reuse and duplicate take a position too; it is one placement rule. + (is (= [4 5] (node/placed-span + (get-in (lane/reuse-drawing doc :main :girl :n :drawing-b + {:at 4 :extent :grow-symbol}) + [:clip :symbols :main :nodes :n])))) + (is (= [4 5] (node/placed-span + (get-in (lane/duplicate-drawing doc :main :b :n + {:at 4 :extent :grow-symbol}) + [:clip :symbols :main :nodes :n])))))) + +(deftest splitting-an-cel-changes-nothing-that-is-drawn + (let [doc (document) + fs (range 12) + before (drawn doc fs)] + (doseq [[label id cut] [["a held drawing" :a 2] + ["a cel with a correction of its own" :b 6] + ["a playing insert" :insert 10]]] + (testing label + (let [r (lane/split doc :main id cut :right) + after (:clip r)] + (is (= :right (:selection r))) + (is (= before (drawn after fs)) "the same picture, frame for frame") + (is (= (node/placed-span (get-in doc [:symbols :main :nodes id])) + [(first (node/placed-span (get-in after [:symbols :main :nodes id]))) + (second (node/placed-span (get-in after [:symbols :main :nodes :right])))]) + "the pieces occupy the frames the cel did") + (is (= cut (second (node/placed-span (get-in after [:symbols :main :nodes id]))) + (first (node/placed-span (get-in after [:symbols :main :nodes :right]))))) + (is (= (:time (get-in doc [:symbols :main :nodes id])) + (:time (get-in after [:symbols :main :nodes :right]))) + "one time map, so the right piece's own frames carry on") + (is (= (select-keys (get-in doc [:symbols :main :nodes id]) [:source :playback :channels]) + (select-keys (get-in after [:symbols :main :nodes :right]) [:source :playback :channels]))) + (is (= 12 (get-in after [:symbols :main :frames])) "and no shot-length question") + (is (empty? (clip/problems after)))))))) + +(deftest split-refuses-anything-that-is-not-one-cut-inside-one-cel + (let [doc (document)] + (doseq [cut [0 4 8 12 -1 2.5 ##NaN nil]] + (is (:refused (lane/split doc :main :b cut :right)) (str "cut at " (pr-str cut)))) + (is (:refused (lane/split doc :main :girl 2 :right)) "a lane is not a cel") + (is (:refused (lane/split doc :main :plate 2 :right)) "nor is a shape outside one") + (is (:refused (lane/split doc :main :a 2 :b)) "the new ID has to be free"))) + +(deftest split-then-place-puts-a-drawing-inside-a-hold + ;; The two commands the doc asks for, composed: neither one guesses. + (let [doc (document) + cut (:clip (lane/split doc :main :a 2 :right)) + r (lane/append-drawing cut :main :girl :n :drawing-n + {:at 2 :extent :grow-symbol}) + after (:clip r)] + (is (= [[0 2] [2 3] [3 5] [5 9] [9 13]] + (mapv #(node/placed-span (get-in after [:symbols :main :nodes %])) + [:a :n :right :b :insert]))) + (is (= (get-in doc [:symbols :main :nodes :girl :channels]) + (get-in after [:symbols :main :nodes :girl :channels])) + "the performance is still timed the way it was authored") + (is (empty? (clip/problems after))))) + +(deftest a-three-frame-correction-crosses-a-drawing-boundary + ;; The lane model's worked example. The correction belongs to the GIRL, so it + ;; applies across whichever drawings are showing under it, and outside its + ;; three frames the animation evaluates exactly as it did before. + (let [doc (document) + fs (range 12) + before (drawn doc fs) + beat (ch/layer :beat [3 6] :offset (ch/framed [30 0])) + c (update-in doc [:symbols :main :nodes :girl :channels [:xform :pos] :over] + (fnil conj []) beat) + after (drawn c fs) + outside [0 1 2 6 7 8 9 10 11]] + (is (empty? (clip/problems c))) + (is (= (mapv before outside) (mapv after outside)) + "outside the support, frame for frame identical") + (let [at (sample c [3 4 5])] + ;; Frame 3 shows drawing A and frames 4 and 5 show drawing B: one + ;; correction, reaching across the cut between them. + (is (= 70 (get-in at [3 [:a :mark]]))) + (is (= 90 (get-in at [4 [:b :mark]]))) + (is (= 102 (get-in at [5 [:b :mark]])) "and B's own correction still applies under it") + (is (= [-10 0 10] (mapv (fn [f] (js/Math.round (get-in at [f :plate]))) [3 4 5])) + "while the background, which is not in the lane, does not move")) + ;; One document change: one step, and it persists in the channel's own leaf. + (let [b (leaf/leaves :p doc) + a (leaf/leaves :p c) + h (-> nil history/hold (history/record b a 0) history/settle)] + (is (= 1 (count (:done h)))) + (is (= b (:leaves (history/undo h a)))) + (is (= c (leaf/clip :p a)) "a correction needs no codec of its own")))) + +(deftest a-correction-on-one-cel-travels-with-it + ;; The other half of ownership: a layer on a cel is in that + ;; cel's own frames, so moving the cel moves the correction and + ;; nothing has to say so. + (let [beat (ch/layer :beat [0 2] :offset (ch/framed [7 0])) + doc (update-in (document) [:symbols :main :nodes :b :channels [:xform :pos] :over] + (fnil conj []) beat) + moved (:clip (lane/extend-hold doc :main :a 2 {:extent :grow-symbol}))] + ;; Stated as the difference from the same document without the correction, + ;; so the claim is about WHERE the layer applies and not about arithmetic. + (let [nudge (fn [with without f] + (- (get-in (sample with [f]) [f [:b :mark]]) + (get-in (sample without [f]) [f [:b :mark]])))] + (is (= [7 7 0 0] (mapv #(nudge doc (document) %) [4 5 6 7])) + "B's first two frames, which are lane frames 4 and 5") + (is (= [7 7 0 0] + (mapv #(nudge moved (:clip (lane/extend-hold (document) :main :a 2 + {:extent :grow-symbol})) + %) + [6 7 8 9])) + "and after A's hold grows, B's first two frames, which are now 6 and 7")) + (is (= (get-in doc [:symbols :main :nodes :b :channels]) + (get-in moved [:symbols :main :nodes :b :channels])) + "the layer itself was not touched by the retiming") + (is (empty? (clip/problems moved))))) + +(defn- spans [clip ids] + (mapv #(node/placed-span (get-in clip [:symbols :main :nodes %])) ids)) + +(deftest trimming-narrows-one-cel-and-moves-nothing-else + (let [doc (document) + r (lane/trim doc :main :b :out 6) + after (:clip r)] + (is (= [[0 4] [4 6] [8 12]] (spans after [:a :b :insert]))) + (is (= :b (:selection r))) + (is (= (select-keys (get-in doc [:symbols :main :nodes :b]) [:time :playback :channels :source]) + (select-keys (get-in after [:symbols :main :nodes :b]) [:time :playback :channels :source])) + "only :span changed") + (is (= 12 (get-in after [:symbols :main :frames]))) + (is (empty? (clip/problems after))))) + +(deftest trimming-the-front-of-a-playing-insert-does-not-restart-it + ;; The difference between trimming and slipping. Its own frames are where they + ;; were, so the frames that survive show exactly what they showed. + (let [doc (document) + before (sample doc [10 11]) + after (:clip (lane/trim doc :main :insert :in 10))] + (is (= [10 12] (node/placed-span (get-in after [:symbols :main :nodes :insert])))) + (is (= (:playback (get-in doc [:symbols :main :nodes :insert])) + (:playback (get-in after [:symbols :main :nodes :insert])))) + (is (= before (sample after [10 11])) "the same animation on the frames it kept") + ;; And the frames it gave up show nothing of it. + (is (= #{:plate} (set (keys (get (sample after [9]) 9))))))) + +(deftest trim-refuses-to-lengthen-or-to-land-on-an-edge + (let [doc (document)] + (doseq [[label edge to] [["at its own start" :in 4] + ["at its own end" :out 8] + ["past its end" :out 9] + ["before its start" :in 2] + ["off a whole frame" :out 5.5]]] + (is (:refused (lane/trim doc :main :b edge to)) label)) + (is (:refused (lane/trim doc :main :b :middle 6))) + (is (:refused (lane/trim doc :main :girl :out 6)) "a lane is not a cel"))) + +(deftest moving-an-cel-keeps-its-length-and-its-source-origin + (let [doc (update-in (document) [:symbols :main :nodes] dissoc :b) + r (lane/move doc :main :insert 4) + after (:clip r)] + (is (= [[0 4] [4 8]] (spans after [:a :insert]))) + (is (= :insert (:selection r))) + (is (= (:playback (get-in doc [:symbols :main :nodes :insert])) + (:playback (get-in after [:symbols :main :nodes :insert])))) + ;; It began on source frame 3 at lane 8; it begins on source frame 3 at lane 4. + (is (= (get-in (sample doc [8]) [8 [:insert :mark]]) + (get-in (sample after [4]) [4 [:insert :mark]]))) + (is (empty? (clip/problems after))))) + +(deftest a-move-onto-an-occupied-frame-is-refused-rather-than-rippled + (let [doc (document)] + (is (:refused (lane/move doc :main :insert 6)) "it would overlap B") + (is (:refused (lane/move doc :main :insert 4.5))) + (is (:refused (lane/move doc :main :girl 2))) + ;; Clearing the room first is the composition, and then it goes. + (let [cleared (:clip (lane/blank doc :main :girl [4 8] {}))] + (is (= [[0 4] [4 8]] (spans (:clip (lane/move cleared :main :insert 4)) + [:a :insert])))))) + +(deftest blanking-leaves-a-gap-and-does-not-close-it + (let [doc (document) + r (lane/blank doc :main :girl [5 7] {:id :rest}) + after (:clip r)] + ;; B spanned the range, so it became two cels with a hole between them. + (is (= [[0 4] [4 5] [7 8] [8 12]] (spans after [:a :b :rest :insert]))) + (is (= :rest (:selection r))) + (let [at (sample after [4 5 6 7])] + (is (= #{:plate} (set (keys (at 5)))) "nothing is drawn on a blanked frame") + (is (= #{:plate} (set (keys (at 6))))) + (is (get-in at [4 [:b :mark]])) + (is (get-in at [7 [:rest :mark]]))) + (is (= 12 (get-in after [:symbols :main :frames]))) + (is (empty? (clip/problems after))))) + +(deftest blanking-a-whole-cel-removes-it-and-keeps-its-drawing + (let [doc (document) + after (:clip (lane/blank doc :main :girl [4 8] {}))] + (is (nil? (get-in after [:symbols :main :nodes :b]))) + (is (= [[0 4] [8 12]] (spans after [:a :insert])) "and moves nothing") + (is (= (get-in doc [:symbols :drawing-b]) (get-in after [:symbols :drawing-b])) + "a lane does not own its content") + (is (empty? (clip/problems after))))) + +(deftest blanking-a-range-trims-what-it-only-partly-covers + (let [doc (document) + after (:clip (lane/blank doc :main :girl [3 9] {}))] + (is (= [[0 3] [9 12]] (spans after [:a :insert]))) + (is (nil? (get-in after [:symbols :main :nodes :b]))) + (is (= (get-in (sample doc [9]) [9 [:insert :mark]]) + (get-in (sample after [9]) [9 [:insert :mark]])) + "the insert kept its own frames, so frame 9 shows what it showed") + (is (empty? (clip/problems after))))) + +(deftest overwrite-clears-one-frame-and-does-not-ripple-what-follows + (let [r (lane/overwrite-drawing (document) :main :girl :n :drawing-n 5 + {:extent :keep :remainder-id :right}) + after (:clip r) + nodes (get-in after [:symbols :main :nodes])] + (is (= :n (:selection r))) + (is (= [[0 4] [4 5] [5 6] [6 8] [8 12]] + (mapv #(node/placed-span (get nodes %)) [:a :b :n :right :insert]))) + (is (= :drawing-b (node/source (:right nodes)))) + (is (= 12 (get-in after [:symbols :main :frames]))) + (is (empty? (clip/problems after))))) + +(deftest blank-refuses-what-it-cannot-do-in-one-piece + (let [doc (document)] + (is (re-find #"free ID" (:refused (lane/blank doc :main :girl [5 7] {}))) + "splitting a cel needs an ID for the remainder") + (is (:refused (lane/blank doc :main :girl [5 7] {:id :a})) "and a free one") + (is (:refused (lane/blank doc :main :girl [7 5] {}))) + (is (:refused (lane/blank doc :main :girl [5 5] {}))) + (is (:refused (lane/blank doc :main :girl [5 6.5] {}))) + (is (:refused (lane/blank doc :main :plate [0 2] {}))))) + +(deftest the-shot-length-is-authored-and-emptying-a-lane-does-not-shorten-it + ;; The window and the occupied extent are two facts. A shot with nothing in + ;; the last half is a shot somebody authored that long, and deleting the last + ;; drawing must not quietly shorten the film. + (let [doc (document) + empty-lane (:clip (lane/blank doc :main :girl [0 12] {}))] + (is (empty? (symbol/lane-cels (get-in empty-lane [:symbols :main :nodes]) :girl))) + (is (= 12 (get-in empty-lane [:symbols :main :frames]))) + (is (empty? (clip/problems empty-lane))) + ;; Growing is still the caller's word, and only ever grows. + (is (:refused (lane/append-drawing empty-lane :main :girl :n :drawing-n {:at 20}))) + (is (= 21 (get-in (lane/append-drawing empty-lane :main :girl :n :drawing-n + {:at 20 :extent :grow-symbol}) + [:clip :symbols :main :frames]))) + (is (= 12 (get-in (:clip (lane/trim doc :main :insert :out 9)) + [:symbols :main :frames])) + "and trimming the last cel leaves the window where it was"))) 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 48c7039..0fafeca 100644 --- a/frontend/test/arthur/domain/nest_test.cljs +++ b/frontend/test/arthur/domain/nest_test.cljs @@ -40,7 +40,7 @@ {:keys [sid frame pts]} (nest/drawn-inside c nil :main [u] 16 drawn) c (paint/new-shape c sid :shape frame pts :brow) [op] (filter #(= [u :shape] (:node %)) - ((clip/resolver c nil pal/index-of :main) 16))] + ((clip/resolver c :main nil pal/index-of nil) 16))] (is (= :box sid)) (is (= 6 frame) "frame 16 of main is frame 6 of an instance placed at 10") (is (every? #(< (js/Math.abs %) 1e-9) @@ -64,7 +64,7 @@ (turn :mid v [5 -3] 0.3 1.5) (paint/new-shape :box :shape 4 [0 0 10 0 5 10] :brow)) draw #(take 6 (array-seq (:pts (first (filter (fn [op] (= [u v :shape] (:node op))) - ((clip/resolver % nil pal/index-of :main) 16)))))) + ((clip/resolver % :main nil pal/index-of nil) 16)))))) {:keys [frame matrix time]} (nest/inside c nil :main [u v :shape] 16) out (js/Float64Array. 2) seen (mapcat (fn [[x y]] (vec (array-seq (node/apply-pt! out 0 matrix x y)))) @@ -86,15 +86,15 @@ (deftest a-placed-symbols-sound-is-heard-where-it-is-placed (let [voice {:id :v :kind :audio :source {:footage "f"} :z "a1" :span [10 40] :time {:mode :map :at -10 :rate 1} - :channels {[:audio :gain] (ch/keyed {0 0.0 5 1.0})}} + :channels {[:audio :gain] (ch/keyed {0 0.0 5 1.0} :hold)}} c (-> (clip/blank) (assoc-in [:symbols :talk] {:id :talk :frames 30 :nodes {:v voice}}) (clip/place-symbol nil :main :talk 50 #uuid "00000000-0000-4000-8000-0000000000bb" nil)) [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)] @@ -105,7 +105,7 @@ "What `sid` draws at each of `fs`, without the node paths a move changes: per frame, the sorted marks with their points rounded to a thousandth." [c sid fs] - (let [resolve (clip/resolver c nil pal/index-of sid) + (let [resolve (clip/resolver c sid nil pal/index-of nil) round #(/ (js/Math.round (* 1000 %)) 1000)] (mapv (fn [f] (sort-by str (map (fn [op] @@ -122,7 +122,7 @@ [] (let [tri (fn [id x keyed] {:id id :kind :poly :z "a1" :paint? true :span [4 60] - :channels {[:geom :pts] (ch/keyed (into {} (map (fn [[f dx]] [f [x 10 (+ x dx) 10 x 40]])) keyed)) + :channels {[:geom :pts] (ch/keyed (into {} (map (fn [[f dx]] [f [x 10 (+ x dx) 10 x 40]])) keyed) :hold) [:style :color] (ch/framed :brow)}})] (-> (clip/blank) (assoc-in [:symbols :main :nodes :tri] (tri :tri 100 {4 20 30 40})) @@ -199,7 +199,7 @@ (update-in [:symbols :main :nodes] dissoc :tri) (assoc-in [:symbols :main :nodes a-uuid :time :rate] 2) (assoc-in [:symbols :box :nodes :inner :channels [:geom :pts]] - (ch/keyed {4 [5 10 15 10 5 40] 20 [5 10 45 10 5 40]}))) + (ch/keyed {4 [5 10 15 10 5 40] 20 [5 10 45 10 5 40]} :hold))) {slid :clip :as r} (nest/slide c :main [a-uuid :inner] 6) fs [13 15 18 20]] (is (nil? (:refused r)) (:refused r)) @@ -251,7 +251,7 @@ (is (= [0 8] (:span heard)) "own frames 0-8: it starts on inner's 2 and inner ends on 10") (is (= [7 15] (node/placed-span heard)) "inner starts on 5 of outer") - (is (empty? ((clip/resolver c nil pal/index-of :inner) 3)) + (is (empty? ((clip/resolver c :inner nil pal/index-of nil) 3)) "and it draws nothing") (is (= c (clip/place-sound c :inner {:sound "tone"} "tone.mp3" 40 1 10 (random-uuid))) "nor lands past the end of its symbol") diff --git a/frontend/test/arthur/domain/node_test.cljs b/frontend/test/arthur/domain/node_test.cljs index 55dff81..c7cc882 100644 --- a/frontend/test/arthur/domain/node_test.cljs +++ b/frontend/test/arthur/domain/node_test.cljs @@ -145,14 +145,14 @@ (deftest transform-channels-default-to-the-identity (let [chs (node/channels {:id :x :kind :group})] - (is (= [0.0 0.0] (ch/value-at (get chs [:xform :pos]) 0))) - (is (= [1.0 1.0] (ch/value-at (get chs [:xform :scale]) 0))) - (is (= true (ch/value-at (get chs [:vis]) 0)))) + (is (= [0.0 0.0] (ch/value-at (get chs [:xform :pos]) 0 nil))) + (is (= [1.0 1.0] (ch/value-at (get chs [:xform :scale]) 0 nil))) + (is (= true (ch/value-at (get chs [:vis]) 0 nil)))) (testing "and a node's own channels win" (let [chs (node/channels {:id :x :kind :group :channels {[:xform :pos] (ch/framed [5 5])}})] - (is (= [5 5] (ch/value-at (get chs [:xform :pos]) 0))) - (is (= [1.0 1.0] (ch/value-at (get chs [:xform :scale]) 0)))))) + (is (= [5 5] (ch/value-at (get chs [:xform :pos]) 0 nil))) + (is (= [1.0 1.0] (ch/value-at (get chs [:xform :scale]) 0 nil)))))) (deftest skew-and-anchor-are-in-the-shape-although-nothing-drives-them ;; A decomposition is not extensible after the fact: adding a component later @@ -193,26 +193,26 @@ (deftest keying-a-channel-from-the-inspector (let [n {:id :x :kind :group} a (node/set-channel n [:xform :rot] 3 1.0) - b (node/toggle-key a [:xform :rot] 3) + b (node/toggle-key a [:xform :rot] 3 nil) c (-> b (node/set-channel [:xform :pos] 9 [5 5]) - (node/toggle-key [:xform :pos] 0) + (node/toggle-key [:xform :pos] 0 nil) (node/set-channel [:xform :pos] 10 [10 0])) - rot #(ch/value-at (get (node/channels %1) [:xform :rot]) %2) - pos #(ch/value-at (get (node/channels %1) [:xform :pos]) %2)] + rot #(ch/value-at (get (node/channels %1) [:xform :rot]) %2 nil) + pos #(ch/value-at (get (node/channels %1) [:xform :pos]) %2 nil)] (is (= 1.0 (rot a 50)) "an unkeyed channel is its one value") (is (= {3 1.0} (get-in b [:channels [:xform :rot] :keys])) "the first key is its value here") (is (= [7.5 2.5] (pos c 5)) "an edit on a keyed channel keys it, and keys tween") (is (= 1.0 (rot (node/set-channel b [:xform :rot] 8 2.0) 3)) "without moving the key before it") - (let [d (node/toggle-key b [:xform :rot] 3)] + (let [d (node/toggle-key b [:xform :rot] 3 nil)] (is (not (:animated? (get-in d [:channels [:xform :rot]]))) "the last key off is one value again") (is (= 1.0 (rot d 0)))) - (is (= :hold (get-in (node/toggle-key n [:vis] 0) [:channels [:vis] :interp])) "a boolean holds") + (is (= :hold (get-in (node/toggle-key n [:vis] 0 nil) [:channels [:vis] :interp])) "a boolean holds") (let [h (node/set-segment-interp c [:xform :pos] 0 :hold)] (is (= [5 5] (pos h 5)) "a gap set to hold cuts at the next key") (is (= [10 0] (pos h 10))) (is (= [7.5 2.5] (pos (node/set-segment-interp h [:xform :pos] 0 :linear) 5)) "and back to a tween") (is (= h (node/set-segment-interp h [:xform :pos] 10 :hold)) "the last key has no gap after it") (is (empty? (ch/problems (get-in h [:channels [:xform :pos]]))))) - (let [d (node/toggle-key (node/set-segment-interp c [:xform :pos] 0 :hold) [:xform :pos] 0)] + (let [d (node/toggle-key (node/set-segment-interp c [:xform :pos] 0 :hold) [:xform :pos] 0 nil)] (is (not (contains? (get-in d [:channels [:xform :pos] :segments]) 0)) "taking a key off takes its gap's choice with it")))) diff --git a/frontend/test/arthur/domain/paint_test.cljs b/frontend/test/arthur/domain/paint_test.cljs index 1e019ea..f5d1e5f 100644 --- a/frontend/test/arthur/domain/paint_test.cljs +++ b/frontend/test/arthur/domain/paint_test.cljs @@ -5,6 +5,7 @@ [arthur.domain.leaf :as leaf] [arthur.domain.node :as node] [arthur.domain.paint :as paint] + [arthur.domain.palette :as pal] [arthur.domain.symbol :as symbol])) (defn- geometry [clip] @@ -22,13 +23,13 @@ node/set-segment-interp paint/geometry 9 :linear) mixed (geometry mixed-clip)] (is (= [3 229] (get-in c2 [:symbols :main :nodes :paint-test :span]))) - (is (= a (channel/value-at held 8))) - (is (= 10 (first (channel/value-at held 8)))) - (is (= 22 (first (channel/value-at held 9)))) - (is (= 10 (first (channel/value-at mixed 6))) "the first gap cuts") - (is (= 28 (first (channel/value-at mixed 12))) "the second gap tweens") + (is (= a (channel/value-at held 8 nil))) + (is (= 10 (first (channel/value-at held 8 nil)))) + (is (= 22 (first (channel/value-at held 9 nil)))) + (is (= 10 (first (channel/value-at mixed 6 nil))) "the first gap cuts") + (is (= 28 (first (channel/value-at mixed 12 nil))) "the second gap tweens") (is (empty? (channel/problems mixed))) ;; The demo's root is exposed on 2s. Paint at frame 3 must still appear at 3. (is (some #(= :paint-test (:node %)) - (symbol/eval-frame (get-in c2 [:symbols :main]) 3))) + (symbol/eval-frame (get-in c2 [:symbols :main]) 3 nil pal/index-of nil))) (is (= mixed-clip (leaf/clip :c1 (leaf/leaves :c1 mixed-clip)))))) diff --git a/frontend/test/arthur/domain/project_test.cljs b/frontend/test/arthur/domain/project_test.cljs index aa31428..a4fec3f 100644 --- a/frontend/test/arthur/domain/project_test.cljs +++ b/frontend/test/arthur/domain/project_test.cljs @@ -21,6 +21,7 @@ [arthur.demo.take :as take] [arthur.domain.channel :as ch] [arthur.domain.clip :as clip] + [arthur.domain.palette :as pal] [arthur.domain.project :as project] [arthur.domain.symbol :as symbol] [arthur.flow.freeze :as freeze] @@ -147,7 +148,7 @@ ;; frame rather than hidden, and its partner is not. (let [back (wired :c1 @gappy) drawn (into #{} (map :node) - ((symbol/resolver (face-symbol (:clip back)) (:store back)) 12))] + ((symbol/resolver (face-symbol (:clip back)) (:store back) pal/index-of nil) 12))] (is (not (contains? drawn :eye-r))) (is (contains? drawn :eye-l)) (is (contains? drawn :mouth)))) diff --git a/frontend/test/arthur/domain/symbol_test.cljs b/frontend/test/arthur/domain/symbol_test.cljs index 663ff09..80fb8d6 100644 --- a/frontend/test/arthur/domain/symbol_test.cljs +++ b/frontend/test/arthur/domain/symbol_test.cljs @@ -27,7 +27,7 @@ {:nodes (into {} (map (juxt :id identity)) nodes)}) (defn- ids-at [scene f] - (mapv :node (symbol/eval-frame scene f))) + (mapv :node (symbol/eval-frame scene f nil pal/index-of nil))) (def ^:private pts-of ops/points) @@ -70,9 +70,9 @@ (is (identical? (get-in s [:nodes :b]) (get-in s' [:nodes :b])) "and so is the new one") (is (= [[0 0] [10 0] [10 10]] - (pts-of (first (filter #(= :c (:node %)) (symbol/eval-frame s 0)))))) + (pts-of (first (filter #(= :c (:node %)) (symbol/eval-frame s 0 nil pal/index-of nil)))))) (is (= [[100 0] [110 0] [110 10]] - (pts-of (first (filter #(= :c (:node %)) (symbol/eval-frame s' 0)))))))) + (pts-of (first (filter #(= :c (:node %)) (symbol/eval-frame s' 0 nil pal/index-of nil)))))))) ;; ---- draw order ---- @@ -129,7 +129,7 @@ :channels {[:xform :pos] (ch/framed [100 50]) [:xform :scale] (ch/framed [2 2])}} (poly :p :g "a1" [0 0 10 0 10 10 0 10] :skin-base)) - op (first (symbol/eval-frame s 0))] + op (first (symbol/eval-frame s 0 nil pal/index-of nil))] (is (= [[100 50] [120 50] [120 70] [100 70]] (pts-of op))))) (deftest a-keyed-group-position-moves-its-children-and-holds-between-keys @@ -137,9 +137,9 @@ ;; group whose [:xform :pos] is keyed on four frames. (let [s (sc {:id :g :kind :group :z "a1" :channels {[:xform :pos] - (ch/keyed {0 [0 0], 4 [10 0], 8 [10 10], 12 [0 10]})}} + (ch/keyed {0 [0 0], 4 [10 0], 8 [10 10], 12 [0 10]} :hold)}} (poly :p :g "a1" [0 0 2 0 2 2] :skin-base)) - at #(first (pts-of (first (symbol/eval-frame s %))))] + at #(first (pts-of (first (symbol/eval-frame s % nil pal/index-of nil))))] (is (= [0 0] (at 0))) (is (= [0 0] (at 3)) "held") (is (= [10 0] (at 4))) @@ -154,17 +154,17 @@ ;; odd frames against a mouth cutting on even ones reads as two performances. (let [s (sc {:id :root :kind :group :z "a1" :time {:mode :map :expose 3}} {:id :g :kind :group :parent :root :z "a1" - :channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}} + :channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)) :hold)}} (poly :p :g "a1" [0 0 1 0 1 1] :skin-base)) - x-at #(first (first (pts-of (first (symbol/eval-frame s %)))))] + x-at #(first (first (pts-of (first (symbol/eval-frame s % nil pal/index-of nil)))))] (is (= [0 0 0 3 3 3 6 6 6 9 9 9] (mapv x-at (range 12))))) (testing "and a node may set its own grid, which the model permits deliberately" (let [s (sc {:id :root :kind :group :z "a1" :time {:mode :map :expose 2}} {:id :g :kind :group :parent :root :z "a1" :time {:mode :map :expose 4} - :channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}} + :channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)) :hold)}} (poly :p :g "a1" [0 0 1 0 1 1] :skin-base)) - x-at #(first (first (pts-of (first (symbol/eval-frame s %)))))] + x-at #(first (first (pts-of (first (symbol/eval-frame s % nil pal/index-of nil)))))] (is (= [0 0 0 0 4 4 4 4 8 8 8 8] (mapv x-at (range 12))))))) (deftest offset-is-per-node-which-is-the-entire-point-of-mouth-lead @@ -173,12 +173,12 @@ (let [keys (into {} (map (juxt identity #(vector % 0))) (range 12)) s (sc {:id :root :kind :group :z "a1"} {:id :plate :kind :group :parent :root :z "a1" - :channels {[:xform :pos] (ch/keyed keys)}} + :channels {[:xform :pos] (ch/keyed keys :hold)}} (poly :plate-p :plate "a1" [0 0 1 0 1 1] :skin-base) {:id :mouth :kind :group :parent :root :z "a2" :time {:mode :map :offset 2} - :channels {[:xform :pos] (ch/keyed keys)}} + :channels {[:xform :pos] (ch/keyed keys :hold)}} (poly :mouth-p :mouth "a1" [0 0 1 0 1 1] :mouth-dark)) - x-of (fn [f id] (->> (symbol/eval-frame s f) + x-of (fn [f id] (->> (symbol/eval-frame s f nil pal/index-of nil) (filter #(= id (:node %))) first pts-of first first))] (is (= [0 1 2 3] (mapv #(x-of % :plate-p) (range 4)))) (is (= [2 3 4 5] (mapv #(x-of % :mouth-p) (range 4))) "the mouth reads ahead"))) @@ -194,12 +194,12 @@ {:span [2 5] :channels {[:geom :pts] (ch/framed [0 0 1 0 1 1]) [:style :color] (ch/framed :brow) - [:vis] (ch/keyed {0 true, 3 false, 4 true})}}))] + [:vis] (ch/keyed {0 true, 3 false, 4 true} :hold)}}))] (is (= [[] [] [:p] [] [:p] [] []] (mapv #(ids-at s %) (range 7)))))) (deftest a-hidden-group-takes-its-children-with-it (let [s (sc {:id :g :kind :group :z "a1" - :channels {[:vis] (ch/keyed {0 true, 2 false})}} + :channels {[:vis] (ch/keyed {0 true, 2 false} :hold)}} (poly :p :g "a1" [0 0 1 0 1 1] :brow))] (is (= [:p] (ids-at s 0))) (is (= [] (ids-at s 2))))) @@ -221,11 +221,11 @@ :dense {:store "pts" :offset 0 :stride 6 :frames 2}} [:style :color] (ch/framed :mouth-dark)}} (poly :teeth :m "a2" [0 0 1 0 1 1] :teeth))] - (is (= [:child] (mapv :node (symbol/eval-frame absent-pos 0 store)))) - (is (= [] (mapv :node (symbol/eval-frame absent-pos 1 store))) + (is (= [:child] (mapv :node (symbol/eval-frame absent-pos 0 store pal/index-of nil)))) + (is (= [] (mapv :node (symbol/eval-frame absent-pos 1 store pal/index-of nil))) "an absent transform gives the children nowhere to be") - (is (= [:m :teeth] (mapv :node (symbol/eval-frame absent-pts 0 store)))) - (is (= [:teeth] (mapv :node (symbol/eval-frame absent-pts 1 store))) + (is (= [:m :teeth] (mapv :node (symbol/eval-frame absent-pts 0 store pal/index-of nil)))) + (is (= [:teeth] (mapv :node (symbol/eval-frame absent-pts 1 store pal/index-of nil))) "an absent outline removes only itself"))) ;; ---- stencils ---- @@ -239,7 +239,7 @@ {:id :iris :kind :disc :parent :root :stencil :sclera :z "a2" :channels {[:geom :radius] (ch/framed 4) [:style :color] (ch/framed :iris)}}) - ops (symbol/eval-frame s 0)] + ops (symbol/eval-frame s 0 nil pal/index-of nil)] (is (= [:sclera :iris] (mapv :node ops))) (is (= (:eye-white pal/index-of) (:stencil (second ops)))))) @@ -250,7 +250,7 @@ (poly :sclera :root "a1" [0 0 10 0 10 10] :eye-white {:channels {[:geom :pts] (ch/framed [0 0 10 0 10 10]) [:style :color] (ch/framed :eye-white) - [:vis] (ch/keyed {0 true, 1 false})}}) + [:vis] (ch/keyed {0 true, 1 false} :hold)}}) {:id :iris :kind :disc :parent :root :stencil :sclera :z "a2" :channels {[:geom :radius] (ch/framed 4) [:style :color] (ch/framed :iris)}})] @@ -266,7 +266,7 @@ :channels {[:geom :radius] (ch/framed 3) [:style :color] (ch/framed :iris)}} {:id :r :kind :rect :parent :g :z "a2" :channels {[:geom :size] (ch/framed 1.7) [:style :color] (ch/framed :pupil)}}) - [d r] (symbol/eval-frame s 0)] + [d r] (symbol/eval-frame s 0 nil pal/index-of nil)] (is (= [50 60 6] [(:cx d) (:cy d) (:r d)])) (is (= 3.4 (:size r))))) @@ -291,7 +291,7 @@ (deftest the-resolver-reuses-one-buffer-per-node ;; At 30fps per-frame allocation is the only thing that will make this stutter, ;; and fixed topology is what makes the buffer size knowable at all. - (let [res (symbol/resolver demo/main) + (let [res (symbol/resolver demo/main nil pal/index-of nil) buf-of (fn [f id] (->> (res f) (filter #(= id (:node %))) first :pts))] (is (identical? (buf-of 0 :card) (buf-of 30 :card))))) @@ -308,15 +308,15 @@ ;; The mistake this split makes easy: both are maps with an :id, and the wrong ;; one resolves to no ops rather than to an error. (is (thrown-with-msg? ExceptionInfo #"not a symbol" - (symbol/resolver demo/clip))) + (symbol/resolver demo/clip nil pal/index-of nil))) (is (thrown-with-msg? ExceptionInfo #"not a symbol" - (symbol/eval-frame demo/clip 0))))) + (symbol/eval-frame demo/clip 0 nil pal/index-of nil))))) (deftest the-hand-written-clip-renders-and-moves ;; port-plan step 2's done condition, as an assertion rather than a look: the ;; scene rasterises, it writes only palette indices, and the pixels are not the ;; same on every frame. - (let [res (symbol/resolver demo/main) + (let [res (symbol/resolver demo/main nil pal/index-of nil) render (fn [f] (let [r (raster/make (:width demo/clip) (:height demo/clip))] (raster/clear! r (:bg pal/index-of)) @@ -334,7 +334,7 @@ ;; Exposure 2 on the clip root, inherited, so odd frames are identical to the ;; even frame before them. If this fails, exposure is being applied somewhere ;; other than the frame the channels are sampled at. - (let [res (symbol/resolver demo/main) + (let [res (symbol/resolver demo/main nil pal/index-of nil) render (fn [f] (let [r (raster/make (:width demo/clip) (:height demo/clip))] (raster/clear! r (:bg pal/index-of)) @@ -351,7 +351,7 @@ (deftest the-hand-written-clip-keeps-the-iris-and-pupil-inside-the-card ;; The stencil chain, on real pixels: the iris is clipped by the card and the ;; pupil by the iris, and neither is expressed anywhere as a chain. - (let [res (symbol/resolver demo/main)] + (let [res (symbol/resolver demo/main nil pal/index-of nil)] (doseq [f (range 0 demo/frames 4)] (let [before (raster/make (:width demo/clip) (:height demo/clip)) after (raster/make (:width demo/clip) (:height demo/clip)) @@ -384,9 +384,9 @@ (poly :p :root "a1" [0 0 10 0 10 10] :skin-base)) day {:skin-base 1} night {:skin-base 17}] - (is (= 1 (:color (first (symbol/eval-frame s 0 nil day))))) - (is (= 17 (:color (first (symbol/eval-frame s 0 nil night))))) - (is (= 17 (:color (first ((symbol/resolver s nil night) 0)))) + (is (= 1 (:color (first (symbol/eval-frame s 0 nil day nil))))) + (is (= 17 (:color (first (symbol/eval-frame s 0 nil night nil))))) + (is (= 17 (:color (first ((symbol/resolver s nil night nil) 0)))) "and the playback path agrees"))) (deftest a-tone-the-ramp-does-not-define-is-loudly-wrong @@ -394,7 +394,7 @@ ;; authored data and should be impossible to miss. (let [s (sc {:id :root :kind :group :z "a1"} (poly :p :root "a1" [0 0 10 0 10 10] :skin-base))] - (is (= 255 (:color (first (symbol/eval-frame s 0 nil {}))))))) + (is (= 255 (:color (first (symbol/eval-frame s 0 nil {} nil))))))) (deftest partitioning-the-index-space-stops-two-palettes-colliding-on-a-stencil ;; A stencil is a colour key, so two nodes sharing a tone share a stencil — @@ -407,6 +407,6 @@ [:style :color] (ch/framed :iris)}}) ;; :night's tones sit above :day's in one concatenated space night {:eye-white 14 :iris 15} - ops (symbol/eval-frame s 0 nil night)] + ops (symbol/eval-frame s 0 nil night nil)] (is (= 14 (:stencil (second ops))) "the stencil resolves to the index the stencil node actually drew in"))) diff --git a/frontend/test/arthur/domain/trace_test.cljs b/frontend/test/arthur/domain/trace_test.cljs index 9cc2d9f..00cf994 100644 --- a/frontend/test/arthur/domain/trace_test.cljs +++ b/frontend/test/arthur/domain/trace_test.cljs @@ -52,7 +52,7 @@ (defn- photo-at "The photo matrix of face-1 alone at frame `f`, the still being 1000px tall." [c f] - (let [r (symbol/resolver (clip/symbol c :face-1) @store pal/index-of) + (let [r (symbol/resolver (clip/symbol c :face-1) @store pal/index-of nil) h (head c)] (r f) (vec (array-seq (trace/photo-matrix (symbol/world-of r :head) h @store @@ -70,40 +70,49 @@ "a continuous head carries the held photo along with it"))) (defn- wrapped - "Face-1's take placed, moved, inside a symbol :wrap, with `underlays` on the - instances named." - [{:keys [outer inner]}] - (-> @frozen - (assoc-in [:symbols :wrap] {:id :wrap :frames 200 - :nodes {:m (cond-> {:id :m :kind :instance :of :main :z "a0" - :channels {[:xform :pos] {:animated? false - :value [30 -10]}}} - outer (assoc :underlay outer))}}) - (cond-> inner (assoc-in [:symbols :main :nodes :face-1 :underlay] inner)))) + "Face-1's take placed, moved, inside a symbol :wrap." + [] + (assoc-in @frozen [:symbols :wrap] + {:id :wrap :frames 200 + :nodes {:m {:id :m :kind :instance :z "a0" + :source {:symbol :main} + :channels {[:xform :pos] {:animated? false :value [30 -10]}}}}})) -(deftest an-underlay-covers-the-faces-below-it-and-the-nearest-decides - (is (= [{:path [:m :face-1] :face :face-1 :opacity 0.3}] - (trace/shown (wrapped {:outer {:on? true :opacity 0.3}}) :wrap)) - "switched on at the take, its face shows") - (is (= [] (trace/shown (wrapped {:outer {:on? true} :inner {:on? false}}) :wrap)) - "and the face can still be switched off inside it") - (is (= [{:path [:m :face-1] :face :face-1 :opacity 0.8}] - (trace/shown (wrapped {:inner {:on? true :opacity 0.8}}) :wrap))) - (let [c (wrapped {:outer {:on? true :opacity 0.3}})] - (is (= {:on? true :opacity 0.3 :own? false} (trace/underlay-at c :wrap [:m :face-1]))) - (is (= {:on? true :opacity 0.3 :own? true} (trace/underlay-at c :wrap [:m]))) - (is (nil? (trace/underlay-at @frozen :main [:face-1]))))) +(deftest a-face-switched-on-shows-wherever-it-is-placed + (let [c (wrapped)] + (is (= [{:path [:m :face-1] :in :main :face :face-1}] (trace/shown c :wrap #{:face-1})) + "a face inside a take inside a symbol, at the row path it is at") + (is (= [] (trace/shown c :wrap #{})) "and nothing when it is switched off") + (is (= [{:path [:face-1] :in :main :face :face-1}] (trace/shown c :main #{:face-1})) + "the same switch, one symbol down") + (is (= [{:path [] :in :face-1 :face :face-1}] (trace/shown c :face-1 #{:face-1})) + "the face open in its own tab is at no path at all — it IS the stage") + (is (= [] (trace/shown c :wrap #{:main})) + "a symbol that is not a face has no footage of its own to show"))) + +(deftest the-faces-that-can-be-traced-are-listed-once-each + (is (= [:face-1] (trace/traceable-faces (wrapped) :wrap))) + (is (= [:face-1] (trace/traceable-faces @frozen :main)) "the take it was frozen into") + (is (= [:face-1] (trace/traceable-faces @frozen :face-1)) "itself, open to draw over")) + +(deftest a-face-opened-to-be-drawn-over-starts-with-its-footage-showing + (let [c (wrapped)] + (is (= #{:face-1} (trace/showing-for c :face-1 #{})) "the face's own tab") + (is (= #{} (trace/showing-for c :main #{})) + "and not the take it is placed in, which is the picture itself") + (is (= #{:face-1} (trace/showing-for c :main #{:face-1})) + "one already switched on stays on wherever you go"))) (deftest a-take-lists-the-faces-in-it (is (= [{:path [:face-1] :in :main :face :face-1}] (trace/faces @frozen :main))) - (is (= [{:path [:m :face-1] :in :main :face :face-1}] (trace/faces (wrapped {}) :wrap))) + (is (= [{:path [:m :face-1] :in :main :face :face-1}] (trace/faces (wrapped) :wrap))) (is (= [] (trace/faces @frozen :face-1)))) (deftest the-resolver-says-where-a-nested-head-went-on-its-last-frame ;; The same answer as `nest/placement`, which walks and resolves the path all ;; over again — the resolver has it already, from drawing the frame. - (let [c (wrapped {}) - r (clip/resolver c @store pal/index-of :wrap) + (let [c (wrapped) + r (clip/resolver c :wrap @store pal/index-of nil) path [:m :face-1 :head]] (doseq [f [0 17 60]] (r f) diff --git a/frontend/test/arthur/domain/wire_test.cljs b/frontend/test/arthur/domain/wire_test.cljs index 3e3b40b..97d4ffc 100644 --- a/frontend/test/arthur/domain/wire_test.cljs +++ b/frontend/test/arthur/domain/wire_test.cljs @@ -13,10 +13,10 @@ ;; THE reason this is transit. Keys are a map by FRAME, and `{"0" v}` is not ;; `{0 v}`: `value-at` would find no key at frame 0 and the part would hold its ;; first pose forever, on a document that looked fine. - (let [c (ch/keyed {0 true 4 false 12 true})] + (let [c (ch/keyed {0 true 4 false 12 true} :hold)] (is (= c (round c))) (is (every? number? (keys (:keys (round c))))) - (is (= true (ch/value-at (round c) 13))))) + (is (= true (ch/value-at (round c) 13 nil))))) (deftest an-id-comes-back-a-keyword (is (= {:id :mouth-in :kind :poly :parent :mouth :z "a2"} @@ -35,7 +35,7 @@ ;; Transit loses sortedness, which is why `domain/channel` says keys are a PLAIN ;; map and builds the sorted index at read time. Asserted so that nobody ;; "improves" the codec into a sorted map that works until the first round trip. - (let [c (round (ch/keyed (into {} (map (juxt identity str)) (range 20))))] + (let [c (round (ch/keyed (into {} (map (juxt identity str)) (range 20)) :hold))] (is (map? (:keys c))) (is (not (sorted? (:keys c)))) (is (= (vec (range 20)) (ch/frames c))))) @@ -50,7 +50,7 @@ ;; turned `[]` into nil or into `[nil]` would either lose the field or refuse to ;; play the document back. (is (= {:over []} (round {:over []}))) - (is (= [] (:over (round (ch/keyed {0 1})))))) + (is (= [] (:over (round (ch/keyed {0 1} :hold)))))) (deftest a-whole-leaf-map-round-trips-through-parsed-json ;; What a save actually does: transit, then parsed so the column holds JSON. 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/lane_test.cljs b/frontend/test/arthur/events/lane_test.cljs new file mode 100644 index 0000000..8cc3a63 --- /dev/null +++ b/frontend/test/arthur/events/lane_test.cljs @@ -0,0 +1,67 @@ +(ns arthur.events.lane-test + (:require [cljs.test :refer [deftest is]] + [arthur.domain.lane-test :as fixture] + [arthur.domain.lane :as lane] + [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-cels-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 the-cel-sheet-is-the-same-cels-with-the-axes-turned + (let [doc (fixture/document) + column (first (timeline/cel-sheet doc :main 12)) + cells (:cells column)] + (is (= :girl (:id column))) + (is (= [:a :b :insert] (mapv #(get-in cells [% :cel :id]) [0 4 8]))) + (is (= [[:node :main :a [:a]] + [:node :main :b [:b]] + [:node :main :insert [:insert]]] + (mapv #(get-in cells [% :cel :select]) [0 4 8]))) + (is (= (mapv :select (:cels (first (filter :cels (timeline/rows doc :main #{}))))) + (mapv #(get-in cells [% :cel :select]) [0 4 8]))))) + +(deftest a-nested-selection-converts-the-open-playhead-to-its-owning-symbol + (let [doc (assoc-in (fixture/document) [:symbols :outer] + {:id :outer :frames 30 + :nodes {:take {:id :take :kind :instance :z "a" + :time {:at 10 :rate 1} :span [0 12] + :source {:symbol :main} + :playback {:in 0 :speed 1 :end :stop}}}})] + (is (= 2 (ui/selection-frame doc nil :outer + [:node :main :a [:take :a]] 12))) + (is (= 12 (ui/selection-frame doc nil :main + [:node :main :a [:a]] 12))))) + +(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-lane-command db :main + (lane/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 :lane-retry]))) + (let [r1 (lane/extend-hold doc :main :a 1 {:extent :grow-symbol}) + db1 (ui/apply-lane-command db :main r1 nil) + r2 (lane/extend-hold (:clip r1) :main :a 1 {:extent :grow-symbol}) + db2 (ui/apply-lane-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/eye_occlusion_test.cljs b/frontend/test/arthur/flow/eye_occlusion_test.cljs index 56cc854..16791ee 100644 --- a/frontend/test/arthur/flow/eye_occlusion_test.cljs +++ b/frontend/test/arthur/flow/eye_occlusion_test.cljs @@ -59,7 +59,7 @@ (is (not (ch/nothing? (sample :eye-l f)))) (is (not (ch/nothing? (sample :mouth f))))) (let [drawn (into #{} (map :node) - ((symbol/resolver (clip/symbol clip :face-1) store pal/index-of) 11))] + ((symbol/resolver (clip/symbol clip :face-1) store pal/index-of nil) 11))] (is (not (contains? drawn :eye-r))) (is (not (contains? drawn :iris-r))) (is (contains? drawn :eye-l)) diff --git a/frontend/test/arthur/flow/freeze_test.cljs b/frontend/test/arthur/flow/freeze_test.cljs index c60c1ec..1fe8139 100644 --- a/frontend/test/arthur/flow/freeze_test.cljs +++ b/frontend/test/arthur/flow/freeze_test.cljs @@ -13,9 +13,11 @@ [arthur.domain.channel :as ch] [arthur.domain.clip :as clip] [arthur.domain.geom :as geom] + [arthur.domain.gesture :as gesture] [arthur.domain.leaf :as leaf] [arthur.domain.node :as node] [arthur.domain.palette :as pal] + [arthur.domain.pick :as pick] [arthur.domain.raster :as raster] [arthur.domain.ring :as ring] [arthur.domain.symbol :as symbol] @@ -54,7 +56,7 @@ (defn- ops-at "Ops for one frame of a TIMELINE." [sym f] - ((symbol/resolver sym @store pal/index-of) f)) + ((symbol/resolver sym @store pal/index-of nil) f)) (defn- render "One frame of a CLIP into a byte buffer. The stage's size comes off the clip and @@ -62,7 +64,7 @@ [c f] (let [r (raster/make (:width c) (:height c))] (raster/clear! r (get pal/index-of :bg)) - (raster/draw-ops! r ((clip/resolver c @store pal/index-of :main) f)) + (raster/draw-ops! r ((clip/resolver c :main @store pal/index-of nil) f)) (vec (array-seq (:buf r))))) (defn- drawn @@ -85,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))) @@ -193,7 +195,7 @@ ;; checking arithmetic against itself; this checks `node/local!`, `node/world!` ;; and `emit` as well. (let [c (freeze/head-mode {} @frozen) - res (clip/resolver c @store pal/index-of :main) + res (clip/resolver c :main @store pal/index-of nil) k (first (:value (chan :face [:xform :scale]))) anc (:value (chan :face [:xform :anchor])) pos (:value (chan :face [:xform :pos])) @@ -247,14 +249,11 @@ "the trace survives the document round trip"))) (deftest trace-keys-hold-the-whole-measured-transform - (let [free (symbol/resolver (face-symbol (freeze/head-mode {} @frozen)) - @store pal/index-of) + (let [free (symbol/resolver (face-symbol (freeze/head-mode {} @frozen)) @store pal/index-of nil) held (symbol/resolver (face-symbol - (freeze/head-mode {:trace {:origin :keys :frames [12 88]}} @frozen)) - @store pal/index-of) + (freeze/head-mode {:trace {:origin :keys :frames [12 88]}} @frozen)) @store pal/index-of nil) start (symbol/resolver (face-symbol - (freeze/head-mode {:trace {:origin :start :frames [12 88]}} @frozen)) - @store pal/index-of) + (freeze/head-mode {:trace {:origin :start :frames [12 88]}} @frozen)) @store pal/index-of nil) world (fn [resolver frame] (resolver frame) (vec (array-seq (symbol/world-of resolver :head))))] @@ -309,6 +308,83 @@ (is (= :framed (ch/describe c)) (str path " is not framed")) (is (nil? (:generated c)) (str path " claims provenance"))))) +(deftest a-part-has-a-pivot-exactly-when-a-hand-can-use-one + ;; The face's anchor was always the head's centre — the test below — and the + ;; parts underneath it had none at all, so each one turned and scaled about ITS + ;; OWN origin, which is the top-left corner of the FOOTAGE. On a 320x200 stage + ;; the mouth's pivot sat at (-234, -395): off the stage by more than a stage, + ;; so a corner drag slid the mouth about instead of resizing it. + ;; + ;; BY BICONDITIONAL, over every node the freeze makes, rather than against a + ;; list of the ones that happen to have geometry today. The rule `pivoted` goes + ;; by is `node/measured?` — the one `gesture/refusal` refuses a hand edit by — + ;; so the two have to agree exactly: a pivot is written where a hand could use + ;; it and nowhere else. A brow has a dense `[:xform :pos]` and so gets none, + ;; which is not an exception to the rule, it is the rule. + (let [c @clip*] + (doseq [sid [:main :face-1] + id (keys (get-in c [:symbols sid :nodes])) + ;; `:face` authors its own in `face-placement`, upstream of this. + :when (not= [:main :face] [sid id])] + (let [n (get-in c [:symbols sid :nodes id]) + frames (range (get-in c [:symbols sid :frames])) + anchor (:value (get-in n [:channels [:xform :anchor]])) + usable (and (not (node/measured? n)) + (some? (pick/pivot c @store n frames)))] + (is (= usable (some? anchor)) + (str sid "/" id " has a pivot: " (some? anchor) + ", but a hand can use one: " usable)) + (is (= (nil? (gesture/refusal n)) (not (node/measured? n))) + (str sid "/" id ": `refusal` and `measured?` disagree")) + (when anchor + (let [bounds (pick/bounds-of c @store n) + [x0 y0 x1 y1] (reduce #(let [k (bounds %2)] + (cond (nil? %1) k (nil? k) %1 + :else (mapv (fn [op i] (op (nth %1 i) (nth k i))) + [min min max max] (range 4)))) + nil frames)] + (is (and (<= x0 (nth anchor 0) x1) (<= y0 (nth anchor 1) y1)) + (str sid "/" id "'s pivot " (pr-str anchor) " is outside what it draws, " + (pr-str [x0 y0 x1 y1]))))))))) + +(deftest the-head-keeps-no-pivot-of-its-own + ;; The case that makes `measured?` the right predicate rather than "draws + ;; nothing": `:head` carries the measured similarity, so its scale is nowhere + ;; near 1 and an anchor on it would NOT cancel out of `node/local!` — it would + ;; move the whole face. It is skipped for that reason, and would still be + ;; skipped if it were ever given geometry. + (is (node/measured? (node :head))) + (is (nil? (get-in (node :head) [:channels [:xform :anchor]])))) + +(deftest giving-every-part-its-pivot-moves-nothing-on-screen + ;; The claim `pivoted`'s docstring makes, asserted in pixels rather than + ;; trusted: rotation and scale are the identity on a node a freeze has just + ;; made, and at the identity the anchor cancels out of `node/local!`. So the + ;; pass decides where a part PIVOTS and nothing else — if it ever renders + ;; differently, it has been applied to a node whose transform is not the + ;; identity, which is the one way it could go wrong. + (let [c @clip* + ;; Every node the freeze makes EXCEPT `:face`, whose anchor + ;; `face-placement` authors — which is the set `pivoted` writes. + every (for [sid [:main :face-1] + id (keys (get-in c [:symbols sid :nodes])) + :when (and (not= [:main :face] [sid id]) + (seq (get-in c [:symbols sid :nodes id :channels])))] + [sid id]) + anchors #(into {} (for [[sid id] every] + [[sid id] (get-in % [:symbols sid :nodes id + :channels [:xform :anchor]])])) + bare (reduce (fn [c [sid id]] + (update-in c [:symbols sid :nodes id :channels] + dissoc [:xform :anchor])) + c every) + again (freeze/pivoted bare @store)] + (is (every? nil? (vals (anchors bare))) "stripped") + (is (= (anchors c) (anchors again)) "the pass puts back exactly what the freeze wrote") + (doseq [f (range 0 take/frames 17)] + (is (= (render bare f) (render again f)) + (str "frame " f " draws differently once every part has a pivot"))))) + (deftest the-face-puts-the-head-s-centre-where-it-says-it-does ;; anchor + pos is where the anchor lands in the parent, which is what makes ;; `:anchor` the registration point: scale and rotation happen about the head's @@ -354,7 +430,7 @@ peak (reduce max ap) want (mapv #(>= (/ % peak) 0.12) ap)] (is (= :keyed (ch/describe c))) - (is (= want (mapv #(ch/value-at c %) (range take/frames))) + (is (= want (mapv #(ch/value-at c % nil) (range take/frames))) "the held keys do not reproduce the threshold") ;; The reason it is keyed: a threshold crossing is a handful of transitions, ;; hold is the default, and keys are the shape a human can correct. A dense @@ -432,7 +508,7 @@ (is (not (ch/nothing? (at :eye-r 60)))) (is (not (ch/nothing? (at :eye-l 50)))) (is (not (ch/nothing? (at :mouth 50)))) - (let [drawn-nodes (into #{} (map :node) ((symbol/resolver sym (:store c) pal/index-of) 50))] + (let [drawn-nodes (into #{} (map :node) ((symbol/resolver sym (:store c) pal/index-of nil) 50))] (is (not (contains? drawn-nodes :eye-r))) (is (contains? drawn-nodes :eye-l)) (is (contains? drawn-nodes :mouth))) @@ -507,7 +583,7 @@ ;; Hoisted: the resolver caches its order and reuses its buffers, so the ;; node ids come out before the next frame is asked for. nodes-at (fn [c] - (let [r (symbol/resolver (face-symbol (:clip c)) (:store c) pal/index-of)] + (let [r (symbol/resolver (face-symbol (:clip c)) (:store c) pal/index-of nil)] (fn [f] (into #{} (map :node) (r f))))) ref-at (nodes-at ref) occ-at (nodes-at occ) @@ -544,7 +620,7 @@ c (freeze/clip (assoc take/params :name "gappy") {:face-1 (assoc @take/measured :detected det)}) sym (face-symbol (:clip c)) - res (symbol/resolver sym (:store c) pal/index-of)] + res (symbol/resolver sym (:store c) pal/index-of nil)] (doseq [f [39 40 50 59 60]] (let [ops (res f)] (if (contains? gap f) @@ -553,9 +629,9 @@ ;; And it is the MASK doing it, not a hidden flag: `[:vis]` on :mouth-in is ;; unchanged across the gap, because hiding and absence are different ;; questions with different answers. - (is (= (mapv #(ch/value-at (get-in (:nodes sym) [:mouth-in :channels [:vis]]) %) + (is (= (mapv #(ch/value-at (get-in (:nodes sym) [:mouth-in :channels [:vis]]) % nil) (range take/frames)) - (mapv #(ch/value-at (chan :mouth-in [:vis]) %) (range take/frames)))))) + (mapv #(ch/value-at (chan :mouth-in [:vis]) % nil) (range take/frames)))))) ;; --------------------------------------------------------------------------- ;; the rings are still rings @@ -603,7 +679,7 @@ shot (fn [f] (let [r (raster/make W H) mouth (filter #(= [:face-1 :mouth] (:node %)) - ((clip/resolver locked @store pal/index-of :main) f))] + ((clip/resolver locked :main @store pal/index-of nil) f))] (raster/clear! r (get pal/index-of :bg)) (raster/draw-ops! r mouth) (vec (array-seq (:buf r))))) diff --git a/frontend/test/arthur/flow/multi_face_test.cljs b/frontend/test/arthur/flow/multi_face_test.cljs index e545aeb..7099adb 100644 --- a/frontend/test/arthur/flow/multi_face_test.cljs +++ b/frontend/test/arthur/flow/multi_face_test.cljs @@ -35,7 +35,7 @@ (defn channel [entry subject node path] (get-in entry [:clip :symbols subject :nodes node :channels path])) -(defn snapshot [c store f] (ops/snapshot ((clip/resolver c store pal/index-of :main) f))) +(defn snapshot [c store f] (ops/snapshot ((clip/resolver c :main store pal/index-of nil) f))) (defn by-node [c store f] (into {} (map (juxt :node identity)) (snapshot c store f))) (deftest subjects-share-local-names-without-sharing-blocks @@ -178,8 +178,8 @@ (merge (dissoc (:nodes (clip/symbol clip :main)) :face-1) (assoc-in (get-in clip [:symbols :face-1 :nodes]) [:head :parent] :face))) - nested (clip/resolver clip store pal/index-of :main) - reference (clip/resolver flat store pal/index-of :main)] + nested (clip/resolver clip :main store pal/index-of nil) + reference (clip/resolver flat :main store pal/index-of nil)] (doseq [f [0 1 7 20 39]] (let [a (ops/snapshot (nested f)) b (ops/snapshot (reference f))] (is (= (mapv (comp second :node) a) (mapv :node b))) diff --git a/frontend/test/arthur/flow/regenerate_test.cljs b/frontend/test/arthur/flow/regenerate_test.cljs index 8fa5bbb..e0b7a0b 100644 --- a/frontend/test/arthur/flow/regenerate_test.cljs +++ b/frontend/test/arthur/flow/regenerate_test.cljs @@ -2,6 +2,7 @@ (:require [cljs.test :refer [deftest is testing]] [clojure.walk :as walk] [arthur.demo.stage :as stage] + [arthur.domain.channel :as ch] [arthur.domain.clip :as clip] [arthur.domain.params :as params] [arthur.domain.project :as project] @@ -256,3 +257,98 @@ (doseq [[_ n] (filter (comp #{:audio} :kind val) nodes)] (is (contains? nodes (:linked-to n)) (str "the voice " (:id n) " still links to a node that is there"))))) + +;; ---- corrections survive the thing they are corrections to ---- + +(defn- corrected + "Put one offset correction on a node's channel, as a hand edit would." + [entry node path values] + (update-in entry [:clip :symbols :face-1 :nodes node :channels path :over] + (fnil conj []) (ch/layer :by-hand [2 6] :offset values))) + +(deftest regenerating-replaces-the-base-and-keeps-the-hand-correction + ;; The loop the whole layer design exists for: generate motion, correct it by + ;; hand, turn the generator's knob, keep the correction. + (let [width (count (:keys (channel @initial :iris-r [:xform :pos]))) + before (corrected @initial :iris-r [:xform :pos] (ch/framed [3 -3])) + after (regenerate/change before + {:scope :feature :id :face-1/eye-r :knob :gaze-gain :value 2}) + base (fn [entry] (dissoc (channel entry :iris-r [:xform :pos]) :over))] + (is (not= (base before) (base after)) "the base was regenerated") + (is (= (base (regenerate/change @initial + {:scope :feature :id :face-1/eye-r :knob :gaze-gain :value 2})) + (base after)) + "and regenerated to exactly what it would have been without the correction") + (is (= [(ch/layer :by-hand [2 6] :offset (ch/framed [3 -3]))] + (:over (channel after :iris-r [:xform :pos]))) + "while the correction came across untouched, and unconflicted") + (is (empty? (ch/conflicts (channel after :iris-r [:xform :pos])))) + (is (empty? (clip/problems (:clip after)))) + (is (= width (count (:keys (channel after :iris-r [:xform :pos])))) + "sanity: this channel is keyed, so the correction rides a keyed base"))) + +(deftest a-correction-does-not-stop-the-head-following-its-measurement + ;; `regenerate-head` leaves the authored channels alone once somebody has + ;; PLACED the head by hand — but a correction is not a placement. Comparing + ;; the bases is what keeps the first correction from freezing the part it was + ;; made to adjust. + (let [plain (regenerate/change @initial + {:scope :subject :id :face-1 :knob :anchor-avg :value 4}) + path [:clip :symbols :face-1 :nodes :head] + prop (first (keys (get-in @initial (conj path :measured)))) + shape (ch/value-shape (get-in @initial (conj path :channels prop))) + nudge (ch/layer :by-hand [2 6] :offset + (ch/framed (if (= :scalar shape) 1 (vec (repeat shape 0.5))))) + before (update-in @initial (conj path :channels prop :over) (fnil conj []) nudge) + after (regenerate/change before + {:scope :subject :id :face-1 :knob :anchor-avg :value 4}) + base (fn [entry] (dissoc (get-in entry (conj path :channels prop)) :over))] + (is (= (base plain) (base after)) + "the head's base followed the re-measurement, correction and all") + (is (= [nudge] (:over (get-in after (conj path :channels prop)))) + "and the correction is the one that was made, unmarked") + (is (empty? (ch/conflicts (get-in after (conj path :channels prop))))) + (is (empty? (clip/problems (:clip after)))))) + +(deftest a-regeneration-that-outgrows-a-correction-records-the-conflict + ;; `:verts` is the mouth's vertex count, so turning it IS the topology change + ;; the lane model names. A geometry correction is a row of components, and a + ;; base with a different number of them cannot take it. Marked, not dropped: + ;; the hand work stays in the document for a person to move, and the picture + ;; meanwhile is the base. + (let [path [:geom :pts] + fitted (fn [entry] + (ch/layer :by-hand [2 6] :offset + (ch/framed (vec (repeat (ch/value-shape (channel entry :mouth path)) + 0.5))))) + with (fn [layer] (update-in @initial + [:clip :symbols :face-1 :nodes :mouth :channels path :over] + (fnil conj []) layer)) + before (with (fitted @initial)) + after (regenerate/change before + {:scope :feature :id :face-1/mouth :knob :verts :value 10}) + layer (first (:over (channel after :mouth path)))] + (is (not= (ch/value-shape (channel @initial :mouth path)) + (ch/value-shape (channel after :mouth path))) + "the mouth really does have a different number of points now") + (is (= :by-hand (:id layer)) "the correction is still in the document") + (is (re-find #"different shape" (:conflict layer))) + (is (= [:by-hand] (mapv :id (ch/conflicts (channel after :mouth path))))) + (is (empty? (clip/problems (:clip after))) + "a recorded conflict does not make the document unloadable") + (is (= (dissoc (channel (regenerate/change @initial + {:scope :feature :id :face-1/mouth :knob :verts :value 10}) + :mouth path) + :over) + (dissoc (channel after :mouth path) :over)) + "and the base is what it would have been with no correction at all") + ;; And the document says so once, for a view to offer. + (is (= [{:id :by-hand :symbol :face-1 :node :mouth :channel [:geom :pts]}] + (mapv #(dissoc % :why) (clip/conflicts (:clip after))))) + ;; A mouth edit that does not change the vertex count leaves it applying. + (let [fine (regenerate/change before + {:scope :feature :id :face-1/mouth :knob :aperture-cut :value 0.2})] + (is (nil? (:conflict (first (:over (channel fine :mouth path)))))) + (is (empty? (ch/conflicts (channel fine :mouth path)))) + (is (empty? (clip/conflicts (:clip fine)))) + (is (empty? (clip/problems (:clip fine))))))) diff --git a/frontend/test/arthur/support/ops.cljs b/frontend/test/arthur/support/ops.cljs index ea72a2e..5c579fc 100644 --- a/frontend/test/arthur/support/ops.cljs +++ b/frontend/test/arthur/support/ops.cljs @@ -52,11 +52,11 @@ "(fn [f] -> snapshot) through `eval-frame`, the specification." ([sym store] (specified sym store pal/index-of)) ([sym store palette] - (fn [f] (snapshot (symbol/eval-frame sym f store palette))))) + (fn [f] (snapshot (symbol/eval-frame sym f store palette nil))))) (defn resolved "(fn [f] -> snapshot) through `resolver`, the playback path." ([sym store] (resolved sym store pal/index-of)) ([sym store palette] - (let [res (symbol/resolver sym store palette)] + (let [res (symbol/resolver sym store palette nil)] (fn [f] (snapshot (res f)))))) diff --git a/frontend/test/browser/lane.mjs b/frontend/test/browser/lane.mjs new file mode 100644 index 0000000..9cffb1e --- /dev/null +++ b/frontend/test/browser/lane.mjs @@ -0,0 +1,275 @@ +// 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.laneSnapshot = () => { + 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); + // A command is named the same wherever it is drawn, and since the transport + // strip was consolidated it is drawn in one of two places: as a button in the + // strip, or as a row in one of the strip's menus. So the test asks for it by + // name and this finds it — opening each menu in turn to look — rather than the + // test knowing which menu anything ended up in. An icon button is matched on + // its `aria-label`, which is also what a screen reader is told it is. + // Two bars carry commands: the location bar says where an edit lands and holds + // what creates things there, the transport strip holds what acts on a cel. + const bars = ['.loc', '.pane.time .pane-head']; + const within = (suffix) => bars.map((b) => `${b} ${suffix}`).join(', '); + const named = label => + `(b => b.textContent.trim() === ${JSON.stringify(label)}` + + ` || b.getAttribute('aria-label') === ${JSON.stringify(label)})`; + const shut = async () => { + await evaluate(`(() => { document.querySelectorAll('.menu-scrim').forEach(s => s.click()); return true })()`); + await sleep(120); + }; + // Leaves the control on screen and returns what to select it with. + const reveal = async label => { + await shut(); + if (await evaluate(`![...document.querySelectorAll('${within('button')}')].find(${named(label)})`)) { + const menus = await evaluate( + `[...document.querySelectorAll('${within('.menu-wrap > button')}')].map(b => b.textContent.trim())`); + let found = false; + for (const menu of menus) { + await evaluate(`(() => { [...document.querySelectorAll('${within('.menu-wrap > button')}')] + .find(b => b.textContent.trim() === ${JSON.stringify(menu)}).click(); return true })()`); + await sleep(180); + if (await evaluate(`!![...document.querySelectorAll('.menu-item')].find(${named(label)})`)) { found = true; break; } + await shut(); + } + assert(found, `a control named: ${label}`); + return '.menu-item'; + } + return within('button'); + }; + const click = async label => { + const where = await reveal(label); + assert(await evaluate(`(() => { + const b = [...document.querySelectorAll('${where}')].find(${named(label)}); + if (!b || b.disabled) return false; + b.click(); return true; + })()`), `enabled control: ${label}`); + await sleep(180); + await shut(); + }; + const shot = async () => (await evaluate('laneSnapshot()')); + 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 cel'); + + // 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 cel, ripple, and shot length'); + + // Sharing: one drawing exposed twice, then one cel decoupled. Room is + // made first so these assertions are about content and not about overflow. + 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')), 20)); + document.querySelector('.tl-cel').click(); + })()`); + await sleep(200); + const enabled = async label => { + const where = await reveal(label); + const yes = await evaluate(`(() => { + const b = [...document.querySelectorAll('${where}')].find(${named(label)}); + return !!b && !b.disabled; + })()`); + await shut(); + return yes; + }; + assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet'); + await click('reuse'); + s = await shot(); + let cels = instances(s); + assert.equal(cels.length, 3); + assert.equal(cels[2].source.symbol, cels[0].source.symbol, 'reuse exposes the same drawing'); + assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 3); + assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1, + 'three cels, still one row'); + assert.equal(await enabled('make unique'), true); + await click('make unique'); + s = await shot(); + cels = instances(s); + assert.notEqual(cels[2].source.symbol, cels[0].source.symbol, 'that cel has its own drawing'); + assert.equal(await enabled('make unique'), false, 'and is not shared any more'); + await click('duplicate'); + s = await shot(); + cels = instances(s); + assert.equal(cels.length, 4); + assert.equal(new Set(cels.map(n => n.source.symbol)).size, 4, + 'four cels of four drawings: nothing is shared once every copy is made'); + assert.equal(s.history.done.length, before.history.done.length + 3, 'three more commands, three more steps'); + + // A drawing into the middle of a hold: split, then insert. Both act at the + // playhead, and neither guesses what the other one is for. + const placed = s => instances(s) + .map(n => [n.time.at + n.span[0] / (n.time.rate ?? 1), n.time.at + n.span[1] / (n.time.rate ?? 1)]) + .sort((a, b) => a[0] - b[0]); + assert.deepEqual(placed(s), [[0, 4], [4, 5], [5, 6], [6, 7]]); + await evaluate(`document.querySelector('.tl-cel').click()`); + await sleep(200); + assert.equal(await enabled('split'), false, 'the start of a cel is not inside it'); + await click('+1'); + await click('+1'); + assert.equal(await enabled('split'), true); + await click('split'); + s = await shot(); + assert.deepEqual(placed(s), [[0, 2], [2, 4], [4, 5], [5, 6], [6, 7]], + 'one cel became two, over the frames it had'); + await click('insert'); + s = await shot(); + assert.deepEqual(placed(s), [[0, 2], [2, 3], [3, 5], [5, 6], [6, 7], [7, 8]], + 'the new drawing took frame 2 and everything from there rippled later'); + assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 6); + assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1, + 'six cels, still one row'); + assert.equal(s.history.done.length, before.history.done.length + 5); + + // Trim, move and blank: three gestures that move nothing but their own + // cel, and a shot whose length does not follow what is in it. + await evaluate(`[...document.querySelectorAll('.tl-cel')][2].click()`); + await sleep(200); + assert.deepEqual(placed(await shot()).slice(2, 4), [[3, 5], [5, 6]]); + await click('+1'); + assert.equal(await enabled('trim out'), true, 'the playhead is inside it'); + await click('trim out'); + s = await shot(); + assert.deepEqual(placed(s), [[0, 2], [2, 3], [3, 4], [5, 6], [6, 7], [7, 8]], + 'it ends at the playhead and every other cel stayed'); + assert.equal(await enabled('move here'), true); + await click('move here'); + s = await shot(); + assert.deepEqual(placed(s), [[0, 2], [2, 3], [4, 5], [5, 6], [6, 7], [7, 8]], + 'and moves to the playhead, into the gap it just made'); + await click('blank'); + s = await shot(); + assert.deepEqual(placed(s), [[0, 2], [2, 3], [5, 6], [6, 7], [7, 8]], + 'blanked: a gap where it was, and nothing closed it'); + assert.equal(s.clip.symbols.main.frames, 20, 'the shot is as long as it was authored'); + assert.equal(s.history.done.length, before.history.done.length + 8); + + // The same cels with the axes turned. Selecting a sheet cell feeds the same + // action strip and therefore the same domain command and undo transaction. + await click('cel sheet'); + assert.equal(await evaluate('document.querySelectorAll(".cs-head:not(.cs-frame)").length'), 1, + 'one lane is one cel-sheet column'); + assert.equal(await evaluate('document.querySelectorAll(".cs-cell").length'), 20, + 'one cell per authored frame'); + await evaluate(`document.querySelector('.cs-cell').click()`); + await sleep(180); + await click('hold +'); + s = await shot(); + assert.deepEqual(placed(s), [[0, 3], [3, 4], [6, 7], [7, 8], [8, 9]], + 'a command selected in the sheet has the timeline command semantics'); + assert.equal(s.history.done.length, before.history.done.length + 9); + await click('timeline'); + assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 5); + assert.equal(errors.length, 0, JSON.stringify(errors)); + console.log('PASS: lane commands agree from timeline and cel sheet; 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}`); + } +} diff --git a/frontend/test/browser/take.mjs b/frontend/test/browser/take.mjs index e8f3205..4c866f0 100644 --- a/frontend/test/browser/take.mjs +++ b/frontend/test/browser/take.mjs @@ -185,7 +185,7 @@ const PROBE = `(() => { w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones], cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null, hash: h >>> 0, - frame: document.querySelector('.time .pane-head .dim')?.textContent ?? '', + frame: document.querySelector('.time .pane-head .at-frame')?.textContent ?? '', // What the top bar says about the document. The media pool holds the OPEN // document's library now and no longer names documents at all, so the status // line is the page's own answer to "what am I looking at". @@ -202,7 +202,7 @@ const PROBE = `(() => { // knows which frame it wants, not how long the clip it is looking at happens to // be, and reading it here is one place instead of every call site. const SEEK = (f) => `(() => { - const read = document.querySelector('.time .pane-head .dim').textContent; + const read = document.querySelector('.time .pane-head .at-frame').textContent; // Split, not a regex: this is inside a template literal, where an escaped // slash collapses to a bare one and the two together open a line comment that // eats the rest of the statement. The readout is "12 / 229" and nothing else. @@ -216,7 +216,7 @@ const SEEK = (f) => `(() => { clientY: box.top + box.height / 2, })); ruler.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 })); - return document.querySelector('.time .pane-head .dim').textContent; + return document.querySelector('.time .pane-head .at-frame').textContent; })()`; // Everything the page has to say about loading, saving and opening. Read off the @@ -231,10 +231,15 @@ const STATUS = `[...document.querySelectorAll('.top .status, .pool .pane-body > // pane each one lived in would be a second copy of the layout. // // `firstChild` is the label: a media-pool row has a second line in a child span, -// so matching on textContent would never find "take". +// so matching on textContent would never find "take". `aria-label` is the same +// label for a control whose face is a glyph — the transport's play and pause — +// and it is the right fallback rather than a test hook, because it is already +// what that button is called for anybody not reading it with their eyes. const CLICK = (label) => `(() => { + const want = ${JSON.stringify(label)}; const b = [...document.querySelectorAll('button')] - .find((b) => (b.firstChild?.textContent ?? '').trim() === ${JSON.stringify(label)}); + .find((b) => (b.firstChild?.textContent ?? '').trim() === want + || b.getAttribute('aria-label') === want); if (!b || b.disabled) return false; b.click(); return true; @@ -626,7 +631,7 @@ async function main() { check(irisSlider !== null, 'the stage eye has an iris-size slider'); if (irisSlider) { check(await page.eval(CLICK('play')), 'the stage starts playing'); - const startFrame = await page.eval(`Number(document.querySelector('.time .pane-head .dim').textContent.match(/\\d+/)[0])`); + const startFrame = await page.eval(`Number(document.querySelector('.time .pane-head .at-frame').textContent.match(/\\d+/)[0])`); await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: irisSlider.x, y: irisSlider.y, button: 'left', clickCount: 1, }); @@ -638,7 +643,7 @@ async function main() { check(preview !== null, 'the slider updates the stage preview', preview ?? debug); check(debug.includes(':face-1/eye-r') && debug.includes('tier 1 only'), 'the panel reports the affected feature and tier', debug); - const endFrame = await page.eval(`Number(document.querySelector('.time .pane-head .dim').textContent.match(/\\d+/)[0])`); + const endFrame = await page.eval(`Number(document.querySelector('.time .pane-head .at-frame').textContent.match(/\\d+/)[0])`); check(endFrame > startFrame, 'playback continues during tuning', `${startFrame} -> ${endFrame}`); await page.eval(CLICK('pause')); } diff --git a/static/arthur/app.css b/static/arthur/app.css index 7c018d5..38dca0b 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -81,10 +81,14 @@ audio { display: none; } display: grid; height: 100%; grid-template-columns: var(--label) minmax(0, 1fr) 250px; - grid-template-rows: 30px minmax(0, 1fr) 232px; + /* The location bar is its own row and takes its height from the stage, not + from the timeline: it exists to explain what the timeline is showing, so + paying for it in timeline rows would be the wrong trade. */ + grid-template-rows: 30px minmax(0, 1fr) 21px 232px; grid-template-areas: "top top top" "pool view params" + "loc loc loc" "time time time"; gap: 1px; background: var(--line); @@ -94,6 +98,7 @@ audio { display: none; } .pool { grid-area: pool; } .view { grid-area: view; } .params { grid-area: params; } +.loc { grid-area: loc; } .time { grid-area: time; } /* Every pane is its own scroll container. `min-height: 0` is what lets a grid @@ -123,6 +128,33 @@ audio { display: none; } border-bottom: 1px solid var(--line); color: var(--dim); letter-spacing: .03em; + /* One line, always. A strip of controls that reflows to two lines resizes the + pane under it, and the pane under this one is the timeline. */ + white-space: nowrap; +} + +.pane-head .readout { color: var(--fg); } + +/* A pick-one that looks like the other pick-ones. A native select in this strip + draws a tall white well and a chevron of the browser's choosing, which reads + as a form field among buttons; the app's own idiom for "choose one of these" + is `label ▾`, and this IS one of those. So: the select keeps the behaviour and + the keyboard, and borrows the look. The arrow is the wrapper's, because an + option list cannot carry one. */ +.pick { position: relative; display: inline-flex; align-items: center; } +.pick > select { + appearance: none; + background: var(--pane); + padding: 1px 15px 1px 6px; + cursor: pointer; +} +.pick > select:hover { background: #fff; } +.pick::after { + content: "▾"; + position: absolute; + right: 5px; + color: var(--dim); + pointer-events: none; } .pane-head .spacer { flex: 1; } @@ -173,6 +205,47 @@ input[type="range"] { width: 100%; accent-color: var(--sel); } .dim { color: var(--dim); } .warn { color: var(--warn); } +/* Buttons that are one control: a transport, a stepper, a mode picker. They + share their borders, so the group reads as a single object with parts rather + than as several things that happen to be adjacent — which is the whole claim + a segmented control makes, and the reason `timeline` and `cel sheet` are one + of these. `.seg` is `.group` with that meaning; they are drawn the same + because the difference is what the buttons do, not how they look. + + The negative margin collapses the doubled border between two buttons into + one hairline. A pressed button then has to be raised, or the neighbour's + plain border covers half of its accent one. */ +.group, .seg { display: flex; align-items: center; } +.group > button, .seg > button { border-radius: 0; margin-left: -1px; } +.group > button:first-child, .seg > button:first-child { margin-left: 0; border-radius: 2px 0 0 2px; } +.group > button:last-child, .seg > button:last-child { border-radius: 0 2px 2px 0; } +.group > button.on, .seg > button.on { position: relative; z-index: 1; } + +/* The rule between two groups of unrelated controls. Shorter than the strip, so + it reads as a division of the row rather than as a wall across it. */ +.sep { flex: 0 0 1px; width: 1px; height: 13px; background: var(--line); } + +/* An icon button. The glyph is inked in `currentColor`, so `.on` colours it + with the same rule that colours a word. 11px to sit on 11px type. */ +button.ico { padding: 2px 5px; line-height: 0; } +button.ico > svg { display: block; width: 11px; height: 11px; } +/* Play is the one control in the strip you aim at without looking. */ +button.ico-play { padding-left: 8px; padding-right: 8px; } + +/* `hold −` / `hold +`: one label over two steppers, because the word is shared + and repeating it in both buttons was most of their width. */ +.stepper { display: flex; align-items: center; gap: 4px; } +.stepper-label { color: var(--dim); } +.stepper button { padding: 1px 6px; } + +/* A number that changes every frame. Tabular figures stop it twitching, and + stop the controls after it being nudged about as the count passes 9 and 99. */ +.readout { font-variant-numeric: tabular-nums; } + +/* The answer to a refusal rather than a standing command — `lane-retry` only + exists because something was declined and named this as the way through. */ +button.retry { border-color: var(--sel); color: var(--sel); } + /* -------------------------------------------------------------------------- top bar */ @@ -296,6 +369,39 @@ a.brand { text-decoration: none; } .undo > .undo-list { padding: 0 4px; margin-right: 4px; border-left: 0; border-radius: 0 2px 2px 0; } .menu.menu-left { left: 0; right: auto; } +/* A menu dropping out of a PANE HEAD. Fixed and placed by `ui/menu` against the + button, because a pane clips its own overflow and an absolutely positioned + panel would be cut off at the pane's edge — invisibly, and more of it the + longer the menu gets. `top`/`bottom`/`left`/`max-height` all come from there. + + Wide enough that a command's one-line explanation is one line. At 215px every + `.sub` in the cel menu wrapped, which doubled the panel's height for no gain: + the explanations are the reason these are menu rows and not buttons, so they + get the width to be read in. */ +.menu.menu-drop { + position: fixed; + /* BOTH offsets on each axis are reset, and `ui/menu` sets exactly one of each + inline. Leaving `.menu`'s own `top` standing alongside an inline `bottom` + over-constrains the box, and a fixed box given both offsets takes its HEIGHT + from them — which, from a toolbar near the foot of the window, computes + negative and collapses the panel to its padding with the commands still + inside it. */ + top: auto; + bottom: auto; + left: auto; + right: auto; + width: 272px; + overflow: auto; +} +.menu-note { padding: 1px 6px 5px; } + +/* A disabled row still says what the command is FOR. That is the whole trade: + a greyed button hides its reason in a `title`, a greyed row prints it. */ +.menu-item:disabled { opacity: .5; cursor: default; } +.menu-item:disabled:hover { background: none; } +.menu-item { padding: 3px 6px; } +.menu-item .sub { font-size: 10px; line-height: 1.3; white-space: normal; } + /* The one way to hand the document to somebody else, so it looks like one. */ button.share-button { background: var(--sel); color: #fff; border-color: var(--sel); font-weight: 600; padding: 1px 12px; } button.share-button:hover, button.share-button.on { filter: brightness(1.1); } @@ -435,6 +541,9 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .tab:hover .close, .tab.on .close { visibility: visible; } .tab .close:hover { background: var(--hair); color: var(--fg); } +.trace-opacity { width: 64px; } +.palette-bar label.dim { display: inline-flex; align-items: center; gap: 2px; white-space: nowrap; } + .palette-bar { display: flex; align-items: center; @@ -555,6 +664,55 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } border-radius: 2px; } +/* -------------------------------------------------------------------------- + the location bar + + Where you are, above the timeline that draws it. Chrome-coloured like a pane + head, because that is what it is to the two temporal views below it — but it + is not one of their heads, since it says the same thing whichever is showing. */ + +.loc { + display: flex; + align-items: center; + gap: 7px; + padding: 0 7px; + min-width: 0; + background: var(--chrome); + color: var(--dim); + white-space: nowrap; + overflow: hidden; +} + +.crumbs { display: flex; align-items: center; gap: 1px; min-width: 0; overflow: hidden; } + +/* A crumb is a place, not a command: no border and no fill until it is pointed + at. The trail has to read as one sentence, and five outlined buttons in a row + read as five things to press. */ +.crumb { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + padding: 1px 4px; + border: 1px solid transparent; + border-radius: 2px; + background: none; + color: var(--dim); +} +.crumb:hover:not(:disabled) { background: var(--sel-bg); color: var(--fg); } +/* The last crumb is the selection itself. Weight and full contrast say so; the + fill and accent border `button.on` would otherwise give it are undone here, + because a filled crumb reads as a pressed control and the thing it marks is + where you ARE, not something switched on. */ +.crumb.on { color: var(--fg); font-weight: 600; background: none; border-color: transparent; } +.crumb.lane::before { content: "≡ "; color: var(--dim); font-weight: 400; } +.crumb-sep { flex: 0 0 auto; color: var(--line); } + +.loc-fact { flex: 0 0 auto; } +.loc-fact::before, .loc-shared::before { content: "· "; color: var(--line); } +.loc-shared { flex: 0 0 auto; display: inline-flex; align-items: baseline; gap: 5px; } +.loc .spacer { flex: 1; } + /* -------------------------------------------------------------------------- timeline */ @@ -650,10 +808,10 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .tl-twist:disabled { opacity: 0; cursor: default; } .tl-delete { margin-left: auto; padding: 0 4px; border: 0; background: none; color: var(--dim); } .tl-delete:hover { color: var(--fg); } -.tl-solo { margin-left: auto; padding: 0 4px; border: 0; background: none; color: var(--dim); font-size: 10px; } -.tl-solo:hover { color: var(--fg); } -.tl-solo.on { color: var(--sel); font-weight: 600; } -.tl-solo + .tl-delete { margin-left: 0; } +.tl-solo, .tl-trace { margin-left: auto; padding: 0 4px; border: 0; background: none; color: var(--dim); font-size: 10px; } +.tl-solo:hover, .tl-trace:hover { color: var(--fg); } +.tl-solo.on, .tl-trace.on { color: var(--sel); font-weight: 600; } +.tl-solo + .tl-delete, .tl-trace + .tl-solo { margin-left: 0; } /* The ruler and the corner above the labels stay at the top of the body while the rows scroll under them: the ruler is the scrubber, and scrolling down to @@ -769,6 +927,50 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .tl-empty { padding: 9px; color: var(--dim); } +/* The second temporal view. Its cells carry the same selection addresses as + the cel blocks above; only the axes change. */ +.cel-sheet { + flex: 1; + min-height: 0; + overflow: auto; + display: grid; + align-content: start; + background: var(--line); + gap: 1px; +} + +.cs-head, +.cs-frame, +.cs-cell { + min-width: 0; + height: 24px; + border: 0; + border-radius: 0; + padding: 0 6px; + background: #fff; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cs-head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + background: var(--chrome); + font-weight: 600; +} + +.cs-head.cs-frame { z-index: 3; } +.cs-frame { position: sticky; left: 0; z-index: 1; color: var(--dim); text-align: right; } +.cs-frame.on, .cs-cell.current { box-shadow: inset 3px 0 0 var(--playhead); } +.cs-cell { text-align: left; cursor: pointer; } +.cs-cell:hover { background: var(--sel-bg); } +.cs-cell.selected { background: var(--sel-bg); color: var(--sel); font-weight: 600; } + /* -------------------------------------------------------------------------- the video -> symbol dialog */