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:
parent
78fb120edd
commit
8d20097e61
5 changed files with 80 additions and 141 deletions
|
|
@ -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)))
|
|
||||||
|
|
|
||||||
|
|
@ -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")]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)))))))
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue