This commit is contained in:
Olive Vaughn 2026-10-04 18:14:05 -04:00
parent 208dddee07
commit 78fb120edd
9 changed files with 102 additions and 48 deletions

@ -0,0 +1 @@
Subproject commit f4dd04764204506fc275180364d0366d693036f4

View file

@ -57,8 +57,9 @@
THE EDITOR'S, NOT THE DOCUMENT'S. Showing a reference is a way of looking at THE EDITOR'S, NOT THE DOCUMENT'S. Showing a reference is a way of looking at
the stage, like solo and zoom: not an undo step, not sent to collaborators, the stage, like solo and zoom: not an undo step, not sent to collaborators,
and it cannot reach an export. A layer is shown unless it is in `:hidden`, so and it cannot reach an export. A layer is shown unless it is in `:hidden`, so
footage brought in shows without being found and switched on first." footage brought in shows without being found and switched on first — once
{:on? true :opacity 0.5 :hidden #{}}) tracing itself is on, which it is not until asked for."
{:on? false :opacity 0.5 :hidden #{}})
(def default (def default
{;; --- the document --- {;; --- the document ---

View file

@ -25,13 +25,14 @@
another clip is `arthur.domain.bring`. This namespace is the document and the another clip is `arthur.domain.bring`. This namespace is the document and the
operations that only need the document. operations that only need the document.
NO SYMBOL IS SPECIAL. There is no reserved root and no pointer to one: which NO SYMBOL IS SPECIAL. There is no reserved root id: which symbol is on screen
symbol is on screen is the editor's state, not the document's, and every is the editor's state, not the document's, and every function here that needs
function here that needs a symbol is told which. A new document has one symbol a symbol is told which. A new document has one symbol called `:main` because
called `:main` because it has to be called something, and that is all the name it has to be called something, and that is all the name means — it can be
means — it can be renamed, placed inside another symbol or deleted like any of renamed or placed inside another symbol like any of them. What the document
them. `unplaced` answers the question a reserved root used to: which symbols does say is `:root`, which symbol it opens on: a pointer, not a kind of
nothing else places, and so which ones a person opening the document wants. symbol, the way a Flash file names its scene. See `opens-on` for why that
cannot be worked out instead.
:fps is the output grid. A symbol's optional :fps names the native grid its :fps is the output grid. A symbol's optional :fps names the native grid its
frames were authored or measured on; absent means the document's grid." frames were authored or measured on; absent means the document's grid."
@ -52,7 +53,7 @@
layer must not be able to have. Add the field here and to `leaf/leaves` and layer must not be able to have. Add the field here and to `leaf/leaves` and
`leaf/clip` in the same commit." `leaf/clip` in the same commit."
#{:name :fps :analyses :subjects :features :groups :width :height :symbols #{:name :fps :analyses :subjects :features :groups :width :height :symbols
:palettes :default-palette}) :palettes :default-palette :root})
(defn symbol (defn symbol
"One of the clip's symbols, by id." "One of the clip's symbols, by id."
@ -201,18 +202,39 @@
(let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))] (let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))]
(vec (sort-by str (remove placed (keys (:symbols clip))))))) (vec (sort-by str (remove placed (keys (:symbols clip)))))))
(defn opens-on (defn- longest-unplaced
"The symbol a document opens on: the longest one nothing else places, ties "The longest symbol nothing else places, ties broken by id, and never a
broken by id. The symbol that contains everything else is the longest of the tracing symbol: that is footage nobody has placed yet, as long as its take."
unplaced ones in every document made so far, and a reserved name is what this
replaces.
Never a tracing symbol: it is footage nobody has placed yet, and as long as
the take it came from, so it would otherwise win."
[clip] [clip]
(first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)]) (first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)])
(remove #(trace? (symbol clip %)) (unplaced clip))))) (remove #(trace? (symbol clip %)) (unplaced clip)))))
(defn opens-on
"The symbol a document opens on: its `:root`.
IT IS STORED, NOT WORKED OUT. It used to be the longest unplaced symbol, on
the theory that the one containing everything else is always that. A take
disproves it: imported footage is a symbol a thousand frames long, and the
moment its instance is deleted, or the drop lands somewhere other than the
root, nothing places it and it outranks a 120-frame `:main`. The document then
opened on the take, and `set-root-fps` rewrote the take's rate to the
project's on the way in.
A document with no `:root` — a demo, a fixture — still gets the old answer."
[clip]
(let [root (:root clip)]
(if (contains? (:symbols clip) root) root (longest-unplaced clip))))
(defn pin-root
"Give a document saved before `:root` existed the root it was made with.
Every such document started as `blank`, whose root is `:main`, and ids never
change, so `:main` is the answer wherever it survives; the old rule is only
for documents that never had one."
[clip]
(cond-> clip
(not (:root clip))
(assoc :root (if (contains? (:symbols clip) :main) :main (longest-unplaced clip)))))
(defn set-root-fps (defn set-root-fps
"Set the document/output rate and the root symbol's editing rate together. "Set the document/output rate and the root symbol's editing rate together.
@ -250,6 +272,7 @@
:width 320 :height 200 :width 320 :height 200
:palettes {pal/default-id pal/default-palette} :palettes {pal/default-id pal/default-palette}
:default-palette pal/default-id :default-palette pal/default-id
:root :main
;; No native fps yet: an untouched canvas follows the project grid. Imported ;; No native fps yet: an untouched canvas follows the project grid. Imported
;; and generated symbols carry their own rate explicitly. ;; and generated symbols carry their own rate explicitly.
:symbols {:main {:id :main :frames blank-frames :nodes {}}}}) :symbols {:main {:id :main :frames blank-frames :nodes {}}}})
@ -723,6 +746,9 @@
(when (and (contains? clip :default-palette) (map? (:palettes clip)) (when (and (contains? clip :default-palette) (map? (:palettes clip))
(not (contains? (:palettes clip) (:default-palette clip)))) (not (contains? (:palettes clip) (:default-palette clip))))
[":default-palette must name a project palette"]) [":default-palette must name a project palette"])
(when (and (contains? clip :root) (map? (:symbols clip))
(not (contains? (:symbols clip) (:root clip))))
[(str ":root names missing symbol " (pr-str (:root clip)))])
(for [[id p] (:palettes clip) (for [[id p] (:palettes clip)
:when (or (not= id (:id p)) (not (pal/valid-palette? p)))] :when (or (not= id (:id p)) (not (pal/valid-palette? p)))]
(str "palette " (pr-str id) " is invalid or has a different :id")) (str "palette " (pr-str id) " is invalid or has a different :id"))

View file

@ -12,6 +12,7 @@
clip/<cid>/stage width, height clip/<cid>/stage width, height
clip/<cid>/palette/<pid> a named indexed palette asset clip/<cid>/palette/<pid> a named indexed palette asset
clip/<cid>/palette-default the project fallback palette id clip/<cid>/palette-default the project fallback palette id
clip/<cid>/root the symbol the document opens on
clip/<cid>/source the analysis record this came out of clip/<cid>/source the analysis record this came out of
clip/<cid>/subject/<subj> a tracked subject and its params clip/<cid>/subject/<subj> a tracked subject and its params
clip/<cid>/feature/<fid> one feature: area, nodes, params clip/<cid>/feature/<fid> one feature: area, nodes, params
@ -138,6 +139,7 @@
(some-leaf (at "timing") (select-keys clip [:fps])) (some-leaf (at "timing") (select-keys clip [:fps]))
(some-leaf (at "stage") (select-keys clip [:width :height])) (some-leaf (at "stage") (select-keys clip [:width :height]))
(some-leaf (at "palette-default") (select-keys clip [:default-palette])) (some-leaf (at "palette-default") (select-keys clip [:default-palette]))
(some-leaf (at "root") (select-keys clip [:root]))
(some-leaf (at "analyses") (:analyses clip)) (some-leaf (at "analyses") (:analyses clip))
(concat (concat
(for [[id v] (:subjects clip)] {(at "subject" (segment id)) v}) (for [[id v] (:subjects clip)] {(at "subject" (segment id)) v})
@ -198,6 +200,7 @@
"stage" (merge acc v) "stage" (merge acc v)
"analyses" (assoc acc :analyses v) "analyses" (assoc acc :analyses v)
"palette-default" (merge acc v) "palette-default" (merge acc v)
"root" (merge acc v)
"palette" (assoc-in acc [:palettes (unsegment a)] v) "palette" (assoc-in acc [:palettes (unsegment a)] v)
"subject" (assoc-in acc [:subjects (unsegment a)] v) "subject" (assoc-in acc [:subjects (unsegment a)] v)
"feature" (assoc-in acc [:features (unsegment a)] v) "feature" (assoc-in acc [:features (unsegment a)] v)
@ -239,7 +242,7 @@
false) false)
(case (count p) (case (count p)
;; The clip's own facts carry no id. ;; The clip's own facts carry no id.
3 (#{"name" "timing" "stage" "analyses" "palette-default"} (nth p 2)) 3 (#{"name" "timing" "stage" "analyses" "palette-default" "root"} (nth p 2))
4 (#{"subject" "feature" "group" "palette"} (nth p 2)) 4 (#{"subject" "feature" "group" "palette"} (nth p 2))
false))))] false))))]
(vec (vec

View file

@ -113,7 +113,9 @@
;; also normalizes documents saved by the earlier model, ;; also normalizes documents saved by the earlier model,
;; where changing project FPS left the root on its old ;; where changing project FPS left the root on its old
;; editing grid. ;; editing grid.
built (clip/set-root-fps (:clip loaded) (:fps (:clip loaded)))] built (-> (:clip loaded)
clip/pin-root
(clip/set-root-fps (:fps (:clip loaded))))]
(let [entry (merge (select-keys built [:fps :width :height]) (let [entry (merge (select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)") {:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid :cid cid

View file

@ -841,6 +841,7 @@
:width (first stage) :height (second stage) :width (first stage) :height (second stage)
:palettes {pal/default-id pal/default-palette} :palettes {pal/default-id pal/default-palette}
:default-palette pal/default-id :default-palette pal/default-id
:root :main
:subjects (into {} (map (fn [[id _]] :subjects (into {} (map (fn [[id _]]
[id {:id id :params {} [id {:id id :params {}
:analysis (:id analysis) :analysis (:id analysis)

View file

@ -1241,10 +1241,9 @@
(let [by-frame (group-by :at (concat key-items (mapcat :key-items cels)))] (let [by-frame (group-by :at (concat key-items (mapcat :key-items cels)))]
(for [[index f] (map-indexed vector (sort (distinct (concat keys (mapcat :keys cels))))) (for [[index f] (map-indexed vector (sort (distinct (concat keys (mapcat :keys cels)))))
:when (and (<= 0 f) (< f frames))] :when (and (<= 0 f) (< f frames))]
^{:key index}
(if-let [items (and (= :channel kind) (seq (get by-frame f)))] (if-let [items (and (= :channel kind) (seq (get by-frame f)))]
[key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements] ^{:key index} [key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements]
[:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))])) ^{:key index} [:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))]))
(defn- cursor-hint (defn- cursor-hint
"What the drag in flight would do, beside the pointer. "What the drag in flight would do, beside the pointer.

View file

@ -216,10 +216,19 @@
(deftest no-symbol-is-special (deftest no-symbol-is-special
(let [c (nested)] (let [c (nested)]
(testing "a document opens on the longest symbol nothing places" (testing "a document opens on its root, however long its unplaced symbols are"
(is (= [:loose :main :outer] (clip/unplaced c))) (is (= [:loose :main :outer] (clip/unplaced c)))
(is (= :outer (clip/opens-on c))) (is (= :main (clip/opens-on c)))
(is (= :main (clip/opens-on (clip/blank))))) (is (= :main (clip/opens-on (clip/blank))))
(is (= :loose (clip/opens-on (assoc c :root :loose))))
(is (= c (leaf/clip "u" (leaf/leaves "u" c))))
(is (seq (clip/problems (assoc c :root :gone)))))
(testing "without a root, the longest symbol nothing places"
(is (= :outer (clip/opens-on (dissoc c :root)))))
(testing "a document saved before roots opens on :main, not a longer take"
(is (= :main (:root (clip/pin-root (dissoc c :root)))))
(is (= :outer (:root (clip/pin-root (-> c (dissoc :root) (update :symbols dissoc :main))))))
(is (= :loose (:root (clip/pin-root (assoc c :root :loose))))))
(testing "an instance can go into any symbol, and spans that symbol's frames" (testing "an instance can go into any symbol, and spans that symbol's frames"
(let [[n] (vals (get-in c [:symbols :outer :nodes]))] (let [[n] (vals (get-in c [:symbols :outer :nodes]))]
(is (= #{:inner} (node/sources n))) (is (= #{:inner} (node/sources n)))

View file

@ -523,32 +523,32 @@ async function main() {
// same synchronous block arrives while the tool is still unset and is dropped. // same synchronous block arrives while the tool is still unset and is dropped.
// A person cannot click twice inside one microtask; a test should not either. // A person cannot click twice inside one microtask; a test should not either.
const armed = await page.eval(`(() => { const armed = await page.eval(`(() => {
const button = [...document.querySelectorAll('.palette-bar button')] const button = document.querySelector('.toolbox button.tool[title^="Pen"]');
.find(b => b.textContent === 'pen');
if (!button) return false; if (!button) return false;
button.click(); button.click();
return true; return true;
})()`); })()`);
await sleep(100); await sleep(100);
const painted = armed && await page.eval(`(() => { // The overlay reaches past the stage on every side, so a stage pixel is
const svg = document.querySelector('.paint-overlay'); // found through the SVG's own transform, as the stage finds it coming back.
const box = svg.getBoundingClientRect(); // Each point is its own eval: a click is a re-frame event, and the next one
for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) { // reads the draft it added to.
svg.dispatchEvent(new PointerEvent('pointerdown', { const PEN_AT = (x, y) => `(() => {
bubbles: true, clientX: box.left + x * box.width / 320, const svg = document.querySelector('.paint-overlay.tool-pen');
clientY: box.top + y * box.height / 200, if (!svg) return false;
})); const p = new DOMPoint(${x}, ${y}).matrixTransform(svg.getScreenCTM());
} svg.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, clientX: p.x, clientY: p.y,
}));
return true; return true;
})()`); })()`;
await sleep(100); let painted = armed;
const finished = await page.eval(`(() => { for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) {
const button = [...document.querySelectorAll('.palette-bar button')] painted = painted && await page.eval(PEN_AT(x, y));
.find(b => b.textContent === 'finish'); await sleep(60);
if (!button || button.disabled) return false; }
button.click(); // Closed as a person closes it: on the first point.
return true; const finished = painted && await page.eval(PEN_AT(10, 10));
})()`);
await sleep(250); await sleep(250);
const afterPaint = await page.eval(`(() => { const afterPaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage'); const c = document.querySelector('canvas.stage');
@ -556,7 +556,8 @@ async function main() {
return [...d]; return [...d];
})()`); })()`);
check(painted && finished && beforePaint.join(',') !== afterPaint.join(','), check(painted && finished && beforePaint.join(',') !== afterPaint.join(','),
'a polygon drawn with the paint controls appears on the canvas'); 'a polygon drawn with the paint controls appears on the canvas',
`${beforePaint} -> ${afterPaint}`);
for (const f of [8, 16]) { for (const f of [8, 16]) {
await page.eval(SEEK(f)); await page.eval(SEEK(f));
await sleep(100); await sleep(100);
@ -597,7 +598,18 @@ async function main() {
return [...c.getContext('2d').getImageData(25, 20, 1, 1).data]; return [...c.getContext('2d').getImageData(25, 20, 1, 1).data];
})()`); })()`);
check(afterPaint.join(',') === reopenedPaint.join(','), check(afterPaint.join(',') === reopenedPaint.join(','),
'the painted polygon survives the project round trip'); 'the painted polygon survives the project round trip', `${afterPaint} vs ${reopenedPaint}`);
// A reopened document has nothing selected, so the shape's keys are not in
// the inspector until it is selected again, as anybody would.
await page.eval(`(() => {
const c = cljs.core, k = c.keyword;
const db = c.deref(re_frame.db.app_db);
const nodes = c.get_in(c.get(arthur.footage.store.entry(c.get(db, k('clip/current'))), k('clip')),
c.vector(k('symbols'), k('main'), k('nodes')));
const id = c.first(c.filter((id) => c.name(id).startsWith('paint-'), c.keys(nodes)));
re_frame.core.dispatch_sync(c.vector(k('arthur.events.ui/select'),
c.vector(k('node'), k('main'), id, c.vector(id))));
})()`);
await page.eval(SEEK(8)); await page.eval(SEEK(8));
await sleep(100); await sleep(100);
check(await page.eval(`(() => { check(await page.eval(`(() => {