Add per-symbol onion skinning controls and cel ghosts

This commit is contained in:
Olive Vaughn 2026-10-04 02:04:00 -04:00
parent f2fc261221
commit 606055382b
8 changed files with 355 additions and 3 deletions

View file

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

View file

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

View file

@ -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"]

View file

@ -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:

View file

@ -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 ")")}}]

View file

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

View file

@ -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;
}
}