Repaint the stage after edits that move the selection; onion any symbol

The player's tracker derefed its inputs one by one inside `ratom/run!`. An
edit that also changed the selection ran it from the selection's change: it
read `::render/shown` while still stale, then pulled the dirty
`::render/clip`, which re-ran it nested with the new resolver — and the outer
run, finishing last, wrote the old one back. A deleted layer stayed on the
stage until something else ran the tracker. The snapshot is now one sub, so
the tracker derefs a single clean input and is never re-entered.

Onion skinning only ghosted cels in `:display :lane` symbols, so a drawing
made of keyed shapes showed nothing. A ghost is now just the picture the
resolver draws n frames back and ahead, limited to the selection when there
is one. The scope setting is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-10-04 18:35:04 -04:00
parent 78fb120edd
commit 8d20097e61
5 changed files with 80 additions and 141 deletions

View file

@ -1,54 +1,20 @@
(ns arthur.domain.onion (ns arthur.domain.onion
"Editor-only neighboring cel events, in output frames." "Editor-only ghosts of the picture a few frames either side of the playhead.
(:require [arthur.domain.clip :as clip]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.symbol :as symbol]))
(def defaults {:on? false :before 1 :after 1 :opacity 0.25 :scope :selected}) THE STAGE AT ANOTHER FRAME, nothing more. The resolver already draws any
frame, so a ghost is what it draws `n` frames back or ahead — whatever is
animating, by spans, keys or anything else, shows up without being looked
for.")
(defn neighbors [events f before after] (def defaults {:on? false :before 1 :after 1 :opacity 0.25})
(defn frames
"The output frames to ghost around frame `f` of a `length`-frame transport:
`before` back and `after` ahead, each tagged with its direction, nearest
first, and none off either end."
[f length {:keys [before after]}]
(concat (concat
(map #(assoc % :direction :before) (for [i (range 1 (inc before)) :let [g (- f i)] :when (<= 0 g)]
(take before (reverse (sort-by :start (filter #(<= (:end %) f) events))))) {:frame g :direction :before})
(map #(assoc % :direction :after) (for [i (range 1 (inc after)) :let [g (+ f i)] :when (< g length)]
(take after (sort-by :start (filter #(> (:start %) f) events)))))) {:frame g :direction :after})))
(defn samples [document store open selection f settings]
(let [own (clip/shown-frame document open f)
path (if (= :node (first selection)) (or (nth selection 3 nil) [(nth selection 2)]) [])
locations (for [n (range (inc (count path)))
:let [p (vec (take n path))
sid (if (empty? p) open (:sid (nest/inside document store open p own)))]
:when (symbol/lane? (clip/symbol document sid))]
{:sid sid :path p})
selected (last locations)
;; Whole-symbol scope includes visible lane placements, recursively.
walk (fn walk [sid p seen]
(when-not (contains? seen sid)
(let [sym (clip/symbol document sid)]
(if (symbol/lane? sym)
[{:sid sid :path p}]
(mapcat (fn [[id n]]
(when (node/source n)
(let [q (conj p id)
at (nest/inside document store open q own)]
(when (:sid at) (walk (:sid at) q (conj seen sid))))))
(:nodes sym))))))
lanes (if (= :symbol (:scope settings)) (walk open [] #{}) (when selected [selected]))]
(mapcat
(fn [{:keys [sid path]}]
(let [events (keep (fn [n]
(let [id (:id n)
q (conj path id)
t (nest/own-time document store open q own)]
(when (and (node/source n) (:span n) t (pos? (:rate t)))
(let [[lo hi] (:span n)
start (+ (:at t) (/ lo (:rate t)))
end (+ (:at t) (/ hi (:rate t)))]
{:start start :end end :path q
:frame (clip/first-output-frame document open (js/Math.ceil start))}))))
(symbol/children (:nodes (clip/symbol document sid))))]
(filter #(< -1 (:frame %) (clip/output-frames document open))
(neighbors events own (:before settings) (:after settings)))))
lanes)))

View file

@ -195,25 +195,20 @@
:on-change #(on-change (js/parseFloat (.. % -target -value)))}]]) :on-change #(on-change (js/parseFloat (.. % -target -value)))}]])
(defn onion-controls [] (defn onion-controls []
(let [{:keys [on? before after opacity scope]} @(rf/subscribe [::onion])] (let [{:keys [on? before after opacity]} @(rf/subscribe [::onion])]
[overlay "onion-controls" "onion" on? [overlay "onion-controls" "onion" on?
"Onion skin — neighboring cels of the selected lane" "Onion skin — the frames before and after"
#(rf/dispatch [::onion :on? (not on?)]) #(rf/dispatch [::onion :on? (not on?)])
(fn [_] (fn [_]
[:div.view-popout [:div.view-popout
[:label "Scope" (for [[k label v] [[:before "Before" before] [:after "After" after]]]
[:select {:value (name scope)
:on-change #(rf/dispatch [::onion :scope (keyword (.. % -target -value))])}
[:option {:value "selected"} "Selected lane"]
[:option {:value "symbol"} "Whole symbol"]]]
(for [[k label v] [[:before "Previous cels" before] [:after "Next cels" after]]]
^{:key k} [:label label ^{:key k} [:label label
[:input {:type "number" :min 0 :max 5 :value v [:input {:type "number" :min 0 :max 5 :value v
:on-change #(let [n (js/parseInt (.. % -target -value) 10)] :on-change #(let [n (js/parseInt (.. % -target -value) 10)]
(when-not (js/isNaN n) (when-not (js/isNaN n)
(rf/dispatch [::onion k (max 0 (min 5 n))])))}]]) (rf/dispatch [::onion k (max 0 (min 5 n))])))}]])
[slider "Opacity" 0.05 0.6 opacity #(rf/dispatch [::onion :opacity %])] [slider "Opacity" 0.05 0.6 opacity #(rf/dispatch [::onion :opacity %])]
[:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]])])) [:small "Before: red · after: blue. Only the selection, when there is one. Hidden during playback."]])]))
(defn fit-stage! [] (defn fit-stage! []
(when-let [area (.querySelector js/document ".stage-area")] (when-let [area (.querySelector js/document ".stage-area")]

View file

@ -102,6 +102,44 @@
(defn- repaint! [] (defn- repaint! []
(swap! state assoc :last -1)) (swap! state assoc :last -1))
(rf/reg-sub
::snapshot
;; ONE SUB, so the tracker below derefs one input that is never dirty when it
;; runs. It used to deref these one by one inside `ratom/run!`, and an edit
;; that also moves the selection ran it from the selection's change: it read
;; `::render/shown` while that was still the old resolver, then pulled the
;; dirty `::render/clip` further down, which re-ran it NESTED with the new one —
;; and the outer run, finishing last, wrote the old resolver back. The deleted
;; layer stayed on the stage until something else ran the tracker again. A
;; layer-3 sub is re-run from Reagent's queue instead, after its inputs.
(fn [_ _]
{:resolver (rf/subscribe [::render/shown])
:palette (rf/subscribe [::render/palette])
:ramp (rf/subscribe [::render/ramp])
:fps (rf/subscribe [::sub/fps])
:frames (rf/subscribe [::render/frames])
:width (rf/subscribe [::sub/width])
:height (rf/subscribe [::sub/height])
:mat (rf/subscribe [::layout/mat])
:tracing (rf/subscribe [::render/tracing])
:frame (rf/subscribe [::sub/frame])
:playing? (rf/subscribe [::sub/playing?])
;; The pen's draft is drawn INTO the picture, filled, so what is on screen
;; while placing points is the pixels a finished shape will be.
:draft (rf/subscribe [::ui-sub/draft])
:hover (rf/subscribe [::ui-sub/hover])
:tone (rf/subscribe [::ui-sub/tone])
:fit (rf/subscribe [::ui-sub/fit])
;; Where a new shape would land, so what is being drawn is drawn in that
;; symbol's stacking context.
:target (rf/subscribe [::ui-sub/creation-target])
:onion (rf/subscribe [::layout/onion])
:selection (rf/subscribe [::ui-sub/selection])})
(fn [{:keys [draft hover tone fit target] :as s} _]
(-> s
(dissoc :draft :hover :tone :fit :target)
(assoc :pen {:draft draft :hover hover :tone tone :fit fit :target (:path target)}))))
(defn refresh-subs! (defn refresh-subs!
"Build (or rebuild) the tracking reaction. Rebuilt on hot reload, because "Build (or rebuild) the tracking reaction. Rebuilt on hot reload, because
clearing the subscription cache orphans the reactions this holds." clearing the subscription cache orphans the reactions this holds."
@ -109,53 +147,18 @@
(some-> @tracker ratom/dispose!) (some-> @tracker ratom/dispose!)
(reset! tracker (reset! tracker
(ratom/run! (ratom/run!
(let [{was :resolver was-t :tracing :as before} @snapshot (let [before @snapshot
now @(rf/subscribe [::render/shown]) now (reset! snapshot @(rf/subscribe [::snapshot]))]
t @(rf/subscribe [::render/tracing])
;; The pen's draft is drawn INTO the picture, filled, so what
;; is on screen while placing points is the pixels a finished
;; shape will be.
pen {:draft @(rf/subscribe [::ui-sub/draft])
:hover @(rf/subscribe [::ui-sub/hover])
:tone @(rf/subscribe [::ui-sub/tone])
:fit @(rf/subscribe [::ui-sub/fit])
;; Where a new shape would land, so what is being drawn
;; is drawn in that symbol's stacking context.
:target (:path @(rf/subscribe [::ui-sub/creation-target]))}]
(reset! snapshot
{:resolver now
:palette @(rf/subscribe [::render/palette])
:ramp @(rf/subscribe [::render/ramp])
:fps @(rf/subscribe [::sub/fps])
:frames @(rf/subscribe [::render/frames])
:width @(rf/subscribe [::sub/width])
:height @(rf/subscribe [::sub/height])
:mat @(rf/subscribe [::layout/mat])
:tracing t
:frame @(rf/subscribe [::sub/frame])
:playing? @(rf/subscribe [::sub/playing?])
:pen pen
:onion @(rf/subscribe [::layout/onion])
:document @(rf/subscribe [::render/clip])
:store @(rf/subscribe [::render/store])
:open @(rf/subscribe [::render/open])
:selection @(rf/subscribe [::ui-sub/selection])})
;; A new resolver means a new scene or a new palette, and neither ;; A new resolver means a new scene or a new palette, and neither
;; moves the playhead — so nothing else would ask for a redraw. ;; moves the playhead — so nothing else would ask for a redraw.
;; ;;
;; Switching tracing on or off wants the same redraw for the same ;; Switching tracing on or off wants the same redraw for the same
;; reason, and it needs asking for SEPARATELY: it is a viewing aid ;; 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 ;; in the editor's own state, so it changes what the canvases should
;; show without touching the frame number OR the resolver. The ;; show without touching the frame number OR the resolver.
;; document and the store are left out of the comparison because (when-not (and (identical? (:resolver before) (:resolver now))
;; they are what a new resolver already means. (= (select-keys before [:tracing :pen :mat :onion :selection :playing?])
;; Comparing the whole map is as cheap as picking fields out of it: (select-keys now [:tracing :pen :mat :onion :selection :playing?])))
;; 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-t t) (= pen (:pen before)) (= (:mat before) (:mat @snapshot))
(= (:onion before) (:onion @snapshot))
(= (:selection before) (:selection @snapshot))
(= (:playing? before) (:playing? @snapshot)))
(repaint!)))))) (repaint!))))))
;; The brush or eraser stroke being painted: its mask, mutated in place as the ;; The brush or eraser stroke being painted: its mask, mutated in place as the
@ -185,12 +188,16 @@
(declare op-path) (declare op-path)
(defn- paint-onions! [f] (defn- paint-onions!
"Ghost the picture `onion/frames` either side of `f`: the selected node's ops
when a node is selected, everything otherwise."
[f]
(let [{:keys [onion-canvas]} @state (let [{:keys [onion-canvas]} @state
{:keys [onion playing? resolver document store open selection width height]} @snapshot] {:keys [onion playing? resolver frames selection width height]} @snapshot
path (if (= :node (first selection)) (or (nth selection 3 nil) [(nth selection 2)]) [])]
(canvas/clear-overlay! onion-canvas) (canvas/clear-overlay! onion-canvas)
(when (and onion-canvas (:on? onion) (not playing?)) (when (and onion-canvas (:on? onion) (not playing?))
(doseq [{:keys [frame path direction]} (onion/samples document store open selection f onion)] (doseq [{:keys [frame direction]} (onion/frames f frames onion)]
;; Consume each result before asking the resolver for another frame: ;; Consume each result before asking the resolver for another frame:
;; its geometry buffers are shared with the current picture. ;; its geometry buffers are shared with the current picture.
(let [ops (filter (fn [op] (let [ops (filter (fn [op]

View file

@ -2,37 +2,8 @@
(:require [cljs.test :refer [deftest is]] (:require [cljs.test :refer [deftest is]]
[arthur.domain.onion :as onion])) [arthur.domain.onion :as onion]))
(def events [{:start 0 :end 12 :drawing :a} (deftest frames-either-side-within-the-transport
{:start 15 :end 17 :drawing :a} (is (= [[4 :before] [3 :before] [6 :after]]
{:start 17 :end 19 :drawing :b} (mapv (juxt :frame :direction) (onion/frames 5 10 {:before 2 :after 1}))))
{:start 25 :end 30 :drawing :c}]) (is (= [[1 :after]] (mapv (juxt :frame :direction) (onion/frames 0 2 {:before 3 :after 3}))))
(is (empty? (onion/frames 5 10 {:before 0 :after 0}))))
(deftest neighbors-count-events-and-skip-gaps
(is (= [[0 :before] [17 :after]]
(mapv (juxt :start :direction) (onion/neighbors events 16 1 1))))
(is (= [0 15] (mapv :start (onion/neighbors events 13 1 1))))
(is (= [15 0 25] (mapv :start (onion/neighbors events 18 2 1))))
(is (empty? (onion/neighbors events 18 0 0))))
(def document
{:fps 24 :stage [32 32]
:symbols {:main {:frames 40 :fps 24 :nodes
{:lane {:id :lane :kind :instance :source {:symbol :drawings}
:span [0 40]}}}
:drawings {:frames 40 :fps 24 :display :lane :nodes
{:a {:id :a :kind :instance :source {:symbol :ink}
:span [0 12]}
:b {:id :b :kind :instance :source {:symbol :ink}
:span [0 2] :time {:at 15 :rate 1}}
:c {:id :c :kind :instance :source {:symbol :ink}
:span [0 2] :time {:at 17 :rate 1}}}}
:ink {:frames 1 :nodes {}}}})
(deftest selected-lane-and-symbol-scopes
(let [settings (assoc onion/defaults :on? true)]
(is (= [[0 [:lane :a]] [17 [:lane :c]]]
(mapv (juxt :frame :path)
(onion/samples document {} :main [:node :drawings :b [:lane :b]] 16 settings))))
(is (empty? (onion/samples document {} :main nil 16 settings)))
(is (= [0 17]
(mapv :frame (onion/samples document {} :main nil 16 (assoc settings :scope :symbol)))))))

View file

@ -117,9 +117,9 @@ try {
const ctx = document.querySelector('canvas.onion-skin').getContext('2d'); const ctx = document.querySelector('canvas.onion-skin').getContext('2d');
return [10,20,30].map(x => Array.from(ctx.getImageData(x,10,1,1).data)); return [10,20,30].map(x => Array.from(ctx.getImageData(x,10,1,1).data));
})()`); })()`);
assert(pixels[0][0] > pixels[0][2] && pixels[0][3] > 0, 'previous cel is red'); assert(pixels[0][0] > pixels[0][2] && pixels[0][3] > 0, 'the previous frame is red');
assert.equal(pixels[1][3], 0, 'current cel is excluded'); assert(pixels[1][2] > pixels[1][0] && pixels[1][3] > 0, 'the next frame is blue');
assert(pixels[2][2] > pixels[2][0] && pixels[2][3] > 0, 'next cel is blue'); assert.equal(pixels[2][3], 0, 'two frames ahead is not ghosted at after=1');
const playbackAlpha = await evaluate(`(() => { const playbackAlpha = await evaluate(`(() => {
const c=cljs.core, k=c.keyword; const c=cljs.core, k=c.keyword;
c.swap_BANG_(re_frame.db.app_db, db => c.assoc_in(db,c.vector(k('playback'),k('playing?')),true)); c.swap_BANG_(re_frame.db.app_db, db => c.assoc_in(db,c.vector(k('playback'),k('playing?')),true));
@ -137,7 +137,7 @@ try {
})()`); })()`);
assert.deepEqual(preferences, [false,true], 'preferences are remembered per symbol'); assert.deepEqual(preferences, [false,true], 'preferences are remembered per symbol');
assert.equal(errors.length, 0, JSON.stringify(errors)); assert.equal(errors.length, 0, JSON.stringify(errors));
console.log('PASS: onion controls, ghost colors, current-cel exclusion, playback hiding, per-symbol preferences'); console.log('PASS: onion controls, ghost colors, frames either side, playback hiding, per-symbol preferences');
} finally { } finally {
if (ws?.readyState === WebSocket.OPEN) ws.close(); if (ws?.readyState === WebSocket.OPEN) ws.close();
chrome.kill('SIGTERM'); chrome.kill('SIGTERM');