Compare commits

...

2 commits

Author SHA1 Message Date
Your Name
a4ce750be2 The face owns its placement, not the take that holds it
The source-to-stage mapping moves off :main's :face group and onto each face's
own :place, above its head. `face-placement` computes exactly what it computed
before, over every subject together, so two faces filmed side by side keep
their filmed relation — it is written into each face instead of onto a group
above them all. Same transform, same subtree, one level lower, and the
composite is identical to the pixel: a digest over every op :main emits across
the whole take is unchanged either way.

THE OWNER IS THE POINT. A face carrying its own mapping is the right size
wherever it is put — dropped into another symbol, or opened in its own tab to
be drawn over — and the take that holds it needs to know nothing. On a group
above the instances the scale belonged to the take, so a face taken out of it
had no size at all and drew at a fraction of a pixel.

The pool's thumbnails drop the workaround that knew about this: a symbol is
rendered rooted at itself again, because a face now carries the placement that
makes that honest, so the pool needs to know nothing about where a symbol
happens to be used. `domain/node` and `arthur.export` leave its requires with it.

The tests here were reading the placement off :main. The photo registration
test changes shape rather than location: its premise was that face-1's head is
its own root, so a photo sitting where it was filmed was image pixels over
image height and nothing else. The head still cancels — that is what the test
is about — but it now cancels against the face's own placement, which is why
the photo comes with the face into its own tab instead of sitting at a
fraction of a pixel beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 01:25:37 -04:00
Your Name
e6d0ededb1 spreadsheet ui 2026-10-01 01:25:26 -04:00
16 changed files with 418 additions and 100 deletions

View file

@ -434,7 +434,7 @@ selected head placement, so it aligns with the vectors drawn over it.
:mouth :mouth-in :teeth :lid-r :lid-l :brow-r :brow-l … :mouth :mouth-in :teeth :lid-r :lid-l :brow-r :brow-l …
``` ```
Changing anchor keys edits `:head` and never touches `:face`, so it cannot move Changing anchor keys edits `:head` and never touches `:place`, so it cannot move
something that was placed by hand. A group node is free, and keeping the authored something that was placed by hand. A group node is free, and keeping the authored
and the measured transform apart is the whole reason the transform is decomposed and the measured transform apart is the whole reason the transform is decomposed
in the first place. in the first place.
@ -679,7 +679,7 @@ Proof that it covers what exists, not just what is wanted:
| square pupil | node `:pupil-r`, `:kind :rect`, parent `:iris-r`, stencil `:iris-r` | | square pupil | node `:pupil-r`, `:kind :rect`, parent `:iris-r`, stencil `:iris-r` |
| brow ring + quantised raise | node `:brow-r`, `[:geom :pts]` dense (the traced ring with height removed), `[:xform :pos]` dense (the quantised raise). **The decomposition design.md insists on is two channels.** | | brow ring + quantised raise | node `:brow-r`, `[:geom :pts]` dense (the traced ring with height removed), `[:xform :pos]` dense (the quantised raise). **The decomposition design.md insists on is two channels.** |
| head plate, kept frames | node `:head`, `:symbol` per instance, keys on `[:symbol]` at kept frames | | head plate, kept frames | node `:head`, `:symbol` per instance, keys on `[:symbol]` at kept frames |
| `makeXform` face-oval crop | **gone.** Placement is `[:xform :*]` on `:face`; the stage clips | | `makeXform` face-oval crop | **gone.** Placement is `[:xform :*]` on the face's own `:place`; the stage clips |
| `stabilize` transforms | dense `[:xform :*]` on `:head`, read through its optional `:anchors` map | | `stabilize` transforms | dense `[:xform :*]` on `:head`, read through its optional `:anchors` map |
| registered underlay | not data — a UI layer riding `(world-of resolver :head)` | | registered underlay | not data — a UI layer riding `(world-of resolver :head)` |
| painted background cel | node per layer, `[:geom :pts]` **framed**, `[:style :color]` framed | | painted background cel | node per layer, `[:geom :pts]` **framed**, `[:style :color]` framed |

View file

@ -88,6 +88,21 @@ decision, not a cleanup.
## Next steps, in order ## Next steps, in order
The cel sheet now supports rectangular selection by pointer drag, Shift-click,
and Shift-arrow, plus an editor-local clipboard (Cmd/Ctrl C/X/V). Paste
overwrites the destination rectangle, including copied gaps, and reuses drawing
symbols. Partial cels retain their local clocks, playback and corrections.
Delete clears frames without closing time. Each cut, paste or clear is one
history transaction. The clipboard belongs to the mounted sheet and current
document; it is not a system clipboard interchange format.
A selected held cel has a bottom-right resize handle. Dragging previews its new
extent and commits one ripple edit on release; Escape cancels. Overflow uses
the existing explicit shot-extension retry. Rectangle edits currently require
lanes on the sheet's clock and paste must fit within the shot and available
columns. Insert-paste, moving rectangles, and repeating multi-cel patterns with
the handle remain future work.
The implemented correction slice and its remaining UI limits are recorded in The implemented correction slice and its remaining UI limits are recorded in
[Correction authoring](correction-authoring-plan.md). [Correction authoring](correction-authoring-plan.md).

View file

@ -333,11 +333,12 @@ per-frame header.
numbers: it centres on the face oval's bbox and zooms until the face is 80% of numbers: it centres on the face oval's bbox and zooms until the face is 80% of
the raster height, so every vertex carries a cropping decision made once from one the raster height, so every vertex carries a cropping decision made once from one
frame's landmarks. Dropping it is a deletion. Placement becomes `[:xform :*]` on frame's landmarks. Dropping it is a deletion. Placement becomes `[:xform :*]` on
an authored `:face` node, the stage clips whatever hangs off, and project an authored `:place` node inside the face, the stage clips whatever hangs off,
and project
dimensions stop being tied to the footage. See "What space geometry is in" in dimensions stop being tied to the footage. See "What space geometry is in" in
`docs/animation-model.md`. `docs/animation-model.md`.
The anchor transform freezes onto `:head`, one level under `:face`, and the The anchor transform freezes onto `:head`, one level under `:place`, and the
normalise on/off/per-plate toggle is which of the three channel shapes that node normalise on/off/per-plate toggle is which of the three channel shapes that node
carries. Always measure and always store factored, whatever the toggle says: carries. Always measure and always store factored, whatever the toggle says:
smoothing and velocity-minimum key selection both require the split to exist in smoothing and velocity-minimum key selection both require the split to exist in

View file

@ -39,7 +39,7 @@ measurements without losing the anchor choices.
A source image used for tracing should be registered with that image's measured A source image used for tracing should be registered with that image's measured
stabilizing transform, then the selected head transform, then the authored stabilizing transform, then the selected head transform, then the authored
`:face` placement. This makes the photo and head-local vectors share the same `:place` placement the face carries. This makes the photo and head-local vectors share the same
orientation and position. Tracing-photo selection is a separate editor address; orientation and position. Tracing-photo selection is a separate editor address;
it does not choose the head anchor. it does not choose the head anchor.

View file

@ -246,6 +246,58 @@
nodes members)] nodes members)]
(finish clip sid nodes (or (when spanning id) lane-id) :keep))))) (finish clip sid nodes (or (when spanning id) lane-id) :keep)))))
(defn sheet-range
"Snapshot a rectangle in open-symbol frames. Cel-local clocks and channels
survive clipping; gaps are represented by the rectangle's duration."
[clip sid lanes [a b]]
(let [nodes (get-in clip [:symbols sid :nodes])]
(if-not (and (seq lanes) (integer? a) (integer? b) (<= 0 a) (< a b)
(every? #(and (node/lane? (get nodes %))
(= {:at 0 :rate 1} (lane-map nodes %))) lanes))
{:refused "range editing requires lanes with the same clock as the sheet"}
{:duration (- b a)
:columns
(mapv (fn [id]
(vec (for [n (symbol/lane-cels nodes id)
:let [[lo hi] (node/placed-span n)]
:when (and (< lo b) (> hi a))]
(-> n
(edged :in (max a lo))
(edged :out (min b hi))
(update-in [:time :at] (fnil - 0) a))))) lanes)})))
(defn paste-range
"Overwrite a rectangle atomically, including its gaps. IDs are supplied by
the caller. Copies share drawings and retain cel-local animation."
[clip sid lanes at payload new-id]
(let [duration (:duration payload)
check (when duration (sheet-range clip sid lanes [at (+ at duration)]))]
(cond
(:refused payload) payload
(not= (count lanes) (count (:columns payload)))
{:refused "the copied range does not fit the destination lanes"}
(or (nil? duration) (:refused check))
(or check {:refused "copy a cel-sheet range first"})
(> (+ at duration) (get-in clip [:symbols sid :frames]))
{:refused "the copied range extends beyond the shot"}
:else
(reduce
(fn [result [id cels]]
(if (:refused result) (reduced result)
(let [cleared (blank (:clip result) sid id [at (+ at duration)] {:id (new-id)})]
(if (:refused cleared) (reduced cleared)
(let [nodes (reduce (fn [nodes n]
(let [cid (new-id)]
(assoc nodes cid (-> n
(assoc :id cid :parent id :z (str "a-" cid))
(update-in [:time :at] + at)))))
(get-in (:clip cleared) [:symbols sid :nodes]) cels)
result (finish (:clip cleared) sid nodes id :keep)
problems (when (:clip result) (clip/problems (:clip result)))]
(if (seq problems) {:refused (first problems)} result))))))
{:clip clip :selection (first lanes)}
(map vector lanes (:columns payload))))))
(defn add-lane [clip sid id] (defn add-lane [clip sid id]
(if (or (nil? (clip/symbol clip sid)) (get-in clip [:symbols sid :nodes 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"} {:refused "the symbol is missing or the lane ID is already used"}

View file

@ -283,6 +283,27 @@
{:db (update db :ui dissoc :lane-retry) :dispatch event} {:db (update db :ui dissoc :lane-retry) :dispatch event}
{}))) {})))
(rf/reg-event-db
::sheet-paste
(fn [db [_ sid lanes at payload]]
(let [clip (:clip (store/entry (:clip/current db)))]
(apply-lane-command db sid
(lane/paste-range clip sid lanes at payload random-uuid) nil))))
(rf/reg-event-db
::sheet-hold
(fn [db [_ sid id end extent]]
(let [clip (:clip (store/entry (:clip/current db)))
n (get-in clip [:symbols sid :nodes id])
at (lane/lane-frame clip sid (:parent n) end)
delta (when at (- at (second (node/placed-span n))))]
(if (= 0 delta) db
(apply-lane-command db sid
(if delta
(lane/extend-hold clip sid id delta {:extent (or extent :keep)})
{:refused "this lane's frames are not the open symbol's"})
[::sheet-hold sid id end :grow-symbol])))))
(rf/reg-event-db (rf/reg-event-db
::toggle-row ::toggle-row
(fn [db [_ path]] (fn [db [_ path]]

View file

@ -25,7 +25,8 @@
oval's bbox and zooms until the face is 80% of the raster height, so every oval's bbox and zooms until the face is 80% of the raster height, so every
stored vertex carries a cropping decision made once, at analysis time, from one stored vertex carries a cropping decision made once, at analysis time, from one
frame's landmarks. Here the geometry stays in the node's own local space and the frame's landmarks. Here the geometry stays in the node's own local space and the
framing is `[:xform :*]` on an authored `:face` node, which the stage clips. framing is `[:xform :*]` on each face's own authored `:place` node, which the
stage clips.
Project dimensions are therefore independent of the footage — see Project dimensions are therefore independent of the footage — see
`face-placement`, and \"What space geometry is in\" in docs/animation-model.md. `face-placement`, and \"What space geometry is in\" in docs/animation-model.md.
@ -396,7 +397,8 @@
The synthetic take uses the reference rigid configuration for its default. The synthetic take uses the reference rigid configuration for its default.
Real footage can request `:fit-motion?`: its default fits the observed mouth, Real footage can request `:fit-motion?`: its default fits the observed mouth,
eyes and brows in the stage across the shot. eyes and brows in the stage across the shot.
Both are ordinary editable transforms on :face, never baked into the geometry. Both are ordinary editable transforms on the face's own `:place`, never baked
into the geometry.
The face oval is not measured, because its only consumers in the prototype were The face oval is not measured, because its only consumers in the prototype were
the old baked framing transform and the placeholder plate outline. the old baked framing transform and the placeholder plate outline.
@ -419,8 +421,18 @@
leaves the jaw and the mouth on the stage. Whatever hangs off is clipped, which leaves the jaw and the mouth on the stage. Whatever hangs off is clipped, which
is not a feature to add: every fill in `domain/raster` clamps already. is not a feature to add: every fill in `domain/raster` clamps already.
All subjects share one source-to-stage mapping on the :face group. Each ONE MAPPING, COMPUTED OVER EVERY SUBJECT AND WRITTEN INTO EACH FACE. It is
instance can then be placed independently with ordinary transform channels." measured across all of them together — which is what keeps two faces filmed
side by side in their filmed relation — and then stored on each face's own
`:place` rather than on a group above them all. Same transform, same subtree,
one level lower: the composite is identical to the pixel.
THE OWNER IS THE POINT. A face carrying its own source-to-stage mapping is a
face that is the right size wherever it is put — dropped into another symbol,
or opened in its own tab to be drawn over — and the symbol that places it needs
to know nothing. On a group above the instances the scale belonged to the take,
so a face taken out of that take had no size at all and drew at a fraction of a
pixel."
[{:keys [stage fit-motion?]} subjects] [{:keys [stage fit-motion?]} subjects]
(let [[w h] stage (let [[w h] stage
inputs (vals subjects)] inputs (vals subjects)]
@ -646,6 +658,17 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; the clip ;; the clip
(defn- place-in
"Put the source-to-stage mapping on symbol `sym`'s own `:place`, above its head.
`:place` is the one authored node a freeze leaves on a face, and `face-placement`
says why it is a default rather than a measurement."
[sym channels]
(-> sym
(assoc-in [:nodes :place] {:id :place :name "source placement" :kind :group
:z "a1" :channels channels})
(assoc-in [:nodes :head :parent] :place)))
(defn- subject-part (defn- subject-part
"A subject's drawing, metadata and blocks. Node names are symbol-local." "A subject's drawing, metadata and blocks. Node names are symbol-local."
[params subject {:keys [outer eyes brows teeth] :as inputs}] [params subject {:keys [outer eyes brows teeth] :as inputs}]
@ -727,10 +750,11 @@
[{:keys [name fps stage expose] :as params} subjects] [{:keys [name fps stage expose] :as params} subjects]
(when-not (and (map? subjects) (seq subjects) (when-not (and (map? subjects) (seq subjects)
(every? keyword? (keys subjects)) (every? keyword? (keys subjects))
(not-any? #{:main :root :face} (keys subjects))) (not-any? #{:main :root :place} (keys subjects)))
(throw (ex-info "a freeze needs subjects with ids distinct from :main, :root and :face" {}))) (throw (ex-info "a freeze needs subjects with ids distinct from :main, :root and :place" {})))
(let [ordered (sort-by (comp str key) subjects) (let [ordered (sort-by (comp str key) subjects)
parts (mapv (fn [[id inputs]] [id (subject-part params id inputs)]) ordered) parts (mapv (fn [[id inputs]] [id (subject-part params id inputs)]) ordered)
placement (face-placement params subjects)
lengths (distinct (map #(get-in % [1 :symbol :frames]) parts)) lengths (distinct (map #(get-in % [1 :symbol :frames]) parts))
_ (when-not (and (= 1 (count lengths)) (pos? (first lengths))) _ (when-not (and (= 1 (count lengths)) (pos? (first lengths)))
(throw (ex-info "subjects need the same positive frame count" (throw (ex-info "subjects need the same positive frame count"
@ -745,17 +769,16 @@
(into {:main (into {:main
{:id :main :frames nf {:id :main :frames nf
:nodes (into {:root {:id :root :name "clip" :kind :group :z "a1" :nodes (into {:root {:id :root :name "clip" :kind :group :z "a1"
:time {:mode :map :expose expose}} :time {:mode :map :expose expose}}}
:face {:id :face :name "source placement" :kind :group
:parent :root :z "a1"
:channels (face-placement params subjects)}}
(map-indexed (map-indexed
(fn [i [id _]] (fn [i [id _]]
[id {:id id :kind :instance :parent :face [id {:id id :kind :instance :parent :root
:z (str "a" i) :z (str "a" i)
:source {:symbol id}}])) :source {:symbol id}}]))
ordered)}} ordered)}}
(map (fn [[id part]] [id (:symbol part)])) parts)}] (map (fn [[id part]]
[id (place-in (:symbol part) placement)]))
parts)}]
(doseq [[subject inputs] ordered (doseq [[subject inputs] ordered
[id track] (:presence inputs)] [id track] (:presence inputs)]
(when-not (and (= nf (count track)) (when-not (and (= nf (count track))

View file

@ -45,9 +45,7 @@
footage stay separate records on the server, so dropping the same file twice footage stay separate records on the server, so dropping the same file twice
does not decode it twice." does not decode it twice."
(:require [arthur.domain.clip :as clip] (:require [arthur.domain.clip :as clip]
[arthur.domain.node :as node]
[arthur.domain.raster :as raster] [arthur.domain.raster :as raster]
[arthur.export :as export]
[arthur.events.footage :as footage] [arthur.events.footage :as footage]
[arthur.events.playback :as pb] [arthur.events.playback :as pb]
[arthur.events.project :as project] [arthur.events.project :as project]
@ -74,35 +72,16 @@
;; cache's. ;; cache's.
(atom {:clip nil :urls {}})) (atom {:clip nil :urls {}}))
(defn- placed-in
"Where `sid` is placed, as `[host-symbol node-id]`, or nil for one nothing
places. Lowest ids first, so a symbol placed several times gets one picture and
the same one every time."
[document sid]
(first (for [h (sort-by str (keys (:symbols document)))
n (sort-by str (keys (get-in document [:symbols h :nodes])))
:when (contains? (node/sources (get-in document [:symbols h :nodes n])) sid)]
[h n])))
(defn- draw-symbol (defn- draw-symbol
"`sid`'s first frame as a PNG data URL, through the resolver and the rasteriser "`sid`'s first frame as a PNG data URL, through the resolver and the rasteriser
the stage uses — so the picture in the pool is the picture, not a sketch of it. the stage uses — so the picture in the pool is the picture, not a sketch of it.
THROUGH THE PLACEMENT, WHERE THERE IS ONE, and this is the whole subtlety. ROOTED AT THE SYMBOL, which is only honest because a symbol carries its own
Rooting at a symbol renders its DRAWING, in its own local space; a rotoscoped placement. A face's geometry is head-local in units of one image height, and
face is stored head-local in units of one image height, so its numbers are the source-to-stage mapping that turns those into stage pixels lives on the
around 0..1 and the source-to-stage scale that turns them into stage pixels — face's own `:place` — see `freeze/face-placement`. So a face drawn by itself is
several hundred — lives on the `:face` group of the symbol that PLACES it. See the size it is on the stage, and this needs to know nothing about where it
`freeze/face-placement` and \"What space geometry is in\" in happens to be used.
docs/animation-model.md. Rendered rooted at itself, a face is therefore correct
and under a pixel across, which is a true picture of nothing anybody wants to
look at.
So a placed symbol is drawn where it is placed, with everything else in that
host isolated away — `export/isolate`, whose docstring draws the same
distinction for the same reason. A symbol nothing places has no placement to
borrow and is rendered rooted at itself, which for the main timeline is exactly
right because the stage is its own.
nil rather than a throw when the frame will not resolve — a cycle, a missing nil rather than a throw when the frame will not resolve — a cycle, a missing
block, an instance naming a symbol that has gone. A pool is a list of names, block, an instance naming a symbol that has gone. A pool is a list of names,
@ -116,12 +95,8 @@
[document sid store palette ramp] [document sid store palette ramp]
(try (try
(when (clip/symbol document sid) (when (clip/symbol document sid)
(let [[host id] (placed-in document sid) (let [resolve (clip/resolver document sid store palette {})
document (cond-> document [w h] (clip/stage document sid)]
host (update-in [:symbols host] export/isolate id))
root (or host sid)
resolve (clip/resolver document root store palette {})
[w h] (clip/stage document root)]
(-> (raster/make w h) (-> (raster/make w h)
(raster/clear! (get palette :bg 0)) (raster/clear! (get palette :bg 0))
(raster/draw-ops! (resolve 0)) (raster/draw-ops! (resolve 0))

View file

@ -696,15 +696,89 @@
(filter :cels (rows clip sid #{})))) (filter :cels (rows clip sid #{}))))
(defn- cel-sheet-view [] (defn- cel-sheet-view []
(let [clip @(rf/subscribe [::render/clip]) (r/with-let [range-state (r/atom nil)
clipboard (atom nil)
gesture (r/atom nil)]
(let [clip @(rf/subscribe [::render/clip])
clip-id @(rf/subscribe [::render/clip-id])
sid @(rf/subscribe [::render/open]) sid @(rf/subscribe [::render/open])
frames (max 1 (or @(rf/subscribe [::render/frames]) 1)) frames (max 1 (or @(rf/subscribe [::render/frames]) 1))
frame @(rf/subscribe [::playback/frame]) frame @(rf/subscribe [::playback/frame])
selection @(rf/subscribe [::sub/selection]) selection @(rf/subscribe [::sub/selection])
columns (cel-sheet clip sid frames) columns (cel-sheet clip sid frames)
active (when (and (= sid (:sid @range-state))
(= clip-id (:clip-id @range-state))) @range-state)
[ac af] (:anchor active)
[bc bf] (:focus active)
left (when active (min ac bc))
right (when active (max ac bc))
top (when active (min af bf))
bottom (when active (max af bf))
lanes (when (and active (< right (count columns)))
(mapv :id (subvec columns left (inc right))))
choose! (fn [c f extend?]
(reset! range-state {:sid sid :clip-id clip-id
:anchor (if (and extend? active) (:anchor active) [c f])
:focus [c f]})
(rf/dispatch [::pb/seek f])
(rf/dispatch [::ui/select (or (get-in columns [c :cells f :cel :select])
(:select (nth columns c)))]))
clear! #(when lanes
(rf/dispatch [::ui/sheet-paste sid lanes top
{:duration (inc (- bottom top))
:columns (mapv (constantly []) lanes)}]))
style {:grid-template-columns style {:grid-template-columns
(str "52px repeat(" (max 1 (count columns)) ", minmax(110px, 1fr))")}] (str "52px repeat(" (max 1 (count columns)) ", 140px)")}]
[:div.cel-sheet {:style style} [:div.cel-sheet
{:style style :tab-index 0
:aria-label "Cel sheet. Drag to select; Shift extends selection. Copy and paste reuse drawings."
:on-pointer-move
(fn [e]
(when @gesture
(when-let [cell (some-> (.elementFromPoint js/document (.-clientX e) (.-clientY e))
(.closest "[data-cs-col]"))]
(let [c (js/Number (.getAttribute cell "data-cs-col"))
f (js/Number (.getAttribute cell "data-cs-frame"))]
(if (= :hold (:kind @gesture))
(swap! gesture assoc :end (inc f))
(swap! range-state assoc :focus [c f]))))))
:on-pointer-up
(fn [_]
(when (= :hold (:kind @gesture))
(rf/dispatch [::ui/sheet-hold sid (:id @gesture) (:end @gesture)]))
(reset! gesture nil))
:on-pointer-cancel #(reset! gesture nil)
:on-lost-pointer-capture #(reset! gesture nil)
:on-key-down
(fn [e]
(let [k (.toLowerCase (.-key e))
cmd? (or (.-metaKey e) (.-ctrlKey e))
arrow (get {"arrowup" [0 -1] "arrowdown" [0 1]
"arrowleft" [-1 0] "arrowright" [1 0]} k)]
(when (and lanes (or arrow (#{"delete" "backspace" "escape"} k)
(and cmd? (#{"c" "x" "v"} k))))
(.preventDefault e)
(.stopPropagation e)
(cond
(= k "escape") (do (reset! gesture nil) (reset! range-state nil))
arrow (let [[dc df] arrow]
(choose! (max 0 (min (dec (count columns)) (+ bc dc)))
(max 0 (min (dec frames) (+ bf df))) (.-shiftKey e)))
(#{"delete" "backspace"} k) (clear!)
(#{"c" "x"} k)
(let [payload (lane/sheet-range clip sid lanes [top (inc bottom)])]
(if (:refused payload)
(rf/dispatch [::ui/sheet-paste sid lanes top payload])
(do (reset! clipboard {:clip-id clip-id :payload payload})
(when (= k "x") (clear!)))))
(= k "v")
(let [payload (when (= clip-id (:clip-id @clipboard)) (:payload @clipboard))
targets (mapv :id (take (count (:columns payload)) (drop left columns)))]
(rf/dispatch [::ui/sheet-paste sid targets top payload]))))))}
[:div.cs-help {:style {:grid-column "1 / -1"}}
(if (= :hold (:kind @gesture))
(str "Extend hold to frame " (dec (:end @gesture)) " · later cels move with it")
"Drag to select · Shift extends · ⌘/Ctrl C/X/V · Delete clears · drag a hold’s corner to resize")]
[:div.cs-head.cs-frame "frame"] [:div.cs-head.cs-frame "frame"]
(doall (for [{:keys [path label select]} columns] (doall (for [{:keys [path label select]} columns]
^{:key (str "head-" path)} ^{:key (str "head-" path)}
@ -714,22 +788,45 @@
(doall (doall
(for [f (range frames) (for [f (range frames)
item (cons {:frame-label? true} item (cons {:frame-label? true}
(map #(get-in % [:cells f]) columns))] (map-indexed #(assoc (get-in %2 [:cells f]) :column %1) columns))]
(if (:frame-label? item) (if (:frame-label? item)
^{:key (str "frame-" f)} ^{:key (str "frame-" f)}
[:button.cs-frame {:class (when (= f frame) "on") [:button.cs-frame {:class (when (= f frame) "on")
:on-click #(rf/dispatch [::pb/seek f])} f] :on-click #(rf/dispatch [::pb/seek f])} f]
(let [{:keys [id label select]} (:cel item) (let [{:keys [id label select span]} (:cel item)
target (or select (:lane-select item))] c (:column item)
^{:key (str f "-" (:lane item) "-" (or id "gap"))} selected? (and lanes (<= left c right) (<= top f bottom))
held? (and id (zero? (get-in clip [:symbols sid :nodes id :playback :speed] 1)))
handle? (and held? (= select selection) (= (inc f) (second span)))]
^{:key (str f "-" (:lane item))}
[:button.cs-cell [:button.cs-cell
{:class (str (when (= f frame) " current") {:class (str (when (= f frame) " current")
(when (and select (= select selection)) " selected")) (when selected? " selected")
(when (and (= c (:column @gesture)) (= :hold (:kind @gesture))
(<= (:start @gesture) f) (< f (:end @gesture))) " cs-preview"))
:data-cs-col c :data-cs-frame f
:title (if id (str label " · frame " f) (str "gap · frame " f)) :title (if id (str label " · frame " f) (str "gap · frame " f))
:on-click (fn [] :on-pointer-down
(rf/dispatch [::pb/seek f]) (fn [e]
(rf/dispatch [::ui/select target]))} (when (= 0 (.-button e))
(or label "—")]))))])) (.preventDefault e)
(.focus (.-currentTarget e))
(.setPointerCapture (.-currentTarget e) (.-pointerId e))
(choose! c f (.-shiftKey e))
(reset! gesture {:kind :select})))
:on-click #(when (zero? (.-detail %)) (choose! c f (.-shiftKey %)))}
(or label "—")
(when handle?
[:span.cs-fill-handle
{:title "Drag to resize hold (ripples later cels)"
:on-pointer-down
(fn [e]
(when (= 0 (.-button e))
(.preventDefault e)
(.stopPropagation e)
(.setPointerCapture (.-currentTarget e) (.-pointerId e))
(reset! gesture {:kind :hold :id id :column c
:start (first span) :end (second span)})))}])]))))])))
(defn view [] (defn view []
(if (= :cel-sheet @(rf/subscribe [::sub/time-view])) (if (= :cel-sheet @(rf/subscribe [::sub/time-view]))

View file

@ -42,6 +42,37 @@
:wave (assoc-in (drawing :wave 0 10) [:nodes :mark :channels [:xform :pos]] :wave (assoc-in (drawing :wave 0 10) [:nodes :mark :channels [:xform :pos]]
(ch/keyed {0 [0 0] 9 [900 0]} :linear))}})) (ch/keyed {0 [0 0] 9 [900 0]} :linear))}}))
(deftest sheet-copy-clips-without-resetting-source-or-local-keys
(let [doc (document)
payload (lane/sheet-range doc :main [:girl] [9 11])
copied (first (first (:columns payload)))
result (lane/paste-range doc :main [:girl] 1 payload random-uuid)
nodes (get-in (:clip result) [:symbols :main :nodes])
pasted (first (filter #(and (= :wave (node/source %))
(= [1 3] (node/placed-span %))) (vals nodes)))]
(is (= 2 (:duration payload)))
(is (= [1 3] (:span copied)))
(is (= (:playback copied) (:playback pasted)))
(is (= [1 3] (:span pasted)))
(is (= :wave (node/source pasted)))
(is (empty? (clip/problems (:clip result))))
(is (= [0 1] (node/placed-span (:a nodes))))
(is (some #(= [3 4] (node/placed-span %)) (vals nodes)))))
(deftest sheet-paste-preserves-gaps-and-refuses-overflow-and-incompatible-clocks
(let [doc (document)
gap (:clip (lane/blank doc :main :girl [1 3] {:id :remainder}))
payload (lane/sheet-range gap :main [:girl] [0 4])
result (lane/paste-range gap :main [:girl] 4 payload random-uuid)
cels (symbol/lane-cels (get-in (:clip result) [:symbols :main :nodes]) :girl)]
(is (empty? (clip/problems (:clip result))))
(is (not-any? #(let [[a b] (node/placed-span %)] (and (< a 7) (> b 5))) cels))
(is (:refused (lane/paste-range doc :main [:girl] 10 payload random-uuid)))
(is (:refused (lane/paste-range doc :main [] 0 payload random-uuid)))
(is (:refused (lane/sheet-range
(assoc-in doc [:symbols :main :nodes :girl :time] {:rate 2})
:main [:girl] [0 4])))))
(defn sample [doc fs] (defn sample [doc fs]
(let [r (clip/resolver doc :main nil pal/index-of nil)] (let [r (clip/resolver doc :main nil pal/index-of nil)]
(into {} (map (fn [f] [f (into {} (map (juxt :node :cx)) (r f))])) fs))) (into {} (map (fn [f] [f (into {} (map (juxt :node :cx)) (r f))])) fs)))

View file

@ -4,6 +4,7 @@
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf] [arthur.domain.leaf :as leaf]
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.symbol :as symbol] [arthur.domain.symbol :as symbol]
[arthur.domain.trace :as trace] [arthur.domain.trace :as trace]
@ -58,15 +59,30 @@
(vec (array-seq (trace/photo-matrix (symbol/world-of r :head) h @store (vec (array-seq (trace/photo-matrix (symbol/world-of r :head) h @store
(trace/photo-frame (trace/of h) f) 1000))))) (trace/photo-frame (trace/of h) f) 1000)))))
(defn- filmed
"Where a photo sitting exactly where it was filmed must land: the face's OWN
placement, over image height, and nothing else.
`:place` carries the source-to-stage mapping now, above `:head` — so a face
opened in its own tab draws at the size it is on the stage, and its photo has
to come with it. That is the whole reason the mapping belongs to the face: the
head still cancels out, which is what these assertions are about, but it
cancels against a placement rather than against nothing."
[c f]
(let [r (symbol/resolver (clip/symbol c :face-1) @store pal/index-of nil)]
(r f)
(vec (array-seq (node/mul! (node/mat) (symbol/world-of r :place)
(js/Float64Array. #js [0.001 0 0 0.001 0 0]))))))
(deftest the-photo-registers-to-the-face-it-was-filmed-with (deftest the-photo-registers-to-the-face-it-was-filmed-with
;; Face-1 is its own symbol and its head is its root, so a photo sitting where (let [a (traced {:frames [] :origin :continuous})
;; it was filmed is image pixels over image height and nothing else. b (traced {:frames [12] :origin :keys})
(let [filmed [0.001 0 0 0.001 0 0]] d (traced {:frames [12] :origin :continuous})]
(is (near? filmed (photo-at (traced {:frames [] :origin :continuous}) 30)) (is (near? (filmed a 30) (photo-at a 30))
"a head showing the photo's own frame cancels out") "a head showing the photo's own frame cancels out")
(is (near? filmed (photo-at (traced {:frames [12] :origin :keys}) 30)) (is (near? (filmed b 30) (photo-at b 30))
"held at the trace frame, face and photo both stand still") "held at the trace frame, face and photo both stand still")
(is (not (near? filmed (photo-at (traced {:frames [12] :origin :continuous}) 30))) (is (not (near? (filmed d 30) (photo-at d 30)))
"a continuous head carries the held photo along with it"))) "a continuous head carries the held photo along with it")))
(defn- wrapped (defn- wrapped

View file

@ -86,13 +86,19 @@
(is (empty? (clip/problems c)) (str spec ": " (pr-str (clip/problems c))))))) (is (empty? (clip/problems c)) (str spec ": " (pr-str (clip/problems c)))))))
(deftest the-tree-is-the-one-the-model-specifies (deftest the-tree-is-the-one-the-model-specifies
(is (= [:face :root] (symbol/lineage (:nodes (clip/symbol @clip* :main)) :face))) ;; :main HOLDS INSTANCES AND NOTHING ELSE. The source-to-stage placement used
;; to be a `:face` group here, above them all; it is each face's own `:place`
;; now, so the take is a container that knows nothing and a face is the right
;; size wherever it is put. Same transform, same subtree, one level lower.
(is (= [:face-1 :root] (symbol/lineage (:nodes (clip/symbol @clip* :main)) :face-1)))
(is (nil? (get-in @clip* [:symbols :main :nodes :face])))
(is (= #{:face-1} (node/sources (get-in @clip* [:symbols :main :nodes :face-1])))) (is (= #{:face-1} (node/sources (get-in @clip* [:symbols :main :nodes :face-1]))))
(is (= [:head] (symbol/lineage (:nodes @sym*) :head))) (is (= [:place] (symbol/lineage (:nodes @sym*) :place)))
(is (= [:mouth :head] (symbol/lineage (:nodes @sym*) :mouth))) (is (= [:head :place] (symbol/lineage (:nodes @sym*) :head)))
(is (= [:mouth-in :mouth :head] (symbol/lineage (:nodes @sym*) :mouth-in))) (is (= [:mouth :head :place] (symbol/lineage (:nodes @sym*) :mouth)))
(is (= [:mouth-in :mouth :head :place] (symbol/lineage (:nodes @sym*) :mouth-in)))
(is (= {:mode :map :expose 2} (:time (node :root)))) (is (= {:mode :map :expose 2} (:time (node :root))))
(is (every? #(nil? (:time (node %))) [:face :head :mouth :mouth-in]))) (is (every? #(nil? (:time (node %))) [:place :head :mouth :mouth-in])))
(deftest geometry-is-flat-and-dense-and-the-interior-shares-the-outline-s-block (deftest geometry-is-flat-and-dense-and-the-interior-shares-the-outline-s-block
(doseq [id [:mouth :mouth-in]] (doseq [id [:mouth :mouth-in]]
@ -121,11 +127,11 @@
(abs (- a b))))] (abs (- a b))))]
(is (<= gap (/ q 2)) (is (<= gap (/ q 2))
(str "worst quantisation error " gap " against a quantum of " q)) (str "worst quantisation error " gap " against a quantum of " q))
;; And in stage pixels, which is the unit anyone can judge. `:face`'s scale is ;; And in stage pixels, which is the unit anyone can judge. `:place`'s scale is
;; stage px per image height, so the whole quantum is q·k — a twentieth of a ;; stage px per image height, so the whole quantum is q·k — a twentieth of a
;; pixel at this placement, two orders below anything the rasteriser can ;; pixel at this placement, two orders below anything the rasteriser can
;; express, which is the argument for Int16 geometry stated as a measurement. ;; express, which is the argument for Int16 geometry stated as a measurement.
(let [k (first (:value (chan :face [:xform :scale])))] (let [k (first (:value (chan :place [:xform :scale])))]
(is (< (* q k) 0.1) (is (< (* q k) 0.1)
(str "the quantum is " (* q k) " stage pixels at " k " px per image height")) (str "the quantum is " (* q k) " stage pixels at " k " px per image height"))
(is (< (* gap k) (* q k)))))) (is (< (* gap k) (* q k))))))
@ -190,15 +196,15 @@
(deftest head-local-geometry-composed-through-head-and-face-lands-on-the-stage (deftest head-local-geometry-composed-through-head-and-face-lands-on-the-stage
;; The end-to-end claim of the split, asserted against the OPS the resolver ;; The end-to-end claim of the split, asserted against the OPS the resolver
;; actually emits rather than against an intermediate: stored head-local, put ;; actually emits rather than against an intermediate: stored head-local, put
;; back through `:head`, placed by `:face`, the mouth is where the composition ;; back through `:head`, placed by `:place`, the mouth is where the composition
;; of the two says it is. A test that recomputed the chain would only be ;; of the two says it is. A test that recomputed the chain would only be
;; checking arithmetic against itself; this checks `node/local!`, `node/world!` ;; checking arithmetic against itself; this checks `node/local!`, `node/world!`
;; and `emit` as well. ;; and `emit` as well.
(let [c (freeze/head-mode {} @frozen) (let [c (freeze/head-mode {} @frozen)
res (clip/resolver c :main @store pal/index-of nil) res (clip/resolver c :main @store pal/index-of nil)
k (first (:value (chan :face [:xform :scale]))) k (first (:value (chan :place [:xform :scale])))
anc (:value (chan :face [:xform :anchor])) anc (:value (chan :place [:xform :anchor]))
pos (:value (chan :face [:xform :pos])) pos (:value (chan :place [:xform :pos]))
tfs (:transforms @take/measured)] tfs (:transforms @take/measured)]
(doseq [f (range 0 take/frames 13)] (doseq [f (range 0 take/frames 13)]
(let [ops (res f) (let [ops (res f)
@ -217,7 +223,7 @@
;; the fit removed the head's motion, so putting it back is the fit's ;; the fit removed the head's motion, so putting it back is the fit's
;; inverse. Node :head carries exactly this. ;; inverse. Node :head carries exactly this.
im (geom/apply-sim (freeze/invert (nth tfs ef)) g) im (geom/apply-sim (freeze/invert (nth tfs ef)) g)
;; And where :face puts it: scaled about the anchor, then translated, ;; And where :place puts it: scaled about the anchor, then translated,
;; which is p ↦ k(p - anchor) + anchor + pos. ;; which is p ↦ k(p - anchor) + anchor + pos.
wx (+ (* k (- (:x im) (nth anc 0))) (nth anc 0) (nth pos 0)) wx (+ (* k (- (:x im) (nth anc 0))) (nth anc 0) (nth pos 0))
wy (+ (* k (- (:y im) (nth anc 1))) (nth anc 1) (nth pos 1))] wy (+ (* k (- (:y im) (nth anc 1))) (nth anc 1) (nth pos 1))]
@ -272,8 +278,8 @@
b (freeze/head-mode {:trace {:origin :start}} @frozen) b (freeze/head-mode {:trace {:origin :start}} @frozen)
c (freeze/head-mode {:trace {:origin :keys :frames [0 40]}} @frozen)] c (freeze/head-mode {:trace {:origin :keys :frames [0 40]}} @frozen)]
(doseq [x [b c]] (doseq [x [b c]]
(is (= (get (nodes a) :face) (get (nodes x) :face)) (is (= (get (nodes a) :place) (get (nodes x) :place))
":face moved") ":place moved")
(is (= (dissoc (nodes a) :head) (dissoc (nodes x) :head)) (is (= (dissoc (nodes a) :head) (dissoc (nodes x) :head))
"a node other than :head changed") "a node other than :head changed")
;; The measurement does not go away when the head is locked: always measure, ;; The measurement does not go away when the head is locked: always measure,
@ -304,7 +310,7 @@
;; transform on a node, which a hand can revise, and it claims no generator that ;; transform on a node, which a hand can revise, and it claims no generator that
;; would offer to overwrite it. ;; would offer to overwrite it.
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale] [:xform :anchor]]] (doseq [path [[:xform :pos] [:xform :rot] [:xform :scale] [:xform :anchor]]]
(let [c (get (node/channels (node :face)) path)] (let [c (get (node/channels (node :place)) path)]
(is (= :framed (ch/describe c)) (str path " is not framed")) (is (= :framed (ch/describe c)) (str path " is not framed"))
(is (nil? (:generated c)) (str path " claims provenance"))))) (is (nil? (:generated c)) (str path " claims provenance")))))
@ -324,8 +330,8 @@
(let [c @clip*] (let [c @clip*]
(doseq [sid [:main :face-1] (doseq [sid [:main :face-1]
id (keys (get-in c [:symbols sid :nodes])) id (keys (get-in c [:symbols sid :nodes]))
;; `:face` authors its own in `face-placement`, upstream of this. ;; `:place` authors its own in `face-placement`, upstream of this.
:when (not= [:main :face] [sid id])] :when (not= [:face-1 :place] [sid id])]
(let [n (get-in c [:symbols sid :nodes id]) (let [n (get-in c [:symbols sid :nodes id])
frames (range (get-in c [:symbols sid :frames])) frames (range (get-in c [:symbols sid :frames]))
anchor (:value (get-in n [:channels [:xform :anchor]])) anchor (:value (get-in n [:channels [:xform :anchor]]))
@ -364,11 +370,11 @@
;; differently, it has been applied to a node whose transform is not the ;; differently, it has been applied to a node whose transform is not the
;; identity, which is the one way it could go wrong. ;; identity, which is the one way it could go wrong.
(let [c @clip* (let [c @clip*
;; Every node the freeze makes EXCEPT `:face`, whose anchor ;; Every node the freeze makes EXCEPT `:place`, whose anchor
;; `face-placement` authors — which is the set `pivoted` writes. ;; `face-placement` authors — which is the set `pivoted` writes.
every (for [sid [:main :face-1] every (for [sid [:main :face-1]
id (keys (get-in c [:symbols sid :nodes])) id (keys (get-in c [:symbols sid :nodes]))
:when (and (not= [:main :face] [sid id]) :when (and (not= [:face-1 :place] [sid id])
(seq (get-in c [:symbols sid :nodes id :channels])))] (seq (get-in c [:symbols sid :nodes id :channels])))]
[sid id]) [sid id])
anchors #(into {} (for [[sid id] every] anchors #(into {} (for [[sid id] every]
@ -385,13 +391,13 @@
(is (= (render bare f) (render again f)) (is (= (render bare f) (render again f))
(str "frame " f " draws differently once every part has a pivot"))))) (str "frame " f " draws differently once every part has a pivot")))))
(deftest the-face-puts-the-head-s-centre-where-it-says-it-does (deftest the-place-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 + 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 ;; `:anchor` the registration point: scale and rotation happen about the head's
;; centre rather than about the corner of the footage, where MediaPipe's ;; centre rather than about the corner of the footage, where MediaPipe's
;; normalised space has its origin. ;; normalised space has its origin.
(let [anc (:value (chan :face [:xform :anchor])) (let [anc (:value (chan :place [:xform :anchor]))
pos (:value (chan :face [:xform :pos])) pos (:value (chan :place [:xform :pos]))
c (geom/centroid (:ref @take/measured))] c (geom/centroid (:ref @take/measured))]
(is (< (abs (- (nth anc 0) (:x c))) 1e-12)) (is (< (abs (- (nth anc 0) (:x c))) 1e-12))
(is (< (abs (- (nth anc 1) (:y c))) 1e-12)) (is (< (abs (- (nth anc 1) (:y c))) 1e-12))
@ -418,8 +424,8 @@
(vec (array-seq (:data (get (:store big) (key-of (:clip big))))))) (vec (array-seq (:data (get (:store big) (key-of (:clip big)))))))
"the geometry is the same numbers at either stage size") "the geometry is the same numbers at either stage size")
(is (not= (:value (get-in (nodes (:clip big)) (is (not= (:value (get-in (nodes (:clip big))
[:face :channels [:xform :scale]])) [:place :channels [:xform :scale]]))
(:value (chan :face [:xform :scale])))))) (:value (chan :place [:xform :scale]))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; the aperture, as [:vis] ;; the aperture, as [:vis]

View file

@ -177,7 +177,7 @@
flat (assoc-in clip [:symbols :main :nodes] flat (assoc-in clip [:symbols :main :nodes]
(merge (dissoc (:nodes (clip/symbol clip :main)) :face-1) (merge (dissoc (:nodes (clip/symbol clip :main)) :face-1)
(assoc-in (get-in clip [:symbols :face-1 :nodes]) (assoc-in (get-in clip [:symbols :face-1 :nodes])
[:head :parent] :face))) [:head :parent] :place)))
nested (clip/resolver clip :main store pal/index-of nil) nested (clip/resolver clip :main store pal/index-of nil)
reference (clip/resolver flat :main store pal/index-of nil)] reference (clip/resolver flat :main store pal/index-of nil)]
(doseq [f [0 1 7 20 39]] (doseq [f [0 1 7 20 39]]

View file

@ -46,8 +46,8 @@
(channel after :iris-l [:xform :pos]))) (channel after :iris-l [:xform :pos])))
(is (= (channel before :mouth [:geom :pts]) (is (= (channel before :mouth [:geom :pts])
(channel after :mouth [:geom :pts]))) (channel after :mouth [:geom :pts])))
(is (= (get-in before [:clip :symbols :main :nodes :face]) (is (= (get-in before [:clip :symbols :face-1 :nodes :place])
(get-in after [:clip :symbols :main :nodes :face]))) (get-in after [:clip :symbols :face-1 :nodes :place])))
(is (= (channel (full-at {:gaze-gain 2}) :iris-r [:xform :pos]) (is (= (channel (full-at {:gaze-gain 2}) :iris-r [:xform :pos])
(channel after :iris-r [:xform :pos]))))) (channel after :iris-r [:xform :pos])))))
@ -85,8 +85,8 @@
(get-in after [:clip :symbols :face-1 :nodes :head :measured]))) (get-in after [:clip :symbols :face-1 :nodes :head :measured])))
(is (= (get-in full [:clip :symbols :face-1 :nodes :head :measured]) (is (= (get-in full [:clip :symbols :face-1 :nodes :head :measured])
(get-in after [:clip :symbols :face-1 :nodes :head :measured]))) (get-in after [:clip :symbols :face-1 :nodes :head :measured])))
(is (= (get-in before [:clip :symbols :main :nodes :face]) (is (= (get-in before [:clip :symbols :face-1 :nodes :place])
(get-in after [:clip :symbols :main :nodes :face]))))) (get-in after [:clip :symbols :face-1 :nodes :place])))))
(deftest contour-edit-does-not-rebuild-head-or-teeth (deftest contour-edit-does-not-rebuild-head-or-teeth
(let [before @initial (let [before @initial

View file

@ -75,7 +75,7 @@ try {
// its `aria-label`, which is also what a screen reader is told it is. // 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 // 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. // what creates things there, the transport strip holds what acts on a cel.
const bars = ['.loc', '.pane.time .pane-head']; const bars = ['.loc', '.pane.time .pane-head', '.section'];
const within = (suffix) => bars.map((b) => `${b} ${suffix}`).join(', '); const within = (suffix) => bars.map((b) => `${b} ${suffix}`).join(', ');
const named = label => const named = label =>
`(b => b.textContent.trim() === ${JSON.stringify(label)}` + `(b => b.textContent.trim() === ${JSON.stringify(label)}` +
@ -113,7 +113,10 @@ try {
await sleep(180); await sleep(180);
await shut(); await shut();
}; };
const shot = async () => (await evaluate('laneSnapshot()')); // UUIDs expose a mutable hash cache through clj->js; compare their identity,
// not that implementation detail, when asserting exact undo restoration.
const shot = async () => JSON.parse(JSON.stringify(await evaluate('laneSnapshot()'),
(_key, value) => value?.uuid ?? value));
const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance') const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance')
.sort((a, b) => a.time.at - b.time.at); .sort((a, b) => a.time.at - b.time.at);
await click('lane'); await click('lane');
@ -285,7 +288,7 @@ try {
// only appears once a sheet has more than one lane. // only appears once a sheet has more than one lane.
await click('timeline'); await click('timeline');
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 5); assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 5);
await click('+ lane'); await click('lane');
await click('cel sheet'); await click('cel sheet');
assert.equal(await evaluate('document.querySelectorAll(".cs-head:not(.cs-frame)").length'), 2); assert.equal(await evaluate('document.querySelectorAll(".cs-head:not(.cs-frame)").length'), 2);
await evaluate(`([...document.querySelectorAll('.cs-cell')].slice(0, 2) await evaluate(`([...document.querySelectorAll('.cs-cell')].slice(0, 2)
@ -301,6 +304,76 @@ try {
'clicking a gap selects that column before overwrite'); 'clicking a gap selects that column before overwrite');
assert.equal(s.history.done.length, before.history.done.length + 12, assert.equal(s.history.done.length, before.history.done.length + 12,
'correction, lane creation, and overwrite are separate undo steps'); 'correction, lane creation, and overwrite are separate undo steps');
// Real pointer capture and keyboard events exercise the spreadsheet surface.
const cellAt = async (c, f) => evaluate(`(() => {
const el = document.querySelector('[data-cs-col="${c}"][data-cs-frame="${f}"]');
el.scrollIntoView({block: 'center'});
const r = el.getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
})()`);
const mouse = (type, p) => send('Input.dispatchMouseEvent', {
type, ...p, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1,
});
const key = async (key, extra = {}) => {
await send('Input.dispatchKeyEvent', {type: 'keyDown', key, ...extra});
await send('Input.dispatchKeyEvent', {type: 'keyUp', key, ...extra});
await sleep(120);
};
const start = await cellAt(0, 0);
await mouse('mousePressed', start);
await mouse('mouseMoved', await cellAt(1, 2));
await mouse('mouseReleased', await cellAt(1, 2));
await sleep(150);
assert.equal(await evaluate('document.querySelectorAll(".cs-cell.selected").length'), 6);
await key('c', {modifiers: 2});
const dest = await cellAt(0, 10);
await mouse('mousePressed', dest);
await mouse('mouseReleased', dest);
await sleep(120);
const beforePaste = await shot();
await key('v', {modifiers: 2});
const pasted = await shot();
assert.equal(pasted.history.done.length, beforePaste.history.done.length + 1,
'multi-lane paste is one undo step');
assert.deepEqual(Object.keys(pasted.clip.symbols), Object.keys(beforePaste.clip.symbols),
'copy reuses the drawings');
await key('z', {modifiers: 2});
assert.deepEqual((await shot()).clip, beforePaste.clip, 'undo restores the entire rectangle');
await key('ArrowDown', {modifiers: 8});
assert.equal(await evaluate('document.querySelectorAll(".cs-cell.selected").length'), 2);
const first = await cellAt(0, 0);
await mouse('mousePressed', first);
await mouse('mouseReleased', first);
await sleep(150);
const handle = await evaluate(`(() => {
const r = document.querySelector('.cs-fill-handle').getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
})()`);
const beforeHold = await shot();
await mouse('mousePressed', handle);
await mouse('mouseMoved', await cellAt(0, 4));
await mouse('mouseReleased', await cellAt(0, 4));
await sleep(150);
const afterHold = await shot();
assert.equal(afterHold.history.done.length, beforeHold.history.done.length + 1,
'a hold drag commits once');
assert.equal(instances(afterHold).filter(n => {
const old = instances(beforeHold).find(o => o.id === n.id);
return old && n.span[1] !== old.span[1] && n.time.at + n.span[1] === 5;
}).length, 1, 'the dragged hold ends at the previewed boundary');
await key('z', {modifiers: 2});
assert.deepEqual((await shot()).clip, beforeHold.clip);
await key('Delete');
const cleared = await shot();
assert.equal(cleared.history.done.length, beforeHold.history.done.length + 1,
'Delete clears selected frames in one step, without the global node-delete handler');
assert.equal(cleared.clip.symbols.main.frames, beforeHold.clip.symbols.main.frames);
await key('z', {modifiers: 2});
assert.deepEqual((await shot()).clip, beforeHold.clip);
await key('x', {modifiers: 2});
assert.equal((await shot()).history.done.length, beforeHold.history.done.length + 1);
await key('z', {modifiers: 2});
assert.deepEqual((await shot()).clip, beforeHold.clip, 'cut is independently undoable');
assert.equal(errors.length, 0, JSON.stringify(errors)); assert.equal(errors.length, 0, JSON.stringify(errors));
console.log('PASS: lane commands and corrections agree from timeline and cel sheet; no server writes'); console.log('PASS: lane commands and corrections agree from timeline and cel sheet; no server writes');
} finally { } finally {

View file

@ -1160,6 +1160,14 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.cs-cell:hover { background: var(--sel-bg); } .cs-cell:hover { background: var(--sel-bg); }
.cs-cell.selected { background: var(--sel-bg); color: var(--sel); font-weight: 600; } .cs-cell.selected { background: var(--sel-bg); color: var(--sel); font-weight: 600; }
.cs-head.selected { background: var(--sel-bg); color: var(--sel); } .cs-head.selected { background: var(--sel-bg); color: var(--sel); }
.cel-sheet { user-select: none; touch-action: none; }
.cs-cell { position: relative; }
.cs-cell:focus-visible { outline: 2px solid var(--sel); outline-offset: -2px; }
.cs-help { padding: 6px 10px; color: var(--dim); font-size: 11px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cs-fill-handle { position: absolute; right: 0; bottom: 0; width: 9px; height: 9px;
background: var(--sel); border: 1px solid white; cursor: ns-resize; z-index: 2; }
.cs-preview { outline: 1px dashed var(--sel); outline-offset: -1px; }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
the video -> symbol dialog */ the video -> symbol dialog */