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>
207 lines
10 KiB
Clojure
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))))))
|