diff --git a/frontend/src/arthur/domain/onion.cljs b/frontend/src/arthur/domain/onion.cljs index 2715027..6b343e4 100644 --- a/frontend/src/arthur/domain/onion.cljs +++ b/frontend/src/arthur/domain/onion.cljs @@ -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}))) diff --git a/frontend/src/arthur/ui/layout.cljs b/frontend/src/arthur/ui/layout.cljs index 027367c..556fff7 100644 --- a/frontend/src/arthur/ui/layout.cljs +++ b/frontend/src/arthur/ui/layout.cljs @@ -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")] diff --git a/frontend/src/arthur/ui/player.cljs b/frontend/src/arthur/ui/player.cljs index a0c64e0..e848ab0 100644 --- a/frontend/src/arthur/ui/player.cljs +++ b/frontend/src/arthur/ui/player.cljs @@ -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] diff --git a/frontend/test/arthur/domain/onion_test.cljs b/frontend/test/arthur/domain/onion_test.cljs index 6a47089..b73c3e5 100644 --- a/frontend/test/arthur/domain/onion_test.cljs +++ b/frontend/test/arthur/domain/onion_test.cljs @@ -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})))) diff --git a/frontend/test/browser/onion.mjs b/frontend/test/browser/onion.mjs index bacdbf3..6da5a9e 100644 --- a/frontend/test/browser/onion.mjs +++ b/frontend/test/browser/onion.mjs @@ -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');