Add per-symbol onion skinning controls and cel ghosts
This commit is contained in:
parent
f2fc261221
commit
606055382b
8 changed files with 355 additions and 3 deletions
38
frontend/test/arthur/domain/onion_test.cljs
Normal file
38
frontend/test/arthur/domain/onion_test.cljs
Normal 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)))))))
|
||||
148
frontend/test/browser/onion.mjs
Normal file
148
frontend/test/browser/onion.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue