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
|
||||
"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})))
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}))))
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue