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

@ -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');