arthur/frontend/src/arthur/domain/span.cljs
Your Name 5ebe776ce4 A lane is a generic row of symbol clips
A lane was a drawing lane: the only thing that could go in one was a one-frame
held cel, and every other symbol instance stayed a permanent root row of its
own. Those are not two kinds of timing, they are one kind with two creation
policies. `lane/place-symbol` drops any library symbol in as a clip that plays
naturally at speed one, `lane/adopt` moves an instance that is already in the
document into a lane keeping its source, span, playback and corrections, and
`append-drawing`/`overwrite-drawing` keep being the policy that makes a new
empty symbol a one-frame hold. The child shape they produce is the same.

Both new commands claim their interval through `blank` before they write, so
the partition rule is unchanged and unduplicated: placing into occupied lane
time trims, removes or splits the incumbents, and a lane still never stores an
overlap. Real compositing overlap is another lane, where the order is explicit.

Creating a symbol with nothing aimed now makes a lane and a clip in it instead
of a loose root instance, and a pool drop prefers an explicitly targeted lane,
then the selected one, and makes a lane only when there is neither. That is
what stops the row-per-symbol growth coming back in through the drop path, and
it is why `add-lane` now takes a z in front of the existing root nodes and
calls what it makes a "lane" rather than "drawings".

The timeline learned the two gestures that a generic lane needs. A clip body
dragged over another lane's track previews there as a dashed block and lands
through `::adopt-in-lane`; the track is found with `elementsFromPoint` and its
selection read back off the element, because a pointer capture does not
retarget. A pool drop over an existing lane previews as a dashed clip inside
that lane instead of a temporary new row that appears and then vanishes --
which also needed the drag-leave check to be geometric, since inserting the
preview changes the element under the pointer and Chromium then reports a leave
with no related target. Lanes are renameable from their label, by double-click,
F2, or the pencil, through `::rename-node`.

`symbol/lane-cels` is `symbol/lane-clips`, and the vocabulary table in the
handoff now separates the two words it had merged: a clip is an instance in a
lane, and a cel is specifically the one-frame held source that drawing creation
makes. Keeping `cel` for the policy is what lets the lane stop being about
drawings at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 15:13:07 -04:00

207 lines
10 KiB
Clojure

(ns arthur.domain.span
"The commands over ONE node's place in time: split it, trim an edge, move it.
A `:span` is in the node's OWN frames and its `:time` says where those land in
its parent, and that is true of EVERY node — which is why these three are not
lane commands, though a lane of cels is where they were first needed. A cel in
a lane, a symbol placed straight into a shot, a shape that exists for part of
one: each is a span in a parent's frame space, and a span in a parent's frame
space is the whole of what these commands touch. They were gated on a lane for
as long as a lane was the only thing anybody had timed.
THE COORDINATE IS ALWAYS THE PARENT'S. For a cel the parent is its lane, so
`host-frame` reads lane time exactly as the lane commands always did; for a
node sitting straight in the symbol it reads the symbol's own frames. One rule,
so a caller holding a node does not branch on what it sits in.
A GROUP IS REFUSED. Dividing a group means deciding what becomes of its
children, and nothing in a span says: the right half of a split lane would
reference none of its cels, and a span that narrows past a child hides it
without saying so. `domain/lane` holds the commands for a sequence, which are
the ones that ripple siblings or leave a gap.
`finish` lives here because every command in this namespace and every one in
`domain/lane` commits through it."
(:require [arthur.domain.clip :as clip]
[arthur.domain.node :as node]
[arthur.domain.symbol :as symbol]))
(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.
Only LANES are measured for reach. A node placed straight in a shot may hang
off the end of it — that is an ordinary thing to author and the window is
what crops it — whereas a lane's cels are a sequence whose length is the
thing being edited."
[clip sid nodes selection extent]
(let [sym (clip/symbol clip sid)
reach (for [[id n] nodes :when (node/lane? n)
child (symbol/lane-clips nodes id)
:let [m (symbol/frame-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 edge edit is made of
;;
;; A `:span` is in the node's OWN frames and its `:time` says where those
;; land in the parent. So moving an edge is one write to `:span`, and `:time`
;; and `:playback` are untouched — which is why trimming the front of a playing
;; insert starts it later in its source instead of resetting it, and why the two
;; halves of a split go on meaning what the one node meant.
;; Trim, split and `lane/blank` are all this one operation, applied differently.
(defn local
"Parent frame `f` as one of `n`'s own frames."
[n f]
(let [{:keys [at rate]} (node/time-of n)]
(* rate (- f at))))
(defn edged
"`n` with its `:in` or `:out` edge at parent frame `f`."
[n which f]
(assoc-in n [:span (case which :in 0 :out 1)] (local n f)))
(defn host-frame
"Symbol frame `f` as a frame of the space node `id` is POSITIONED in — its
parent's — which is the frame space every command here takes its coordinate
in. Nil through a stepped or looping ancestor, where one frame of the symbol
is not one frame of the parent and there is no single answer to give."
[clip sid id f]
(let [nodes (get-in clip [:symbols sid :nodes])]
(when-let [{:keys [at rate]} (symbol/frame-map nodes (:parent (get nodes id)))]
(* rate (- f at)))))
(defn- subject
"The node `id` names, as `{:node n}`, or `{:refused why}` where these commands
have nothing to act on. The one guard all three share."
[nodes id]
(let [n (get nodes id)]
(cond
(nil? n) {:refused "select something with a place in time"}
(= :group (:kind n))
{:refused "a group is divided by its children, not by its span"}
(nil? (node/placed-span n))
{:refused "this is on screen for the whole shot, so it has no edges to cut"}
:else {:node n})))
(defn split
"Cut node `id` in two at parent frame `cut`. The left piece keeps its
identity; the right gets `new-id`.
NOTHING BUT `:span` DIFFERS between the two pieces. They keep one `:time`, so
the right piece's own frames carry on exactly where the left's stopped, and its
source clock, its keys and its corrections therefore go on meaning what they
meant before the cut — preserved by construction rather than by arithmetic on
in-points that could be wrong. A held drawing holds the same frame on both
sides; a playing insert plays on through the cut without a seam; a shape goes
on being the same shape over each half. That is what `:span` being in the
node's OWN coordinates buys, and it is why splitting needs no shot-length
policy: the pieces occupy the frames the one node occupied.
THE RIGHT PIECE KEEPS THE ORIGINAL'S `:z`. Two halves of one thing draw at
one depth; nothing orders them against each other, because they are never on
screen on the same frame. Cels in a lane do not consult `:z` at all —
`symbol/lane-clips` sorts them by where they start.
The right piece is the selection, because it is the piece that was made."
[clip sid id cut new-id]
(let [nodes (get-in clip [:symbols sid :nodes])
{:keys [node refused]} (subject nodes id)
[lo hi] (when node (node/placed-span node))]
(cond
refused {:refused refused}
(not (integer? cut)) {:refused "a cut is a whole frame"}
(contains? nodes new-id) {:refused "the new ID is already used"}
(not (< lo cut hi)) {:refused (str "frame " cut " is not inside this")}
:else
(let [nodes (-> nodes
(assoc id (edged node :out cut))
(assoc new-id (assoc (edged node :in cut) :id new-id)))]
(finish clip sid nodes new-id :keep)))))
(defn trim
"Move one edge of node `id` to parent frame `to`, without disturbing anything
else at all.
TRIM NARROWS. Lengthening a cel is `lane/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])
{:keys [node refused]} (subject nodes id)
[lo hi] (when node (node/placed-span node))]
(cond
refused {:refused refused}
(not (#{:in :out} edge)) {:refused "an edge is :in or :out"}
(not (integer? to)) {:refused "an edge goes to a whole frame"}
(not (< lo to hi))
{:refused (str "frame " to " is not inside this; trim narrows it")}
:else (finish clip sid (assoc nodes id (edged node edge to)) id :keep))))
(defn resize-out
"Put one node's right edge at parent frame `to`, allowing it to grow.
This is for an ordinary timeline clip, including audio. Lane cels use
`lane/resize-out`, because only a lane has neighbours to trim or ripple."
[clip sid id to]
(let [nodes (get-in clip [:symbols sid :nodes])
{:keys [node refused]} (subject nodes id)
[lo _] (when node (node/placed-span node))]
(cond
refused {:refused refused}
(not (integer? to)) {:refused "an edge goes to a whole frame"}
(not (< lo to)) {:refused "a clip must keep at least one frame"}
:else (finish clip sid (assoc nodes id (edged node :out to)) id :keep))))
(defn move
"Put node `id` at parent frame `to`, leaving its own length, source and
corrections alone — and, in a lane, every other cel.
One write to `:time :at`. A destination that would overlap a neighbour IN A
LANE is refused rather than rippled or overwritten: moving a drawing and
re-timing the ones around it are different intentions, and a move that
silently pushed the rest would be the second one wearing the first one's name.
Clear the room first — `lane/blank` makes a gap, `trim` shortens a neighbour.
Outside a lane there is no such rule to break: things placed in a composition
are allowed to be on screen together, so the move simply happens."
[clip sid id to]
(let [nodes (get-in clip [:symbols sid :nodes])
{:keys [node refused]} (subject nodes id)]
(cond
refused {:refused refused}
(not (integer? to)) {:refused "a move goes to a whole frame"}
:else
(let [moved (update-in node [:time :at] (fnil + 0) (- to (first (node/placed-span node))))]
(if (not= to (first (node/placed-span moved)))
{:refused "timing through a stepped or looping parent is not supported"}
(finish clip sid (assoc nodes id moved) id :keep))))))