Keep overview keyframes passive and edit expanded automation lanes

This commit is contained in:
Olive Vaughn 2026-10-04 02:05:22 -04:00
parent 606055382b
commit 4d441ae606
12 changed files with 856 additions and 69 deletions

View file

@ -161,3 +161,45 @@
(is (nil? (get-in made [:symbols :main :nodes :plate])))
(is (nil? (get-in made [:symbols :main :nodes :child])))
(is (empty? (clip/problems made)))))
(defn- reparent-document []
(-> (fixture/document)
(update-in [:symbols :main] dissoc :display)
(assoc-in [:symbols :box] {:id :box :frames 40 :nodes {}})
(assoc-in [:symbols :main :nodes :target]
(fixture/cel :target :box 0 40 1))))
(deftest reparenting-a-forest-preserves-relative-times
(let [doc (reparent-document)
selections [(address :main :a [:a]) (address :main :b [:b])]
r (clipboard/move-many doc {} :main selections [:target] 6 0)]
(is (nil? (:refused r)))
(is (= [0 4] (mapv #(get-in r [:clip :symbols :box :nodes % :time :at]) [:a :b])))
(is (nil? (get-in r [:clip :symbols :main :nodes :a])))
(is (nil? (get-in r [:clip :symbols :main :nodes :b])))
(is (= [[:node :box :a [:target :a]] [:node :box :b [:target :b]]] (:selections r)))))
(deftest reparenting-refuses-the-entire-forest-on-a-cycle
(let [doc (reparent-document)
r (clipboard/move-many doc {} :main
[(address :main :a [:a]) (address :main :target [:target])]
[:target] 6 0)]
(is (:refused r))
(is (nil? (:clip r)))))
(deftest reparenting-into-a-lane-refuses-collision-atomically
(let [doc (-> (reparent-document)
(assoc-in [:symbols :box :display] :lane)
(assoc-in [:symbols :box :nodes :occupied] (fixture/cel :occupied :drawing-a 4 4 0)))
r (clipboard/move-many doc {} :main
[(address :main :a [:a]) (address :main :b [:b])]
[:target] 6 0)]
(is (:refused r))
(is (nil? (:clip r)))))
(deftest dropping-the-forest-shifts-it-as-a-unit
(let [r (clipboard/move-many (reparent-document) {} :main
[(address :main :a [:a]) (address :main :b [:b])]
[:target] 6 3)]
(is (nil? (:refused r)))
(is (= [3 7] (mapv #(get-in r [:clip :symbols :box :nodes % :time :at]) [:a :b])))))

View file

@ -0,0 +1,30 @@
(ns arthur.domain.keyframes-test
(:require [cljs.test :refer [deftest is]]
[arthur.domain.keyframes :as k]))
(def items [{:sid :main :id :a :channel [:xform :pos] :frame 2 :at 2 :scale 1}
{:sid :main :id :a :channel [:xform :pos] :frame 4 :at 4 :scale 1}])
(def document {:symbols {:main {:nodes {:a {:channels {[:xform :pos]
{:animated? true :interp :linear :keys {2 [2 0] 4 [4 0] 8 [8 0]}
:segments {2 :hold}}}}}}}})
(defn channel [d] (get-in d [:symbols :main :nodes :a :channels [:xform :pos]]))
(deftest simultaneous-moves-preserve-values-and-segments
(let [c (channel (k/edit-keys document items 2))]
(is (= {4 [2 0] 6 [4 0] 8 [8 0]} (:keys c)))
(is (= {4 :hold} (:segments c)))))
(deftest deleting-last-key-keeps-its-value
(let [d (k/edit-keys document items nil)
last-key (assoc (first items) :frame 8 :at 8)]
(is (= {8 [8 0]} (:keys (channel d))))
(is (= {:animated? false :value [8 0]} (channel (k/edit-keys d [last-key] nil))))))
(deftest moving-left-clamps-the-whole-selection
(is (= [0 2] (mapv :frame (k/shifted items -10))))
(is (= {0 [2 0] 2 [4 0] 8 [8 0]} (:keys (channel (k/edit-keys document items -10))))))
(deftest retimed-keys-snap-in-their-own-clock
(let [key (assoc (first items) :scale 2 :at 14)]
(is (= 3 (:frame (first (k/shifted [key] 2)))))
(is (= 16 (:at (first (k/shifted [key] 2)))))))

View file

@ -857,3 +857,25 @@
{:extent :grow-symbol :remainder-id :rest}))]
(is (empty? (symbol/overlaps (get-in mixed [:symbols :main]))))
(is (empty? (clip/problems mixed))))))
(deftest moving-a-selection-in-a-lane-is-simultaneous
(let [doc (document)
moved (:clip (nest/slide-many doc :main [[:a] [:b]] 1))]
(is (nil? moved))
(is (:refused (nest/slide-many doc :main [[:a] [:b]] 1))
"the whole group refuses collision with the unselected insert")))
(deftest moving-all-lane-clips-preserves-their-spans
(let [doc (document)
moved (:clip (nest/slide-many doc :main [[:a] [:b] [:insert]] 2))]
(is (some? moved))
(is (= [2 6 10] (mapv #(get-in moved [:symbols :main :nodes % :time :at]) [:a :b :insert])))
(is (= [0 4] (get-in moved [:symbols :main :nodes :a :span])))))
(deftest a-selected-instance-carries-its-selected-contents-once
(let [doc (document)
result (nest/slide-many doc :main [[:insert] [:insert :mark]] 2)]
(is (nil? (:refused result)))
(is (= 10 (get-in result [:clip :symbols :main :nodes :insert :time :at])))
(is (= (get-in doc [:symbols :wave :nodes])
(get-in result [:clip :symbols :wave :nodes])))))

View file

@ -0,0 +1,264 @@
// Browser smoke test for timeline keyframes and group editing. It uses the in-memory
// document and disables project routing, so it performs no server-side write.
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-timeline-'));
const port = 9336;
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++) {
try {
if (await evaluate('typeof arthur !== "undefined" && !!document.querySelector("canvas.stage")')) break;
} catch (error) {
if (!error.message.includes("Cannot find default execution context")) throw error;
}
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);
await evaluate(`(() => {
const k = cljs.core.keyword, v = cljs.core.vector, m = cljs.core.hash_map;
let c = arthur.domain.clip.blank();
const rect = (id, at, keys) => m(k('id'), k(id), k('kind'), k('rect'), k('z'), id,
k('time'), m(k('at'), at, k('rate'), 1), k('span'), v(0, 20),
k('channels'), keys ? m(v(k('xform'), k('pos')), arthur.domain.channel.keyed(m(2, v(2,0), 4, v(4,0)), k('linear'))) : m());
const target = m(k('id'), k('target'), k('kind'), k('instance'), k('z'), 'z',
k('source'), m(k('symbol'), k('box')), k('span'), v(0, 60),
k('time'), m(k('at'), 0, k('rate'), 1),
k('playback'), m(k('in'), 0, k('speed'), 1, k('end'), k('stop')));
c = cljs.core.assoc_in(c, v(k('symbols'), k('box')), m(k('id'), k('box'), k('frames'), 60, k('nodes'), m()));
c = cljs.core.assoc_in(c, v(k('symbols'), k('main'), k('nodes')), m(k('a'), rect('a',0,true), k('b'), rect('b',4,false), k('target'), target));
const id = arthur.footage.store.install_BANG_(m(k('clip'), c, k('store'), m()), 'timeline-browser');
const a = v(k('node'),k('main'),k('a'),v(k('a'))), b = v(k('node'),k('main'),k('b'),v(k('b')));
cljs.core.swap_BANG_(re_frame.db.app_db, db => cljs.core.assoc(db,
k('clip/current'), id, k('paint/revision'), 100,
k('ui'), m(k('open'),k('main'),k('selection'), b,k('selections'),v(a,b),k('expanded'),cljs.core.hash_set(v(k('a')))),
k('playback'),m(k('frame'),0,k('playing?'),false)));
return true;
})()`);
await sleep(250);
const shot = async () => JSON.parse(JSON.stringify((await evaluate('laneSnapshot()')).clip,
(key, value) => key === 'channels' && Object.keys(value).length === 0 ? undefined : value));
assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.automation) button.tl-key").length'), 0,
'symbol overview keyframes are passive');
assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.automation) .tl-key").length'), 2,
'symbol overview still shows keyframe ticks');
const initial = await shot();
await evaluate(`(() => {
const track = document.querySelector('.tl-span.on').closest('.tl-track');
track.focus(); track.dispatchEvent(new KeyboardEvent('keydown', {key:'ArrowRight',bubbles:true})); return true;
})()`);
await sleep(100);
assert.equal((await shot()).symbols.main.nodes.a.time.at, 1);
assert.equal((await shot()).symbols.main.nodes.b.time.at, 5);
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await sleep(100);
assert.deepEqual(await shot(), initial, 'group shift undoes atomically');
await evaluate(`(() => {
const span=document.querySelector('.tl-span.on'), track=span.closest('.tl-track');
const box=track.getBoundingClientRect(), x=box.left+box.width*10/120, dx=box.width*2/120;
span.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:20,clientX:x}));
track.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:20,clientX:x+dx}));
track.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:20,clientX:x+dx}));
return true;
})()`);
await sleep(150);
assert.equal((await shot()).symbols.main.nodes.a.time.at, 2, 'drag shifts the first selected node');
assert.equal((await shot()).symbols.main.nodes.b.time.at, 6, 'drag shifts the other selected node');
assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 2,
'group dragging preserves selection');
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await sleep(100);
assert.deepEqual(await shot(), initial, 'group dragging undoes atomically');
assert.equal(await evaluate('document.querySelectorAll("button.tl-key").length'), 2);
await evaluate(`(() => {
const keys = [...document.querySelectorAll('button.tl-key')];
for (let i=0;i<2;i++) {
keys[i].dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:i+1,clientX:200+i*20,shiftKey:!!i}));
keys[i].dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:i+1,clientX:200+i*20,shiftKey:!!i}));
} return true;
})()`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 2);
await evaluate(`(() => {
const key=document.querySelector('button.tl-key.selected');
const box=key.parentElement.getBoundingClientRect(), x=box.left+box.width*2/120;
key.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:21,clientX:x}));
key.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:21,clientX:x+box.width*2/120}));
key.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:21,clientX:x+box.width*2/120}));
return true;
})()`);
await sleep(100);
assert.deepEqual(await evaluate(`(() => {
const k=cljs.core.keyword,v=cljs.core.vector;
const c=arthur.footage.store.entry(cljs.core.get(cljs.core.deref(re_frame.db.app_db),k('clip/current')));
return cljs.core.clj__GT_js(cljs.core.sort(cljs.core.keys(cljs.core.get_in(c,
v(k('clip'),k('symbols'),k('main'),k('nodes'),k('a'),k('channels'),v(k('xform'),k('pos')),k('keys'))))));
})()`), [4,6], 'drag moves both selected keys without losing colliding source frames');
await evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 2);
await evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown',{key:'Delete',bubbles:true}))`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key").length'), 0);
assert((await shot()).symbols.main.nodes.a, 'deleting keys preserves their node');
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await sleep(100);
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await sleep(100);
assert.deepEqual(await shot(), initial, 'key dragging, nudge and delete undo independently');
await evaluate(`(() => {
const k=cljs.core.keyword,v=cljs.core.vector;
const a=v(k('node'),k('main'),k('a'),v(k('a'))),b=v(k('node'),k('main'),k('b'),v(k('b')));
re_frame.core.dispatch_sync(v(k('arthur.events.ui/select-many'),v(a,b)));
arthur.ui.drag.row_BANG_(v(k('a')),k('rect'),a,v(a,b));
const row=[...document.querySelectorAll('.tl-label')].find(x=>x.textContent.includes('box'));
const r=row.getBoundingClientRect();
row.dispatchEvent(new DragEvent('drop',{bubbles:true,clientY:r.top+r.height/2,dataTransfer:new DataTransfer()}));
return true;
})()`);
await sleep(150);
const moved=await shot();
assert(moved.symbols.box.nodes.a && moved.symbols.box.nodes.b, 'row drop reparents the entire group');
assert.equal(moved.symbols.box.nodes.b.time.at, 4);
assert(!moved.symbols.main.nodes.a && !moved.symbols.main.nodes.b);
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`);
await sleep(100);
assert.deepEqual(await shot(), initial, 'group reparenting is one undo step');
await evaluate(`(() => {
const rows=[...document.querySelectorAll('.tl-label')].filter(x=>x.querySelector('.tl-delete') || /box|rect/.test(x.textContent));
rows[0].dispatchEvent(new MouseEvent('click',{bubbles:true}));
rows.at(-1).dispatchEvent(new MouseEvent('click',{bubbles:true,shiftKey:true}));
})()`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 3, 'Shift selects the intervening row headers');
await evaluate(`document.querySelectorAll('.tl-label.on')[1].dispatchEvent(new MouseEvent('click',{bubbles:true,metaKey:true}))`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 2, 'Command toggles just one row');
await evaluate(`(() => {
const k=cljs.core.keyword,v=cljs.core.vector,m=cljs.core.hash_map;
let c=cljs.core.get(arthur.footage.store.entry(cljs.core.get(cljs.core.deref(re_frame.db.app_db),k('clip/current'))),k('clip'));
c=cljs.core.assoc_in(c,v(k('symbols'),k('main'),k('nodes'),k('a'),k('channels'),v(k('xform'),k('scale'))),
arthur.domain.channel.keyed(m(2,v(1,1),4,v(2,2)),k('linear')));
const id=arthur.footage.store.install_BANG_(m(k('clip'),c,k('store'),m()),'selection-browser');
cljs.core.swap_BANG_(re_frame.db.app_db,db=>cljs.core.assoc(db,k('clip/current'),id,k('ui'),
m(k('open'),k('main'),k('expanded'),cljs.core.hash_set(v(k('a'))))));
})()`);
await sleep(200);
await evaluate(`(() => {
const tracks=document.querySelector('.tl-tracks');
const rows=[...tracks.querySelectorAll('.tl-track')].filter(x=>x.querySelectorAll('button.tl-key').length===2);
const first=rows[0], last=rows[1], b=first.getBoundingClientRect(), end=last.getBoundingClientRect();
first.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:50,clientX:b.left+1,clientY:b.top+1}));
tracks.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:50,clientX:b.left+b.width*6/120,clientY:end.bottom-1}));
tracks.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:50,clientX:b.left+b.width*6/120,clientY:end.bottom-1}));
})()`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 4, 'marquee selects keys across both automation rows');
await evaluate(`document.querySelector('.tl-tracks').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))`);
await sleep(100);
assert.deepEqual(await evaluate(`(() => {
const k=cljs.core.keyword,v=cljs.core.vector;
const c=arthur.footage.store.entry(cljs.core.get(cljs.core.deref(re_frame.db.app_db),k('clip/current')));
return ['pos','scale'].map(channel=>cljs.core.clj__GT_js(cljs.core.sort(cljs.core.keys(cljs.core.get_in(c,
v(k('clip'),k('symbols'),k('main'),k('nodes'),k('a'),k('channels'),v(k('xform'),k(channel)),k('keys')))))));
})()`), [[3,5],[3,5]], 'marquee selection nudges keys in both automations together');
await evaluate(`document.querySelector('.tl-tracks').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 0);
await evaluate(`(() => {
const rows=[...document.querySelectorAll('.tl-track')].filter(x=>x.querySelectorAll('button.tl-key').length===2);
const a=rows[0].querySelector('button.tl-key'), b=rows[1].querySelectorAll('button.tl-key')[1];
for (const [key,shift] of [[a,false],[b,true]]) {
const box=key.getBoundingClientRect();
for (const type of ['pointerdown','pointerup']) key.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerId:70,clientX:box.left,clientY:box.top,shiftKey:shift}));
}
})()`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 4, 'Shift-click selects a range across automation rows');
await evaluate(`(() => {
const key=[...document.querySelectorAll('.tl-track')].filter(x=>x.querySelectorAll('button.tl-key').length===2)[1].querySelectorAll('button.tl-key')[1];
const box=key.getBoundingClientRect();
for (const type of ['pointerdown','pointerup']) key.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerId:71,clientX:box.left,metaKey:true}));
})()`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 3, 'Command-click toggles one channel key');
await evaluate(`document.querySelector('.tl-tracks').dispatchEvent(new KeyboardEvent('keydown',{key:'a',metaKey:true,bubbles:true}))`);
await sleep(100);
assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 4, 'Command-A selects all visible keyframes');
assert.equal(errors.length, 0, JSON.stringify(errors));
console.log('PASS: passive overview ticks; automation keyframe selection, dragging, marquee, keyboard editing, and undo');
} finally {
if (ws?.readyState === WebSocket.OPEN) ws.close();
chrome.kill('SIGTERM');
await Promise.race([
new Promise(resolve => chrome.once('exit', resolve)),
sleep(2000).then(() => { if (chrome.exitCode === null) chrome.kill('SIGKILL'); }),
]);
try {
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (error) {
if (error.code !== 'ENOTEMPTY') throw error;
}
}