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
"Editor-only neighboring cel events, in output frames."
(:require [arthur.domain.clip :as clip]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.symbol :as symbol]))
"Editor-only ghosts of the picture a few frames either side of the playhead.
(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
(map #(assoc % :direction :before)
(take before (reverse (sort-by :start (filter #(<= (:end %) f) events)))))
(map #(assoc % :direction :after)
(take after (sort-by :start (filter #(> (:start %) f) events))))))
(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)))
(for [i (range 1 (inc before)) :let [g (- f i)] :when (<= 0 g)]
{:frame g :direction :before})
(for [i (range 1 (inc after)) :let [g (+ f i)] :when (< g length)]
{:frame g :direction :after})))

View file

@ -195,25 +195,20 @@
:on-change #(on-change (js/parseFloat (.. % -target -value)))}]])
(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?
"Onion skin — neighboring cels of the selected lane"
"Onion skin — the frames before and after"
#(rf/dispatch [::onion :on? (not on?)])
(fn [_]
[:div.view-popout
[:label "Scope"
[: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]]]
(for [[k label v] [[:before "Before" before] [:after "After" after]]]
^{:key k} [:label label
[:input {:type "number" :min 0 :max 5 :value v
:on-change #(let [n (js/parseInt (.. % -target -value) 10)]
(when-not (js/isNaN n)
(rf/dispatch [::onion k (max 0 (min 5 n))])))}]])
[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! []
(when-let [area (.querySelector js/document ".stage-area")]

View file

@ -102,6 +102,44 @@
(defn- repaint! []
(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!
"Build (or rebuild) the tracking reaction. Rebuilt on hot reload, because
clearing the subscription cache orphans the reactions this holds."
@ -109,53 +147,18 @@
(some-> @tracker ratom/dispose!)
(reset! tracker
(ratom/run!
(let [{was :resolver was-t :tracing :as before} @snapshot
now @(rf/subscribe [::render/shown])
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])})
(let [before @snapshot
now (reset! snapshot @(rf/subscribe [::snapshot]))]
;; A new resolver means a new scene or a new palette, and neither
;; moves the playhead — so nothing else would ask for a redraw.
;;
;; Switching tracing on or off wants the same redraw for the same
;; reason, and it needs asking for SEPARATELY: it is a viewing aid
;; in the editor's own state, so it changes what the canvases should
;; show without touching the frame number OR the resolver. The
;; document and the store are left out of the comparison because
;; they are what a new resolver already means.
;; Comparing the whole map is as cheap as picking fields out of it:
;; the document and the store it carries are the same OBJECTS unless
;; the resolver changed too, and that is tested first.
(when-not (and (identical? was now) (= was-t t) (= pen (:pen before)) (= (:mat before) (:mat @snapshot))
(= (:onion before) (:onion @snapshot))
(= (:selection before) (:selection @snapshot))
(= (:playing? before) (:playing? @snapshot)))
;; show without touching the frame number OR the resolver.
(when-not (and (identical? (:resolver before) (:resolver now))
(= (select-keys before [:tracing :pen :mat :onion :selection :playing?])
(select-keys now [:tracing :pen :mat :onion :selection :playing?])))
(repaint!))))))
;; The brush or eraser stroke being painted: its mask, mutated in place as the
@ -185,12 +188,16 @@
(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
{: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)
(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:
;; its geometry buffers are shared with the current picture.
(let [ops (filter (fn [op]

View file

@ -2,37 +2,8 @@
(:require [cljs.test :refer [deftest is]]
[arthur.domain.onion :as onion]))
(def events [{:start 0 :end 12 :drawing :a}
{:start 15 :end 17 :drawing :a}
{:start 17 :end 19 :drawing :b}
{:start 25 :end 30 :drawing :c}])
(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)))))))
(deftest frames-either-side-within-the-transport
(is (= [[4 :before] [3 :before] [6 :after]]
(mapv (juxt :frame :direction) (onion/frames 5 10 {:before 2 :after 1}))))
(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}))))

View file

@ -117,9 +117,9 @@ try {
const ctx = document.querySelector('canvas.onion-skin').getContext('2d');
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.equal(pixels[1][3], 0, 'current cel is excluded');
assert(pixels[2][2] > pixels[2][0] && pixels[2][3] > 0, 'next cel is blue');
assert(pixels[0][0] > pixels[0][2] && pixels[0][3] > 0, 'the previous frame is red');
assert(pixels[1][2] > pixels[1][0] && pixels[1][3] > 0, 'the next frame is blue');
assert.equal(pixels[2][3], 0, 'two frames ahead is not ghosted at after=1');
const playbackAlpha = await evaluate(`(() => {
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));
@ -137,7 +137,7 @@ try {
})()`);
assert.deepEqual(preferences, [false,true], 'preferences are remembered per symbol');
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 {
if (ws?.readyState === WebSocket.OPEN) ws.close();
chrome.kill('SIGTERM');