diff --git a/frontend/src/arthur/domain/onion.cljs b/frontend/src/arthur/domain/onion.cljs new file mode 100644 index 0000000..2715027 --- /dev/null +++ b/frontend/src/arthur/domain/onion.cljs @@ -0,0 +1,54 @@ +(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])) + +(def defaults {:on? false :before 1 :after 1 :opacity 0.25 :scope :selected}) + +(defn neighbors [events f 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))) diff --git a/frontend/src/arthur/ui/canvas.cljs b/frontend/src/arthur/ui/canvas.cljs index 91fc948..8add87d 100644 --- a/frontend/src/arthur/ui/canvas.cljs +++ b/frontend/src/arthur/ui/canvas.cljs @@ -74,3 +74,30 @@ (raster/->rgba r palette-rgb 1 (.-data img)) (.putImageData ctx img 0 0)) (.toDataURL el "image/png"))) + + +(defonce ^:private onion-surface (atom nil)) + +(defn clear-overlay! [el] + (when el + (.clearRect (.getContext el "2d") 0 0 (.-width el) (.-height el)))) + +(defn tint-layer! + "Composite a covered raster in one editor tint, keeping uncovered pixels clear." + [el {:keys [w h cov]} [red green blue] opacity] + (when el + (let [scratch (or @onion-surface + (reset! onion-surface (js/document.createElement "canvas"))) + _ (when (not= w (.-width scratch)) (set! (.-width scratch) w)) + _ (when (not= h (.-height scratch)) (set! (.-height scratch) h)) + ctx (.getContext scratch "2d") + img (image-data-for ctx scratch w h) + data (.-data img)] + (dotimes [i (* w h)] + (let [p (* i 4)] + (aset data p red) + (aset data (+ p 1) green) + (aset data (+ p 2) blue) + (aset data (+ p 3) (if (pos? (aget cov i)) (js/Math.round (* 255 opacity)) 0)))) + (.putImageData ctx img 0 0) + (.drawImage (.getContext el "2d") scratch 0 0)))) diff --git a/frontend/src/arthur/ui/layout.cljs b/frontend/src/arthur/ui/layout.cljs index 3092e20..4cf9c2c 100644 --- a/frontend/src/arthur/ui/layout.cljs +++ b/frontend/src/arthur/ui/layout.cljs @@ -20,7 +20,8 @@ drawers over the stage rather than columns beside it, which is the stylesheet's half of this (see `@media` in app.css), and a drawer that is shut should not be holding video thumbnails live." - (:require [re-frame.core :as rf])) + (:require [arthur.domain.onion :as onion] + [re-frame.core :as rf])) (def ^:private narrow? "A phone-shaped window, read ONCE at load. It decides what the layout opens @@ -167,6 +168,38 @@ (rf/reg-sub ::mat (fn [db _] (merge {:opacity 0.55} (get-in db [:ui :passepartout])))) (rf/reg-event-db ::mat (fn [db [_ k v]] (assoc-in db [:ui :passepartout k] v))) +(rf/reg-sub ::onion + (fn [db _] + (merge onion/defaults (get-in db [:ui :onion (:clip/current db) (get-in db [:ui :open])])))) +(rf/reg-event-db ::onion + (fn [db [_ k v]] + (assoc-in db [:ui :onion (:clip/current db) (get-in db [:ui :open]) k] v))) + +(defn onion-controls [] + (let [{:keys [on? before after opacity scope]} @(rf/subscribe [::onion])] + [:div.group.onion-controls + [:button {:class (when on? "on") :aria-pressed (boolean on?) + :title "Onion skin — neighboring cels of the selected lane" + :on-click #(rf/dispatch [::onion :on? (not on?)])} "onion"] + [:details + [:summary {:aria-label "Onion skin settings" :title "Onion skin settings"} "▾"] + [: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]]] + ^{: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))])))}]]) + [:label "Opacity" + [:input {:type "range" :min 0.05 :max 0.6 :step 0.05 :value opacity + :on-change #(rf/dispatch [::onion :opacity (js/parseFloat (.. % -target -value))])}]] + [:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]]]])) + (defn fit-stage! [] (when-let [area (.querySelector js/document ".stage-area")] (when-let [wrap (.querySelector area ".stage-wrap")] @@ -179,6 +212,7 @@ (defn stage-controls [] (let [{:keys [opacity]} @(rf/subscribe [::mat])] [:<> + [onion-controls] [:button {:on-click fit-stage! :title "Fit stage in view"} "fit"] [:details.passepartout-controls [:summary "passepartout"] diff --git a/frontend/src/arthur/ui/player.cljs b/frontend/src/arthur/ui/player.cljs index 156f9c8..a0c64e0 100644 --- a/frontend/src/arthur/ui/player.cljs +++ b/frontend/src/arthur/ui/player.cljs @@ -22,6 +22,7 @@ [arthur.domain.clip :as clip] [arthur.domain.cut :as cut] [arthur.domain.outline :as outline] + [arthur.domain.onion :as onion] [arthur.domain.palette :as pal] [arthur.domain.pick :as pick] [arthur.domain.raster :as raster] @@ -133,7 +134,12 @@ :tracing t :frame @(rf/subscribe [::sub/frame]) :playing? @(rf/subscribe [::sub/playing?]) - :pen pen}) + :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 ;; moves the playhead — so nothing else would ask for a redraw. ;; @@ -146,7 +152,10 @@ ;; 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))) + (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!)))))) ;; The brush or eraser stroke being painted: its mask, mutated in place as the @@ -170,6 +179,30 @@ ;; — a blank canvas under a transport reading perfectly correct. (when el (repaint!))) +(defn set-onion-canvas! [el] + (swap! state assoc :onion-canvas el) + (when el (repaint!))) + +(declare op-path) + +(defn- paint-onions! [f] + (let [{:keys [onion-canvas]} @state + {:keys [onion playing? resolver document store open selection width height]} @snapshot] + (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)] + ;; Consume each result before asking the resolver for another frame: + ;; its geometry buffers are shared with the current picture. + (let [ops (filter (fn [op] + (and (not= :trace (:kind op)) + (= path (vec (take (count path) (op-path op)))))) + (resolver frame)) + layer (raster/layer width height)] + (raster/draw-ops! layer ops) + (canvas/tint-layer! onion-canvas layer + (if (= :before direction) [235 75 75] [65 145 245]) + (:opacity onion))))))) + (defn- raster-for [w h] (let [{:keys [raster]} @state] (if (and raster (= w (:w raster)) (= h (:h raster))) @@ -276,6 +309,7 @@ ;; THE STAGE IS THE CLIP'S, not a constant. Project dimensions are ;; independent of the footage, so the size the frame is rasterised at comes ;; out of the document like everything else. + (paint-onions! f) (let [ras (raster-for width height) {traces true picture false} (group-by #(= :trace (:kind %)) (resolver f)) ;; A layer switched off, or all of them, is not on the stage at all: diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index 8108db8..5227841 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -763,6 +763,10 @@ :width w :height h :style {:width (str (* zoom w) "px") :height (str (* zoom h) "px")}}] + [:canvas.onion-skin {:ref #(player/set-onion-canvas! %) + :width w :height h + :style {:width (str (* zoom w) "px") + :height (str (* zoom h) "px")}}] [:canvas.tracing {:ref #(tracing/set-canvas! %) :width (* zoom w) :height (* zoom h)}] [:div.stage-shade {:style {:box-shadow (str "0 0 0 100vmax rgba(0,0,0," opacity ")")}}] diff --git a/frontend/test/arthur/domain/onion_test.cljs b/frontend/test/arthur/domain/onion_test.cljs new file mode 100644 index 0000000..6a47089 --- /dev/null +++ b/frontend/test/arthur/domain/onion_test.cljs @@ -0,0 +1,38 @@ +(ns arthur.domain.onion-test + (: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))))))) diff --git a/frontend/test/browser/onion.mjs b/frontend/test/browser/onion.mjs new file mode 100644 index 0000000..9c8b81c --- /dev/null +++ b/frontend/test/browser/onion.mjs @@ -0,0 +1,148 @@ +// Browser smoke test for onion controls and rendered ghost pixels. +// Uses an in-memory fixture and performs no server-side writes. +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import assert from 'node:assert/strict'; + +const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/'; +const profile = mkdtempSync(join(tmpdir(), 'arthur-onion-')); +const port = 9337; +const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [ + '--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run', + '--no-default-browser-check', '--mute-audio', '--window-size=1440,1000', + `--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url, +], { stdio: 'ignore' }); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +let ws; + +try { + let target; + for (let i = 0; i < 100 && !target; i++) { + await sleep(100); + try { + target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json())) + .find(t => t.type === 'page' && t.url.startsWith(url)); + } catch { /* Chromium is still starting. */ } + } + assert(target, 'browser exposes the editor page'); + ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); + + let serial = 0; + const pending = new Map(); + const errors = []; + ws.onmessage = ({ data }) => { + const msg = JSON.parse(data); + if (msg.method === 'Runtime.exceptionThrown') errors.push(msg.params.exceptionDetails); + if (msg.id && pending.has(msg.id)) { + const waiting = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) waiting.reject(new Error(JSON.stringify(msg.error))); + else waiting.resolve(msg.result); + } + }; + const send = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++serial; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + }); + const evaluate = async expression => { + const result = await send('Runtime.evaluate', { + expression, returnByValue: true, awaitPromise: true, + }); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + + await send('Runtime.enable'); + for (let i = 0; i < 100; i++) { + if (await evaluate('typeof arthur !== "undefined" && !!document.querySelector("canvas.stage")')) break; + await sleep(100); + } + await evaluate(`(() => { + const k = cljs.core.keyword; + cljs.core.swap_BANG_(re_frame.db.app_db, db => cljs.core.assoc(db, k('route'), k('local-test'))); + window.laneSnapshot = () => { + const db = cljs.core.deref(re_frame.db.app_db); + return cljs.core.clj__GT_js(arthur.footage.store.entry(cljs.core.get(db, k('clip/current')))); + }; + return true; + })()`); + await sleep(250); + + assert.equal(await evaluate(`getComputedStyle(document.querySelector('.onion-controls summary')).listStyleType`), 'none'); + assert.equal(await evaluate(`document.querySelector('.onion-controls summary').textContent`), '▾'); + await evaluate(`document.querySelector('.onion-controls > button').click()`); + await sleep(150); + assert.equal(await evaluate(`document.querySelector('.onion-controls > button').getAttribute('aria-pressed')`), 'true'); + await evaluate(`document.querySelector('.onion-controls summary').click()`); + await sleep(100); + assert.equal(await evaluate(`document.querySelector('.onion-controls details').open`), true); + await evaluate(`(() => { + const c = cljs.core, k = c.keyword, m = (...xs) => c.hash_map(...xs), v = (...xs) => c.vector(...xs); + const db = c.deref(re_frame.db.app_db); + const entry = arthur.footage.store.entry(c.get(db, k('clip/current'))); + const base = c.get(entry, k('clip')); + const cel = (id, drawing, at) => m(k('id'), k(id), k('kind'), k('instance'), k('z'), id, + k('source'), m(k('symbol'), k(drawing)), k('span'), v(0, 2), + k('time'), m(k('at'), at, k('rate'), 1), + k('playback'), m(k('in'), 0, k('speed'), 0, k('end'), k('stop'))); + const drawing = (id, x) => m(k('id'), k(id), k('frames'), 1, k('nodes'), + m(k('mark'), m(k('id'), k('mark'), k('kind'), k('rect'), k('z'), 'a', + k('channels'), m(v(k('style'), k('color')), arthur.domain.channel.framed(1), v(k('geom'), k('size')), arthur.domain.channel.framed(6), + v(k('xform'), k('pos')), arthur.domain.channel.framed(v(x, 10)))))); + const symbols = m(k('main'), m(k('id'), k('main'), k('frames'), 6, k('display'), k('lane'), + k('nodes'), m(k('a'), cel('a','ink-a',0), k('b'), cel('b','ink-b',2), k('c'), cel('c','ink-c',4))), + k('ink-a'), drawing('ink-a',10), k('ink-b'), drawing('ink-b',20), k('ink-c'), drawing('ink-c',30)); + const doc = c.assoc(base, k('symbols'), symbols); + const id = arthur.footage.store.install_BANG_(c.assoc(entry,k('clip'),doc),'onion-test'); + c.swap_BANG_(re_frame.db.app_db, db => { + db = c.assoc(db,k('clip/current'),id); + db = c.assoc_in(db,v(k('ui'),k('open')),k('main')); + db = c.assoc_in(db,v(k('ui'),k('solo')),c.hash_map()); + db = c.assoc_in(db,v(k('playback'),k('frame')),2); + db = c.assoc_in(db,v(k('playback'),k('playing?')),false); + return db; + }); + re_frame.core.dispatch_sync(v(k('arthur.ui.layout/onion'),k('on?'),true)); + arthur.ui.player.refresh_subs_BANG_(); + return true; + })()`); + await sleep(300); + const pixels = await evaluate(`(() => { + 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'); + 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)); + arthur.ui.player.refresh_subs_BANG_(); + arthur.ui.player.paint_BANG_(2); + return document.querySelector('canvas.onion-skin').getContext('2d').getImageData(10,10,1,1).data[3]; + })()`); + assert.equal(playbackAlpha, 0, 'ghosts clear during playback'); + const preferences = await evaluate(`(() => { + const c=cljs.core,k=c.keyword, read=()=>c.get(c.deref(re_frame.core.subscribe(c.vector(k('arthur.ui.layout/onion')))),k('on?')); + c.swap_BANG_(re_frame.db.app_db,db=>c.assoc_in(db,c.vector(k('ui'),k('open')),k('ink-a'))); + const other = read(); + c.swap_BANG_(re_frame.db.app_db,db=>c.assoc_in(db,c.vector(k('ui'),k('open')),k('main'))); + return [other,read()]; + })()`); + 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'); +} finally { + if (ws?.readyState === WebSocket.OPEN) ws.close(); + chrome.kill('SIGTERM'); + await new Promise(resolve => chrome.once('exit', resolve)); + try { + rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (error) { + if (error.code !== 'ENOTEMPTY') throw error; + } +} diff --git a/static/arthur/app.css b/static/arthur/app.css index 9805927..9915246 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -1737,3 +1737,16 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .view-popout input { width: 100%; } .passepartout-controls summary { cursor: pointer; } .stage-wrap { flex: 0 0 auto; } + +.onion-skin { position: absolute; inset: 0; pointer-events: none; image-rendering: pixelated; } +.onion-controls { display: inline-flex; align-items: center; } +.onion-controls > button { border-radius: 2px 0 0 2px; } +.onion-controls summary { + display: block; cursor: pointer; list-style: none; + color: var(--fg); background: var(--pane); border: 1px solid var(--line); + border-radius: 0 2px 2px 0; margin-left: -1px; padding: 1px 5px; +} +.onion-controls summary:hover, .onion-controls details[open] > summary { background: #fff; } +.onion-controls summary::-webkit-details-marker { display: none; } +.onion-controls .view-popout { line-height: 1.4; } +.onion-controls select { width: 100%; }