The timeline opens the whole document
Making a lane one row cost the thing a row was for. A clip stopped being a row, so there was no longer any way to open a clip and see what was inside it, and the inside of a drawing — the most ordinary thing in the document — became reachable only by opening it as its own tab. This is that capability back, from the root timeline, down as far as it goes. An expanded lane opens exactly ONE clip: the selected one. Its own keys, then the lanes and nodes of the symbol it places, then theirs, each mapped into this ruler by the recursive walk that was already there. Twelve clips in a lane still cost one row, and inspection costs one branch rather than twelve. Two things that only showed up once it ran. The portal is chosen by the whole LINEAGE of the selection and not by the selected id: selecting a shape inside the clip — or the end of its span — is still working inside that clip, and matching the id alone shut the portal the instant anything under it was touched. And selecting now waits for the pointer to come UP, because selecting on the way down re-drew the timeline before the gesture had said anything: it shut the portal holding the lane being dragged INTO, out from under the pointer. A HELD clip opens too, which the old row walk never did either. `source-time` is nil for a hold, so the walk stopped there and the contents of every drawing were invisible from here. Its rows are shown across the hold — which is when the node is on screen — and marked `:unmapped?`: no keys, and no draggable edges, because a frozen clock gives no frame inside it a place on this ruler. Refusing to place the keys is the honest half; refusing to show the rows was not. Double-clicking a clip opens the symbol it places as a tab, as double-clicking the same symbol in the pool does. That was already written and had never once run: the track captures the pointer for a slide, so the click and double-click that follow are delivered to the track and never to the block. The track now resolves them itself. Fixing the delivery exposed two more: `symbol/lineage` reported a `parent cycle` for any id in a symbol with NO nodes, because a one-element chain is longer than zero nodes — and opening a symbol left the selection pointing into the symbol being left, which the breadcrumb and the inspector then tried to resolve. The editor unmounted. Both are fixed where they were wrong, and the browser test asserts the editor is still standing afterwards. Audio is a clip in a lane like everything else. A dropped sound lands in one and is trimmed and moved by the same commands; a lane holds picture or sound and not both, which is the explicit capability the model asked for rather than a guess per frame. The refusal lives in the commands and not only in validation, because placement claims time: `blank` would have deleted the sound to make room for the picture and left a perfectly valid document behind. What is in a lane of the open symbol is drawn as a lane; what is nested inside a placed symbol is still flattened by `audio-tracks`, so no sound is on two rows. Everything that enters the timeline now enters a lane: a converted take, a symbol brought in from another project, a sound. One rule answers where — `lane-destination` — and every symbol is born with a lane for it to answer with. An unaimed drop fills an EMPTY lane rather than taking an occupied one nobody pointed at, because the alternative is trimming away what was there to make room for what was dropped. Shift during a clip-body drag means the other intention: put this node INSIDE the symbol the clip under the pointer places, through `nest/move-node`, which is what keeps the world transform and the root timing. Overlap cannot say which of the two is meant — dropping on occupied time already means claiming it — so the person says, and a label by the pointer says it back. The label asks `nest/move-refusal`, the same check the command makes, so it cannot promise what the drop would refuse. Today it refuses more than it allows: both clips have to be on screen at one frame, which two clips in one lane never are, and a held destination has no clock to move through at all. `docs/lane-nesting-notes.md` argues that the second refusal is stronger than the facts require and says what would settle it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
95451798d2
commit
2dc5735ded
19 changed files with 885 additions and 166 deletions
|
|
@ -22,6 +22,7 @@
|
|||
(is (= {:main :take :inner :inner-2} ids)
|
||||
"the root gets the name asked for; a taken id gets the next free one")
|
||||
(is (= 10 (clip/frames clip :inner)) "what was already here is untouched")
|
||||
(is (= #{:inner-2} (node/sources (first (vals (get-in clip [:symbols :take :nodes])))))
|
||||
(is (= #{:inner-2} (node/sources (first (filter #(= :instance (:kind %))
|
||||
(vals (get-in clip [:symbols :take :nodes]))))))
|
||||
"and the copy's instance follows its renamed symbol")
|
||||
(is (empty? (clip/problems clip)))))
|
||||
|
|
|
|||
|
|
@ -241,9 +241,11 @@
|
|||
made (clip/new-symbol c :outer id 20 u)]
|
||||
(is (= :symbol-1 id))
|
||||
(is (= :symbol-2 (clip/fresh-id made)) "the next one does not collide")
|
||||
(is (= {:id :symbol-1 :name "symbol-1" :fps 30 :frames 180 :nodes {}}
|
||||
(is (= {:id :symbol-1 :name "symbol-1" :fps 30 :frames 180
|
||||
:nodes {:lane clip/lane-node}}
|
||||
(clip/symbol made :symbol-1))
|
||||
"empty, and as long as the rest of what it was placed in")
|
||||
"empty but for the lane every symbol is born with, and as long as the
|
||||
rest of what it was placed in")
|
||||
(is (= {:span [0 180] :time {:mode :map :at 20 :rate 1}}
|
||||
(select-keys (get-in made [:symbols :outer :nodes u]) [:span :time])))
|
||||
(is (= #{:symbol-1} (node/sources (get-in made [:symbols :outer :nodes u])))
|
||||
|
|
|
|||
|
|
@ -601,3 +601,50 @@
|
|||
(is (= 12 (get-in (:clip (span/trim doc :main :insert :out 9))
|
||||
[:symbols :main :frames]))
|
||||
"and trimming the last cel leaves the window where it was")))
|
||||
|
||||
(deftest a-take-placed-in-a-lane-is-still-heard
|
||||
;; `bring/take` puts a take's sound INSIDE the symbol it makes, so that
|
||||
;; "wherever the symbol is placed it is heard". A lane is one of the places it
|
||||
;; can be placed, and must not be the one place that goes silent.
|
||||
(let [doc (assoc-in (document) [:symbols :take]
|
||||
{:id :take :frames 10 :fps 24
|
||||
:nodes {:pic {:id :pic :kind :instance :z "a"
|
||||
:source {:symbol :wave} :span [0 10]
|
||||
:time {:mode :map :at 0 :rate 1}
|
||||
:playback {:in 0 :speed 1 :end :stop}}
|
||||
:sound {:id :sound :name "sound" :kind :audio
|
||||
:parent nil :z "z-sound"
|
||||
:source {:footage "f1"} :span [0 10]
|
||||
:time {:mode :map :at 0 :rate 1}}}})
|
||||
at-root (clip/place-symbol doc nil :main :take 0 :root nil)
|
||||
in-lane (:clip (lane/place-symbol doc nil :main :girl :drop :take 0
|
||||
{:extent :grow-symbol :remainder-id :tail}))]
|
||||
(is (= 1 (count (nest/audio-tracks at-root :main)))
|
||||
"a take placed at the root is heard")
|
||||
(is (some? in-lane) "the take goes into the lane")
|
||||
(is (= 1 (count (nest/audio-tracks in-lane :main)))
|
||||
"and is still heard from inside a lane")))
|
||||
|
||||
(deftest a-sound-is-a-clip-in-a-lane-like-any-other
|
||||
;; Everything in the timeline is a lane, audio included: a sound claims lane
|
||||
;; time by the same rule, and what a lane will not do is hold both kinds.
|
||||
(let [made (lane/add-lane (document) :main :track)
|
||||
seeded (clip/place-sound (:clip made) :main {:sound "s1"} "voice" 6 1 2 :vo)
|
||||
result (lane/adopt seeded :main :track :vo 2 {:extent :grow-symbol})
|
||||
after (:clip result)
|
||||
n (get-in after [:symbols :main :nodes :vo])]
|
||||
(is (nil? (:refused result)) (str (:refused result)))
|
||||
(is (= :track (:parent n)))
|
||||
(is (= [2 8] (node/placed-span n)))
|
||||
(is (empty? (clip/problems after)))
|
||||
(is (= 1 (count (nest/audio-tracks after :main)))
|
||||
"a sound in a lane is still heard")
|
||||
(is (= [:vo] (mapv :id (symbol/lane-clips (get-in after [:symbols :main :nodes]) :track))))
|
||||
;; The one thing a lane refuses: being half picture and half sound, which
|
||||
;; is the explicit capability rather than a guess per frame.
|
||||
(let [mixed (lane/place-symbol after nil :main :track :also :wave 2
|
||||
{:extent :grow-symbol :remainder-id :rest})]
|
||||
(is (:refused mixed))
|
||||
(is (re-find #"picture or sound" (str (:refused mixed))))
|
||||
(is (= [:vo] (mapv :id (symbol/lane-clips (get-in after [:symbols :main :nodes]) :track)))
|
||||
"and the sound it would have had to delete to make room is still there"))))
|
||||
|
|
|
|||
|
|
@ -170,7 +170,8 @@
|
|||
inst (get-in grouped [:symbols :main :nodes b-uuid])]
|
||||
(is (nil? (:refused r)) (:refused r))
|
||||
(is (= #{:tri a-uuid} (set (keys (get-in grouped [:symbols :group-1 :nodes])))))
|
||||
(is (= [b-uuid] (keys (get-in grouped [:symbols :main :nodes]))) "one instance where they were")
|
||||
(is (= [b-uuid] (keys (dissoc (get-in grouped [:symbols :main :nodes]) :lane)))
|
||||
"one instance where they were")
|
||||
(is (= 4 (get-in inst [:time :at])) "starting where the earliest of them starts")
|
||||
(is (= 56 (clip/frames grouped :group-1)) "and lasting until the last one ends")
|
||||
(is (= (picture c :main fs) (picture grouped :main fs)))
|
||||
|
|
|
|||
|
|
@ -107,7 +107,10 @@
|
|||
saved (:clip (store/entry (:clip/current after)))
|
||||
lanes (symbol/lanes (get-in saved [:symbols :main :nodes]))]
|
||||
(is (= :polygon (get-in after [:ui :tool])))
|
||||
(is (empty? lanes))
|
||||
(is (= [:lane] (mapv :id lanes))
|
||||
"the lane the symbol was born with, and no second one invented here")
|
||||
(is (empty? (symbol/lane-clips (get-in saved [:symbols :main :nodes]) :lane))
|
||||
"and nothing put in it")
|
||||
(is (nil? (get-in after [:ui :target])))
|
||||
(is (nil? (get-in (store/entry (:clip/current after)) [:history :done])))
|
||||
(is (empty? (clip/problems saved)))))
|
||||
|
|
@ -153,3 +156,67 @@
|
|||
(let [refused (ui/apply-correction-command after {:refused "nope"})]
|
||||
(is (= "nope" (get-in refused [:project :status])))
|
||||
(is (= 1 (count (get-in (store/entry id) [:history :done])))))))
|
||||
|
||||
(deftest an-expanded-lane-opens-the-selected-clip-and-everything-under-it
|
||||
;; The whole document is editable from the root timeline: a lane opens one
|
||||
;; portal — the clip selected in it — and that portal opens the lanes and
|
||||
;; nodes of the symbol it places, mapped into this ruler.
|
||||
(let [doc (fixture/document)
|
||||
open #{[:girl] [:insert]}
|
||||
shut (timeline/rows doc :main open [:insert])
|
||||
of (fn [rows] (mapv (juxt :label :depth) rows))
|
||||
lane-row (first (filter :cels (timeline/rows doc :main open [:insert])))]
|
||||
(is (= 1 (count (filter :portal? shut)))
|
||||
"exactly one clip is opened, not one branch per clip in the lane")
|
||||
(is (= [:insert] (:path (first (filter :portal? shut))))
|
||||
"and it is the selected one")
|
||||
(is (some #{["mark" 2]} (of shut))
|
||||
"the clip's own symbol appears under it, at its depth")
|
||||
(is (= [:node :main :insert [:insert]]
|
||||
(:select (first (filter :portal? shut))))
|
||||
"the portal addresses the same clip its block in the lane does")
|
||||
(is (= (:keys (second (:cels lane-row)))
|
||||
(:keys (first (filter #(= [:b] (:path %)) (timeline/rows doc :main open [:b])))))
|
||||
"a clip's keys are on its block whether or not its portal is open")))
|
||||
|
||||
(deftest a-nested-selection-keeps-the-portal-that-revealed-it-open
|
||||
;; Clicking a shape inside the clip — or the end of its span — is still
|
||||
;; working inside that clip. Matching the selected id alone would close the
|
||||
;; portal the moment anything under it was touched.
|
||||
(let [doc (fixture/document)
|
||||
open #{[:girl] [:insert]}
|
||||
deep (timeline/rows doc :main open [:insert :mark])
|
||||
none (timeline/rows doc :main open [:plate])]
|
||||
(is (= [:insert] (:path (first (filter :portal? deep))))
|
||||
"a selection under the clip keeps that clip's portal")
|
||||
(is (empty? (filter :portal? none)))
|
||||
(is (some #{"select a clip to inspect"} (map :label none))
|
||||
"with nothing selected in it, an open lane says what it is waiting for")))
|
||||
|
||||
(deftest a-held-clip-shows-its-contents-without-inventing-frames-for-them
|
||||
;; `clip/source-time` is nil for a hold, so nested keys have no place on this
|
||||
;; ruler — but the drawing's own nodes must still be reachable from here.
|
||||
(let [doc (fixture/document)
|
||||
rows (timeline/rows doc :main #{[:girl] [:a]} [:a])
|
||||
inside (filter :unmapped? rows)]
|
||||
(is (seq inside) "a held drawing opens")
|
||||
(is (some #{"mark"} (map :label inside)))
|
||||
(is (every? (comp empty? :keys) inside)
|
||||
"no key is placed where the hold cannot say it belongs")
|
||||
(is (= [[0 4]] (distinct (keep :span (filter #(= :node (:kind %)) inside))))
|
||||
"its rows span the hold, which is when it is on screen")))
|
||||
|
||||
(deftest a-lane-of-sounds-is-drawn-as-a-lane-and-not-flattened-twice
|
||||
(let [made (lane/add-lane (fixture/document) :main :track)
|
||||
seeded (clip/place-sound (:clip made) :main {:sound "s1"} "voice" 6 1 2 :vo)
|
||||
doc (:clip (lane/adopt seeded :main :track :vo 2 {:extent :grow-symbol}))
|
||||
picture (remove :sound? (timeline/rows doc :main #{} nil))
|
||||
sound-lanes (filter :sound? (timeline/rows doc :main #{} nil))
|
||||
flattened (timeline/sound-rows doc :main #{})]
|
||||
(is (= 1 (count sound-lanes)) "the sound's lane is one row, like any lane")
|
||||
(is (= [:vo] (mapv :id (:cels (first sound-lanes))))
|
||||
"with the sound on it as a block that can be moved and trimmed")
|
||||
(is (empty? (filter #(= [:track] (:path %)) picture))
|
||||
"and it is not also listed among the picture rows")
|
||||
(is (empty? flattened)
|
||||
"nor flattened into a second, parallel audio row")))
|
||||
|
|
|
|||
|
|
@ -160,6 +160,31 @@ try {
|
|||
});
|
||||
await sleep(250);
|
||||
};
|
||||
const tabs = () => evaluate(`(() => {
|
||||
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
|
||||
return {tabs: cljs.core.clj__GT_js(cljs.core.get_in(db, [k('ui'), k('tabs')])).map(String),
|
||||
open: String(cljs.core.clj__GT_js(cljs.core.get_in(db, [k('ui'), k('open')])))};
|
||||
})()`);
|
||||
// A real two-press double-click, not `.dispatchEvent`: what broke here was
|
||||
// where the browser decides to deliver the click, which a synthetic event
|
||||
// cannot show.
|
||||
const doubleClick = async selector => {
|
||||
const p = await evaluate(`(() => {
|
||||
const el = document.querySelector(${JSON.stringify(selector)});
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return {x: r.left + r.width / 2, y: r.top + r.height / 2};
|
||||
})()`);
|
||||
assert(p, `something to double-click: ${selector}`);
|
||||
for (const clickCount of [1, 2]) {
|
||||
await send('Input.dispatchMouseEvent', {type: 'mousePressed', x: p.x, y: p.y,
|
||||
button: 'left', buttons: 1, clickCount});
|
||||
await send('Input.dispatchMouseEvent', {type: 'mouseReleased', x: p.x, y: p.y,
|
||||
button: 'left', buttons: 0, clickCount});
|
||||
await sleep(60);
|
||||
}
|
||||
await sleep(280);
|
||||
};
|
||||
const dropPoolSymbol = async frame => {
|
||||
const points = await evaluate(`(() => {
|
||||
const source = document.querySelector('.pool-row:not(.main) .pool-item[draggable="true"]');
|
||||
|
|
@ -301,8 +326,66 @@ try {
|
|||
const lanes = Object.values(s.clip.symbols.main.nodes).filter(n => n.layout === 'sequence');
|
||||
assert.deepEqual(lanes.map(l => instances(s).filter(n => n.parent === l.id).length).sort(), [1, 3],
|
||||
'a clip body can move from one lane to another');
|
||||
|
||||
// EXPANDING A LANE OPENS THE SELECTED CLIP. Its own keys, and under it the
|
||||
// lanes and nodes of the symbol it places, all on this ruler — which is what
|
||||
// makes the whole document editable from the root timeline.
|
||||
const rowLabels = () => evaluate(
|
||||
`[...document.querySelectorAll('.tl-labels > .tl-label')].map(e => e.textContent.trim())`);
|
||||
const twist = async i => {
|
||||
assert(await evaluate(`(() => {
|
||||
const t = document.querySelectorAll('.tl-labels > .tl-label .tl-twist')[${i}];
|
||||
if (!t || t.disabled) return false;
|
||||
t.click(); return true;
|
||||
})()`), `an expander at row ${i}`);
|
||||
await sleep(220);
|
||||
};
|
||||
await evaluate(`(() => { document.querySelector('.tl-track .tl-cel').click(); return true })()`);
|
||||
await sleep(200);
|
||||
const collapsed = await rowLabels();
|
||||
await twist(0);
|
||||
const opened = await rowLabels();
|
||||
assert(opened.length > collapsed.length, 'the lane opens');
|
||||
assert.equal(opened.filter(l => l.includes('instance')).length, 1,
|
||||
`one clip portal, not one branch per clip: ${JSON.stringify(opened)}`);
|
||||
const portalAt = opened.findIndex(l => l.includes('instance'));
|
||||
await twist(portalAt);
|
||||
const deep = await rowLabels();
|
||||
assert(deep.length > opened.length,
|
||||
`the portal opens the symbol the clip places: ${JSON.stringify(deep)}`);
|
||||
// Selecting something nested must not close the portal that revealed it.
|
||||
await evaluate(`(() => {
|
||||
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
|
||||
const sel = cljs.core.get_in(db, [k('ui'), k('selection')]);
|
||||
const path = cljs.core.nth(sel, 3);
|
||||
re_frame.core.dispatch(cljs.core.vector(
|
||||
k('arthur.events.ui/select'),
|
||||
cljs.core.vector(k('node'), cljs.core.nth(sel, 1), cljs.core.nth(sel, 2),
|
||||
cljs.core.conj(path, k('made-up-child')))));
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(220);
|
||||
assert.equal((await rowLabels()).filter(l => l.includes('instance')).length, 1,
|
||||
'a selection under the clip keeps its portal open');
|
||||
await twist(portalAt);
|
||||
await twist(0);
|
||||
|
||||
const before = await tabs();
|
||||
const tabChips = () => evaluate('document.querySelectorAll(".tabs .tab").length');
|
||||
const chipsBefore = await tabChips();
|
||||
await doubleClick('.tl-track .tl-cel');
|
||||
const after = await tabs();
|
||||
assert.equal(after.tabs.length, before.tabs.length + 1,
|
||||
`double-clicking a clip opens the symbol it places, as the pool row does: ${JSON.stringify(after)}`);
|
||||
assert(!before.tabs.includes(after.open) && after.tabs.includes(after.open),
|
||||
`the opened symbol is the one in front: ${JSON.stringify(after)}`);
|
||||
assert.equal(await tabChips(), chipsBefore + 1,
|
||||
'the opened symbol is drawn as one more tab');
|
||||
assert.equal(await evaluate('document.querySelectorAll("#app > *").length'), 1,
|
||||
'opening from the timeline leaves the editor standing: a stale node selection ' +
|
||||
'pointing into the symbol just left used to throw and unmount it');
|
||||
assert.equal(errors.length, 0, JSON.stringify(errors));
|
||||
console.log('PASS: generic lanes preview, rename, move, place, trim, roll, and ripple clips');
|
||||
console.log('PASS: generic lanes preview, rename, move, place, open, trim, roll, and ripple clips');
|
||||
} finally {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue