Reuse, duplicate and make unique: deciding what is shared
The model's whole claim is that content and its occurrences are different
things, and until now nothing in the editor could tell them apart: you could
make a drawing and time it, but not expose one drawing twice, and so never find
out whether an edit arrives in two places. That is the first proof obligation in
the lane model and it was the one the commands could not reach.
Three commands, and the distinctions between them are the point:
reuse another occurrence of the same drawing. A decision to share,
made on purpose, because sharing discovered later — when an edit
turns up somewhere you did not expect — is the bad version.
duplicate a copy of the drawing, appended, for when what is on screen is
the starting point for the next one.
make unique this occurrence gets a private copy; the others keep sharing.
The undo of reuse, and refused when nothing else uses the
drawing: a copy nobody asked for is a second identical symbol in
the library for no reason a person could see.
Duplicate copies the CONTENT and not the exposure. Its new occurrence is a plain
one-frame hold, not a copy of the source occurrence's transform or corrections,
because those belong to that use of the drawing — carrying them over would make
duplicating a drawing quietly duplicate the treatment of one exposure of it.
A copy is SHALLOW by default and keeps its references to other symbols, so a
head built out of reusable eyes still uses those eyes. `:deep? true` copies
everything it places with new ids throughout. The lane model asks for both and
says why: never promise decoupling while leaving the edited object shared, and
only the deep copy can keep that promise. `bring/symbols` already did the
reachability walk and the id remapping, so the deep copy is that function
pointed at its own clip.
`node/sources` was still being read as a SET at five call sites, each with a
comment about a lane that cuts between several drawings — the keyed source that
no longer exists. An occurrence names one symbol, so they now ask `node/source`,
and `placed-frame` answers with `:symbol` rather than `:of`, which was the last
echo of the retired field name.
To let the commands use `clip/free-id` and the copy machinery, the lane's own
validation moved from `domain/sequence` to `domain/symbol`, which is where it
belonged anyway: a sequence is the one composition rule a node map carries, and
it now sits beside the parent and stencil checks rather than in the namespace
that happens to build lanes. That also breaks the cycle — sequence can require
clip and bring, and nothing below it requires sequence. Preconditions still
check only the LANE's shape: refusing an exposure edit over an unrelated defect
elsewhere in the symbol would be this command answering for a part of the
document it never touches.
The cel strip gains reuse, duplicate and make unique, the last shown only where
the selected exposure actually shares its drawing. Drawing on twos is also now
under test: exposure length is the cadence, the lane's transform has its own
clock, and it still moves on every frame — stepping it would be the cel cadence
leaking into continuous motion.
397 tests, 5,561 assertions. `test/browser/sequence.mjs` drives the three new
commands through the real editor and checks that three exposures are still one
row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3d3c1bbca0
commit
9446829774
7 changed files with 427 additions and 83 deletions
|
|
@ -236,3 +236,101 @@
|
|||
(is (nil? (:clip result)))
|
||||
(is (= 15 (get-in (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol})
|
||||
[:clip :symbols :main :frames])))))
|
||||
|
||||
(deftest reuse-shares-content-and-make-unique-decouples-one-exposure
|
||||
(let [doc (document)
|
||||
shared (:clip (sequence/reuse-drawing doc :main :girl :c :drawing-a
|
||||
{:extent :grow-symbol}))
|
||||
edit (fn [c sym x]
|
||||
(assoc-in c [:symbols sym :nodes :mark :channels [:xform :pos]]
|
||||
(ch/framed [x 0])))]
|
||||
(is (:refused (sequence/reuse-drawing doc :main :girl :c :drawing-a {}))
|
||||
"the shot has to be extended on purpose")
|
||||
(is (= :drawing-a (node/source (get-in shared [:symbols :main :nodes :c]))))
|
||||
(is (= [12 13] (node/placed-span (get-in shared [:symbols :main :nodes :c]))))
|
||||
(is (empty? (clip/problems shared)))
|
||||
;; One drawing, two exposures: the edit arrives at both.
|
||||
(let [at (sample (edit shared :drawing-a 99) [0 12])]
|
||||
(is (= 99 (get-in at [0 [:a :mark]])))
|
||||
(is (= 99 (get-in at [12 [:c :mark]]))))
|
||||
(let [unique (:clip (sequence/make-unique shared :main :c {}))]
|
||||
(is (= :drawing-a-2 (node/source (get-in unique [:symbols :main :nodes :c]))))
|
||||
(is (= (:nodes (get-in shared [:symbols :drawing-a]))
|
||||
(:nodes (get-in unique [:symbols :drawing-a-2])))
|
||||
"a copy of the same drawing, not an empty one")
|
||||
(is (= :drawing-a (node/source (get-in unique [:symbols :main :nodes :a])))
|
||||
"the other exposure keeps the original")
|
||||
(let [at (sample (edit unique :drawing-a 99) [0 12])]
|
||||
(is (= 99 (get-in at [0 [:a :mark]])))
|
||||
(is (= 10 (get-in at [12 [:c :mark]])) "the exposure made unique is untouched"))
|
||||
(let [at (sample (edit unique :drawing-a-2 99) [0 12])]
|
||||
(is (= 10 (get-in at [0 [:a :mark]])) "and does not reach back"))
|
||||
(is (empty? (clip/problems unique))))
|
||||
;; Nothing else places drawing-b, so there is nothing to decouple from.
|
||||
(is (:refused (sequence/make-unique doc :main :b {})))
|
||||
(is (:refused (sequence/make-unique doc :main :girl {}))
|
||||
"a lane places nothing itself")))
|
||||
|
||||
(deftest duplicate-copies-the-drawing-and-not-the-exposure
|
||||
(let [doc (document)
|
||||
made (:clip (sequence/duplicate-drawing doc :main :b :d {:extent :grow-symbol}))
|
||||
n (get-in made [:symbols :main :nodes :d])]
|
||||
(is (= :drawing-b-2 (node/source n)))
|
||||
(is (= (:nodes (get-in doc [:symbols :drawing-b]))
|
||||
(:nodes (get-in made [:symbols :drawing-b-2]))))
|
||||
(is (= [12 13] (node/placed-span n)))
|
||||
(is (= {:in 0 :speed 0 :end :stop} (:playback n)))
|
||||
(is (nil? (:channels n)) "B's own position correction belongs to B's exposure")
|
||||
(is (= (get-in doc [:symbols :main :nodes :b])
|
||||
(get-in made [:symbols :main :nodes :b]))
|
||||
"the drawing duplicated is left as it was")
|
||||
(is (empty? (clip/problems made)))))
|
||||
|
||||
(deftest a-shallow-copy-keeps-its-parts-and-a-deep-copy-owns-them
|
||||
;; A drawing assembled from another symbol: copying it shallowly must keep
|
||||
;; using that part, and only an explicit deep copy may promise independence.
|
||||
(let [doc (assoc-in (document) [:symbols :drawing-a :nodes :part]
|
||||
{:id :part :kind :instance :z "b" :span [0 1]
|
||||
:time {:at 0 :rate 1} :source {:symbol :wave}
|
||||
:playback {:in 0 :speed 0 :end :stop}})
|
||||
copy (fn [opts] (:clip (sequence/duplicate-drawing
|
||||
doc :main :a :d (merge {:extent :grow-symbol} opts))))
|
||||
shallow (copy {})
|
||||
deep (copy {:deep? true})]
|
||||
(is (= :wave (node/source (get-in shallow [:symbols :drawing-a-2 :nodes :part]))))
|
||||
(is (nil? (get-in shallow [:symbols :wave-2])))
|
||||
(is (= :wave-2 (node/source (get-in deep [:symbols :drawing-a-2 :nodes :part]))))
|
||||
(is (= (:nodes (get-in doc [:symbols :wave])) (:nodes (get-in deep [:symbols :wave-2]))))
|
||||
(is (empty? (clip/problems shallow)))
|
||||
(is (empty? (clip/problems deep)))))
|
||||
|
||||
(deftest reuse-refuses-what-would-not-be-a-document
|
||||
(let [doc (document)]
|
||||
(is (:refused (sequence/reuse-drawing doc :main :girl :c :nothing-here {})))
|
||||
(is (:refused (sequence/reuse-drawing doc :main :girl :c :main {:extent :grow-symbol}))
|
||||
"a symbol cannot go inside itself")
|
||||
(is (:refused (sequence/reuse-drawing doc :main :girl :a :drawing-a {:extent :grow-symbol}))
|
||||
"an occurrence ID in use is not free")
|
||||
(is (:refused (sequence/reuse-drawing doc :main :plate :c :drawing-a {})))
|
||||
(is (:refused (sequence/duplicate-drawing doc :main :girl :d {})))))
|
||||
|
||||
(deftest drawing-on-twos-does-not-quantize-the-lane-transform
|
||||
;; Exposure length IS the drawing cadence, and it is the only thing on twos
|
||||
;; here: the lane's transform has its own clock and keeps moving every frame.
|
||||
;; Stepping it would be the cel cadence leaking into continuous motion.
|
||||
(let [cel (fn [id source at] (occurrence id source at 2 0))
|
||||
doc (-> (document)
|
||||
(update-in [:symbols :main :nodes] dissoc :a :b :insert)
|
||||
(update-in [:symbols :main :nodes] merge
|
||||
{:c0 (cel :c0 :drawing-a 0)
|
||||
:c1 (cel :c1 :drawing-b 2)
|
||||
:c2 (cel :c2 :drawing-a 4)}))
|
||||
xs {:c0 10 :c1 20 :c2 10}
|
||||
at (sample doc (range 6))
|
||||
showing (fn [f] (first (dissoc (at f) :plate)))]
|
||||
(is (empty? (clip/problems doc)))
|
||||
(is (= [:c0 :c0 :c1 :c1 :c2 :c2] (mapv #(first (key (showing %))) (range 6)))
|
||||
"the drawing showing changes every second frame")
|
||||
(is (= [0 10 20 30 40 50]
|
||||
(mapv (fn [f] (let [[[id _] cx] (showing f)] (- cx (xs id)))) (range 6)))
|
||||
"and the lane moves on every frame, odd ones included")))
|
||||
|
|
|
|||
|
|
@ -113,8 +113,44 @@ try {
|
|||
await evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key:'z', ctrlKey:true, bubbles:true}))`);
|
||||
await sleep(250);
|
||||
assert.deepEqual((await shot()).clip, before.clip, 'one undo restores exposure, ripple, and shot length');
|
||||
|
||||
// Sharing: one drawing exposed twice, then one exposure decoupled. Room is
|
||||
// made first so these assertions are about content and not about overflow.
|
||||
await evaluate(`(() => {
|
||||
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
|
||||
arthur.footage.store.edit_clip_BANG_(cljs.core.get(db, k('clip/current')),
|
||||
clip => cljs.core.assoc_in(clip, cljs.core.vector(k('symbols'), k('main'), k('frames')), 20));
|
||||
document.querySelector('.tl-cel').click();
|
||||
})()`);
|
||||
await sleep(200);
|
||||
const enabled = async label => await evaluate(`(() => {
|
||||
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)});
|
||||
return !!b && !b.disabled;
|
||||
})()`);
|
||||
assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet');
|
||||
await click('reuse');
|
||||
s = await shot();
|
||||
let cels = instances(s);
|
||||
assert.equal(cels.length, 3);
|
||||
assert.equal(cels[2].source.symbol, cels[0].source.symbol, 'reuse exposes the same drawing');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 3);
|
||||
assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1,
|
||||
'three exposures, still one row');
|
||||
assert.equal(await enabled('make unique'), true);
|
||||
await click('make unique');
|
||||
s = await shot();
|
||||
cels = instances(s);
|
||||
assert.notEqual(cels[2].source.symbol, cels[0].source.symbol, 'that exposure has its own drawing');
|
||||
assert.equal(await enabled('make unique'), false, 'and is not shared any more');
|
||||
await click('duplicate');
|
||||
s = await shot();
|
||||
cels = instances(s);
|
||||
assert.equal(cels.length, 4);
|
||||
assert.equal(new Set(cels.map(n => n.source.symbol)).size, 4,
|
||||
'four exposures of four drawings: nothing is shared once every copy is made');
|
||||
assert.equal(s.history.done.length, before.history.done.length + 3, 'three more commands, three more steps');
|
||||
assert.equal(errors.length, 0, JSON.stringify(errors));
|
||||
console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo; no server writes');
|
||||
console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo, reuse/make unique/duplicate; no server writes');
|
||||
} 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