diff --git a/tl/annotation_flow_plan.md b/tl/annotation_flow_plan.md index 4d088c6..f41c6da 100644 --- a/tl/annotation_flow_plan.md +++ b/tl/annotation_flow_plan.md @@ -86,7 +86,13 @@ So "the title is the autocomplete": one control forks create-vs-associate. - [ ] Marks editor renders a proxy as ONE row (start clip/frame → end clip/frame), not N rows. - [ ] Active mark highlighted in the pane, matching the lane. -## Chunk 7 — Orphan / stability polish +## Chunk 7 — Orphan / stability polish ✅ -- [ ] Grey-out + drop orphaned annotations to bottom of list with warn indicator. -- [ ] Per-mark broken-ref warnings; keep annotation if at least one mark still resolves. +- [x] Broken annotations sort to the bottom (existing sub) and are **greyed out** (opacity on the card) while staying visible so surviving marks stay usable; `△`/`⚠` warnings on the card (existing). +- [x] **Per-mark broken indicator** in the editor: broken mark rows show `△` + strike-through + dimmed (`scene/broken-marks` set); an annotation keeps rendering as long as ≥1 mark resolves. + +## Fundamental fix — per-mark lane interaction (not per-bar) + +Handles/drag were attached to each visual bar, so a mark rendered as N pieces got N handle pairs (handles at every clip boundary). Two root fixes: +- [x] **Interaction decoupled from visual pieces**: colored bars are per-piece (pointer-events none for draft); a separate per-MARK layer spans the mark's whole extent = one draggable unit + exactly two end-handles + active ring. Robust no matter how many pieces a mark has. +- [x] **`merge-bars` absorbs ≤1-frame gaps**: independent frame-rounding of clip `:start` could leave a 1-frame gap between adjacent clips and spuriously split a mark's bar; a 1-frame gap is a rounding artifact, not real discontinuity, so it now coalesces (still per-mark, never fuses distinct marks). diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index eac3ea9..8443a74 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -700,6 +700,8 @@ html.dark .timeline-head { one a drawing binds to, and which you're about to delete */ .mark-block.active-mark { box-shadow: inset 3px 0 0 var(--ink); background: var(--shade, rgba(128,128,128,.14)); border-radius: 2px; padding: 2px 0 2px 3px; margin-left: -3px; } +.mark-block.broken { opacity: 0.6; } +.mark-block.broken .pt-chip-name { text-decoration: line-through; } .note-drop { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-height: 20px; margin: 2px 0 2px 14px; padding: 2px 4px; border: 1px dashed var(--mute); border-radius: 0; } .note-drop-label { font-size: 10px; color: var(--mute); } diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index 619033c..9fd9337 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -787,6 +787,9 @@ proxy (get-in scene [:groups pid]) ctx (:parent (get-in scene [:groups ann])) len (scene/length (scene/content-segments scene ctx)) + ;; the dragged endpoint is a whole frame; the FIXED endpoint arrives + ;; EXACT (fractional) so its mark re-derives identically. Only the fixed + ;; end must avoid rounding here — the clamp keeps [la lb) valid half-open. la* (max 0 (min la (dec len))) lb* (max (inc la*) (min lb len))] (if (= :proxy (:type proxy)) diff --git a/tl/src/tl/scene.cljs b/tl/src/tl/scene.cljs index c4d96a7..2f7c64c 100644 --- a/tl/src/tl/scene.cljs +++ b/tl/src/tl/scene.cljs @@ -55,10 +55,12 @@ segs))) (defn merge-bars - "Coalesce [lo hi) ranges that meet at a frame boundary into single bars, so a - continuous selection spanning several clips reads as one piece. Endpoints snap - to whole frames first, which absorbs the sub-frame gaps OTIO's fractional - media offsets leave between adjacent clips; only a real (≥1 frame) gap splits." + "Coalesce [lo hi) ranges that meet at a boundary into single bars, so a + continuous run spanning several clips reads as one piece. Ranges are half-open, + so adjacent clips share a boundary (A.hi == B.lo) and merge exactly — no gap to + fudge. Endpoints snap to whole frames first (a bar covers the frames it + touches); only a real (≥1 frame) gap splits. Called per-mark, so it never fuses + two distinct marks." [bars] (reduce (fn [acc [lo hi]] (let [lo (js/Math.floor lo) hi (js/Math.ceil hi) @@ -88,22 +90,6 @@ (declare resolve resolve-mark) -(defn- target-range - "Source [xs xe) + :track of a referenceable id (a clip/timeline group, or a - single-clip mark). Single-segment by the ref invariant; nil if unknown." - [scene id] - (let [segs (cond - (grp scene id) (resolve scene id) - (find-mark scene id) (let [[gid m] (find-mark scene id)] - (resolve-mark scene gid m)) - :else nil)] - (when (seq segs) - {:xs (-> segs first :src first) - :xe (-> segs last :src second) - :track (-> segs first :track) - :thumb (-> segs first :thumb) - :thumb-start (-> segs first :thumb-start)}))) - (defn- target-segs "Resolved segments (local 0-based) of a referenceable id: a group (clip, timeline, proxy, annotation) or a single mark. One segment for a clip/subclip @@ -115,6 +101,35 @@ (resolve-mark scene gid m)) :else nil)) +;; --- ref :at <-> source : the ONE interpretation of a ref's :at ----------- +;; {:ref id :at n}'s :at is a LOCAL frame of id's OWN resolved timeline (n<0 from +;; the end, -1 = the exclusive end). Every :at<->frame conversion goes through +;; id's FULL resolution (target-segs) + the flat helpers, so it is correct whether +;; id is one clip or a scattered multi-clip proxy. Do NOT summarise a target to a +;; single [xs xe] span — that only holds for a single contiguous segment. + +(defn- ref-len [scene ref] (length (target-segs scene ref))) + +(defn- at->local + "Normalise a ref's :at (n<0 from the end) to a non-negative local frame." + [scene ref at] + (if (neg? at) (+ (ref-len scene ref) at 1) at)) + +(defn- at->src + "Source frame at ref point {:ref :at} — id's own local frame → source — or nil + if the ref dangles or the frame falls outside id." + [scene ref at] + (when-let [segs (seq (target-segs scene ref))] + (let [l (at->local scene ref at)] + (when (<= 0 l (length segs)) + (local->source segs l))))) + +(defn- src->at + "Target-local :at for source frame `src` within `ref` — source → id's own + local frame — or nil if outside id." + [scene ref src] + (some-> (seq (target-segs scene ref)) (source->local src))) + (defn- point-frame "Resolve a point (living in group `gid`) to {:frame :track}, or nil if a ref dangles." @@ -126,12 +141,8 @@ :track nil}) (map? point) - (when-let [{:keys [xs xe track thumb thumb-start]} (target-range scene (:ref point))] - (let [n (:at point) - f (if (neg? n) (+ xe n 1) (+ xs n))] - (when (<= xs f xe) - (cond-> {:frame f :track track} - thumb (assoc :thumb thumb :thumb-at (+ (or thumb-start 0) (- f xs))))))))) ; nil if trimmed out of range + (when-let [f (at->src scene (:ref point) (:at point))] ; nil if dangling / out of range + {:frame f}))) (defn- rebase "Shift `sub`'s :local to start at 0 and stamp :mark = `id` (the mark now owns @@ -208,7 +219,7 @@ [scene gid] (when-let [mid (first (broken-marks scene gid))] (let [ref (->> (:marks (grp scene gid)) (some #(when (= mid (:id %)) %)) :start :ref)] - (if (target-range scene ref) "reference trimmed away" "referenced clip deleted")))) + (if (seq (target-segs scene ref)) "reference trimmed away" "referenced clip deleted")))) ;; --- what the current timeline is made of -------------------------------- @@ -287,6 +298,18 @@ (merge-bars (mapcat (fn [{[a b] :src}] (pieces ctx-segs a b)) ss)))))) vec)) +(defn mark-extent + "EXACT context-local [lo hi] of mark `mark-id` of annotation `gid` — the true + min piece-start / max piece-end, WITHOUT merge-bars' floor/ceil. Endpoint + editing must use this (not a rounded lane bar) or the fixed end drifts a frame + per re-roll. `ctx-segs` = content-segments of ctx." + [scene ctx gid mark-id ctx-segs] + (let [pcs (->> (nested-src-marks scene ctx gid) + (filter #(= mark-id (:mark %))) + (mapcat (fn [{[a b] :src}] (pieces ctx-segs a b))))] + (when (seq pcs) + [(reduce min (map first pcs)) (reduce max (map second pcs))]))) + (defn clip-loss? "True when annotation `gid` loses any content once clipped to its immediate parent annotation — it references frames outside the parent timeline, so it's @@ -303,14 +326,19 @@ (defn selection->marks "Split local range [la lb) of context `ctx` into a run of single-clip ref marks, one per content segment it crosses (the 'no cross-clip marks' rule). - Each references the segment's source id with the right offsets." + Each references the segment's source id with the right offsets. Offsets are + ROUNDED to whole frames here — this is where user frame-accuracy is applied + (clips stay exact for tiling; the mark snaps to a whole frame of its clip)." [scene ctx la lb] (mapv (fn [{:keys [mark src]}] - (let [{:keys [xs]} (target-range scene mark) - [a b] src] + ;; :at is the target's OWN local frame (src->at), matching resolve-mark's + ;; slice — correct even when the target is a scattered multi-clip proxy. + ;; The piece lies within one target segment, so its length maps 1:1. + (let [[a b] src + a' (src->at scene mark a)] {:id (str (random-uuid)) ; string so it survives JSON - :start {:ref mark :at (- a xs)} - :end {:ref mark :at (- b xs)}})) + :start {:ref mark :at (js/Math.round a')} + :end {:ref mark :at (js/Math.round (+ a' (- b a)))}})) (slice (content-segments scene ctx) la lb))) (defn reconcile-run @@ -372,12 +400,6 @@ [segs mid f] (some (fn [{m :mark [c _] :local}] (when (= m mid) (+ c (or f 0)))) segs)) -(defn- at->frame - "Normalise a ref's :at to a non-negative mark-time frame (resolves :at -1 etc.)." - [scene ref at] - (if (neg? at) - (let [{:keys [xs xe]} (target-range scene ref)] (+ (- xe xs) at 1)) - at)) (defn- restore-mark [m] ;; keep :id keyworded in lockstep with the refs that target it: a nested @@ -437,13 +459,18 @@ migrate))]))) anns)) +(defn display-point + "A ref-point {:ref :at} as a display cell {:seg :f}: the target id plus its OWN + local frame (context-local, whole-frame; not translated to the raw clip). The + shared basis for both the annotation editor rows and the jump popover, so they + can never disagree on how a point reads." + [scene {:keys [ref at]}] + {:seg ref :f (js/Math.round (at->local scene ref at))}) + (defn- clip-row - "Editor row {:s … :e …} for a single-clip/subclip ref mark, in mark time. - Frames are rounded for display — OTIO's fractional media offsets can leave a - ref's :at sub-frame, and the editor/labels want whole frames." + "Editor row {:s … :e …} for a single-clip/subclip ref mark, in mark time." [scene {:keys [start end]}] - {:s {:seg (:ref start) :f (js/Math.round (at->frame scene (:ref start) (:at start)))} - :e {:seg (:ref end) :f (js/Math.round (at->frame scene (:ref end) (:at end)))}}) + {:s (display-point scene start) :e (display-point scene end)}) (defn mark-row "One editor row for a mark. A plain clip/subclip ref collapses to {:s :e} @@ -476,25 +503,25 @@ clip mark-group per clip (source range + track), and the root timeline. The otio is only a seed — nothing here reads it again. - Frames are SNAPPED to whole integers here, at the one boundary where OTIO's - fractional RationalTime enters: a frame-based tool has no meaning below a whole - frame, so we round once, at the source, and everything downstream (source - ranges, mark :at offsets, seeks, labels) stays frame-accurate by construction." + Clip ranges are kept EXACT (OTIO's fractional RationalTime), NOT rounded: + adjacent clips must share their half-open boundary exactly to tile without a + gap, and independently rounding :start vs length breaks that. Frame-accuracy is + applied where it belongs — at the mark the user creates (selection->marks + rounds :at) — and at display, never by rounding clips or bars." [{:keys [duration tracks]}] - (let [r (fn [x] (js/Math.round x)) - vtracks (filter #(= :video (:kind %)) tracks) + (let [vtracks (filter #(= :video (:kind %)) tracks) track-map (into {} (map (fn [t] [(keyword (str "t" (:index t))) {:name (:name t)}])) vtracks) clips (into {} (for [t vtracks c (:clips t)] [(keyword (:id c)) {:type :clip :parent nil :name (:name c) - :start (r (:start c)) ; timeline position (frames) + :start (:start c) ; timeline position (frames) :marks [{:id (keyword (str (:id c) "-m")) - :start (r (:media-in c)) - :end (r (+ (:media-in c) (:duration c))) + :start (:media-in c) + :end (+ (:media-in c) (:duration c)) :track (keyword (str "t" (:index t)))}]}]))] {:tracks track-map :groups (assoc clips :root {:type :timeline :parent nil - :marks [{:id :root-m :start 0 :end (r duration)}]})})) + :marks [{:id :root-m :start 0 :end duration}]})})) (defn clip-name [scene gid] (:name (grp scene gid))) @@ -519,7 +546,7 @@ "Ref-point {:ref :at} for mark-time frame `f` within content-segment `seg` (the convention selection->marks uses, so it resolves identically)." [scene {:keys [mark src]} f] - {:ref mark :at (+ (- (first src) (:xs (target-range scene mark))) (or f 0))}) + {:ref mark :at (+ (src->at scene mark (first src)) (or f 0))}) (defn- runs "Contiguous runs of `gid`'s marks in `ctx`-local coords, each {:id :lo :len} @@ -551,7 +578,7 @@ [scene ctx gid] (mapv (fn [{:keys [id lo]}] (let [st (:start (some #(when (= id (:id %)) %) (:marks (grp scene gid))))] - {:local lo :seg (:ref st) :f (js/Math.round (at->frame scene (:ref st) (:at st)))})) + (assoc (display-point scene st) :local lo))) ; same cell as the editor row (runs scene ctx gid))) (defn linkables diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 8a159c3..334738e 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -471,12 +471,13 @@ (let [d (d-of e)] (when (or @moved? (> (js/Math.abs d) 3)) (reset! moved? true) + ;; round ONLY the dragged endpoint; the fixed endpoint stays + ;; exact so selection->marks re-derives its mark identically. (let [[la lb] (case mode - :move [(+ lo d) (+ hi d)] - :start [(+ lo d) hi] - :end [lo (+ hi d)])] - (rf/dispatch [::events/reroll-proxy ann mark-id - (js/Math.round la) (js/Math.round lb)]))))) + :move [(js/Math.round (+ lo d)) (js/Math.round (+ hi d))] + :start [(js/Math.round (+ lo d)) hi] + :end [lo (js/Math.round (+ hi d))])] + (rf/dispatch [::events/reroll-proxy ann mark-id la lb]))))) up (fn up [_] (.removeEventListener js/document "mousemove" move) (.removeEventListener js/document "mouseup" up) @@ -624,6 +625,7 @@ thumbs @(rf/subscribe [::subs/thumbnails]) authoring? (some? @(rf/subscribe [::subs/draft-group])) active-mark @(rf/subscribe [::subs/active-mark]) + ctx @(rf/subscribe [::subs/context]) linking @(rf/subscribe [::subs/linking]) len @(rf/subscribe [::subs/length]) width (px len fps zoom) @@ -656,17 +658,17 @@ label]]))] [:div.ann-lanes {:style {:height lane-h :width width} :on-mouse-down #(scrub! @content fps zoom %)} - ;; annotation bars — solid for saved, dashed for the in-progress draft - ;; visible annotations: full bars with labels, greedily packed into lanes + ;; VISUAL bars — one rectangle per piece (solid saved, dashed draft). + ;; Interaction is NOT here: a mark can span several pieces, so its + ;; drag/handles live on ONE per-mark layer below (that's the fix for + ;; handles-at-every-clip-boundary). Draft pieces are pointer-events + ;; none so the per-mark layer receives the events. (for [a visible [j [lo hi mid]] (map-indexed vector (:bars a))] ^{:key (str (:id a) "-" j)} [:div.ann-bar {:title (:name a) :on-mouse-down - (if (:draft a) - ;; draft/edit: drag the whole mark, or click (no drag) - ;; to draw on it. Both make it the active mark. - (fn [e] (mark-drag! (:id a) mid :move lo hi fps zoom e)) + (when-not (:draft a) (fn [e] (.stopPropagation e) (if linking (do (.preventDefault e) ; pick: link to this timeline @@ -680,25 +682,41 @@ :style {:top (+ 2 (* (lane-of (:id a)) 18)) :height 14 :left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom)) :background (str (:color a) (if (:draft a) "44" "cc")) - :cursor (if (:draft a) "grab" "pointer") + :cursor (if (:draft a) "default" "pointer") + :pointer-events (when (:draft a) "none") :border-radius 2 - :border (str (if (:draft a) "1px dashed " "1px solid ") (:color a)) - :box-shadow (when (and mid (= mid active-mark)) "0 0 0 2px var(--ink)") - :z-index (when (and mid (= mid active-mark)) 3)}} - ;; edge handles: drag to roll one endpoint (adds/drops clips as it - ;; crosses a boundary via roll-proxy). Only on the draft/edit mark. - (when (:draft a) - (let [grip {:width 3 :height 9 :border-radius 2 - :background "var(--paper)" :border "1px solid var(--ink)"} - zone {:position "absolute" :top 0 :width 9 :height "100%" :cursor "ew-resize" - :display "flex" :align-items "center" :justify-content "center"}] - [:<> - [:div.bar-handle {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :start lo hi fps zoom e)) - :style (assoc zone :left -3)} - [:div {:style grip}]] - [:div.bar-handle {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :end lo hi fps zoom e)) - :style (assoc zone :right -3)} - [:div {:style grip}]]]))]) + :border (str (if (:draft a) "1px dashed " "1px solid ") (:color a))}}]) + ;; per-MARK interaction layer (draft/edit only): each mark is ONE unit + ;; spanning its whole extent — body-drag = move, click = draw, and + ;; exactly two end-handles = resize (roll-proxy). No matter how many + ;; visual pieces the mark has, it gets one handle pair, at its ends. + (for [a visible :when (:draft a) + mid (distinct (map #(nth % 2) (:bars a))) + ;; EXACT context-local extent (never the rounded bar). The drag + ;; keeps the FIXED endpoint at this exact value so its mark + ;; re-derives identically — that's what stops the other end drifting. + :let [ext (scene/mark-extent scene ctx (:id a) mid segs)] + :when ext + :let [[lo hi] ext + active? (= mid active-mark)]] + ^{:key (str "edit-" (:id a) "-" mid)} + [:div.mark-edit {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :move lo hi fps zoom e)) + :style {:position "absolute" :top (+ 2 (* (lane-of (:id a)) 18)) :height 14 + :left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom)) + :cursor "grab" :border-radius 2 + :box-shadow (when active? "0 0 0 2px var(--ink)") + :z-index (if active? 4 2)}} + (let [grip {:width 3 :height 9 :border-radius 2 + :background "var(--paper)" :border "1px solid var(--ink)"} + zone {:position "absolute" :top 0 :width 9 :height "100%" :cursor "ew-resize" + :display "flex" :align-items "center" :justify-content "center"}] + [:<> + [:div.bar-handle {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :start lo hi fps zoom e)) + :style (assoc zone :left -3)} + [:div {:style grip}]] + [:div.bar-handle {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :end lo hi fps zoom e)) + :style (assoc zone :right -3)} + [:div {:style grip}]]])]) ;; visible annotation labels (for [a visible :let [[lo _] (first (:bars a))] :when lo] ^{:key (str "lbl-" (:id a))} @@ -1173,7 +1191,10 @@ [:button.edit-btn {:title "Edit" :on-click #(rf/dispatch [::events/edit-draft (:id a)])} "✎"]]] [:div.ann (merge drag-props {:id (str "ann-" (name (:id a))) - :style {"--ann-color" (:color a)}}) + ;; grey out an annotation with dangling marks (it sorts to the + ;; bottom too) — still shown so its surviving marks stay usable + :style (cond-> {"--ann-color" (:color a)} + (:broken a) (assoc :opacity 0.55))}) [:div.ann-head [:div.ann-title [:span.ann-swatch {:style {:background (:color a)}}] @@ -1336,6 +1357,7 @@ live @(rf/subscribe [::subs/active-note-set]) active @(rf/subscribe [::subs/active-mark]) rows (scene/marks->rows scene (:marks d)) + broken (set (scene/broken-marks scene gid)) ; marks whose refs no longer resolve valid? (or root? (and (not (str/blank? (:name d))) (seq (:marks d)))) save #(when valid? (rf/dispatch [::events/save-group gid (dissoc d :draft :gid) @@ -1391,6 +1413,7 @@ [:div.mark-block (merge (mark-drag-props put d i) {:class (str (when (= i (:src drag)) "dragging ") (when (= mark-id active) "active-mark ") + (when (contains? broken mark-id) "broken ") (when (and (:src drag) (= i (:over drag)) (not= i (:src drag))) "drop-before"))}) [:div.mark-row @@ -1399,6 +1422,8 @@ (.. e -dataTransfer (setData "text/mark-idx" (str i))) (set! (.. e -dataTransfer -effectAllowed) "move") (reset! mark-drag {:src i}))} "⠿"] + (when (contains? broken mark-id) + [:span.ann-warn {:title "This mark's clip/reference no longer resolves"} "△ "]) ;; a proxy collapses its cross-clip run to one read-only span ;; (first-clip start → last-clip end); endpoint editing is via the ;; lane handles (a later chunk). A plain clip mark stays editable. diff --git a/tl/test/tl/scene_test.cljs b/tl/test/tl/scene_test.cljs index 44b62e8..ccde702 100644 --- a/tl/test/tl/scene_test.cljs +++ b/tl/test/tl/scene_test.cljs @@ -294,18 +294,26 @@ (is (= #{:t0 :t1} (s/tracks segs))) (is (= 200 (s/length segs))))))) -(deftest from-otio-snaps-fractional-frames - (testing "OTIO's fractional RationalTime is rounded to whole frames at seed" +(deftest from-otio-keeps-clip-ranges-exact + (testing "clips keep OTIO's exact fractional ranges (so adjacent clips tile); + frame-accuracy is applied at mark creation, not here" (let [parsed {:fps 24 :duration 199.6 :tracks [{:index 0 :kind :video :name "W" :clips [{:id "t0-c0" :name "a" :start 0.2 :media-in 188.87 :duration 100.4}]}]} - scene (s/from-otio parsed) - mark (first (get-in scene [:groups :t0-c0 :marks]))] - (is (= 0 (get-in scene [:groups :t0-c0 :start]))) ; 0.2 -> 0 - (is (= 189 (:start mark))) ; media-in 188.87 -> 189 - (is (= 289 (:end mark))) ; 188.87+100.4=289.27 -> 289 - (is (= 200 (get-in scene [:groups :root :marks 0 :end]))) ; 199.6 -> 200 - (is (every? integer? [(:start mark) (:end mark)]))))) + mark (first (get-in (s/from-otio parsed) [:groups :t0-c0 :marks]))] + (is (= 188.87 (:start mark))) ; exact, not rounded + (is (= 289.27 (:end mark)))))) + +(deftest selection-rounds-at-to-whole-frames + (testing "a selection over a clip with fractional layout still yields whole-frame + :at offsets (clip stays exact, the mark snaps)" + (let [scene {:tracks {:t0 {:name "W"}} + :groups {:root {:type :timeline :parent nil :marks [{:id :m/r :start 0 :end 100}]} + :fr {:type :clip :parent nil :start 0.3 ; fractional timeline pos + :marks [{:id :m/fr :start 10.4 :end 110.4 :track :t0}]}}} + run (s/selection->marks scene :root 20 60)] + (is (seq run)) + (is (every? integer? (mapcat (juxt #(get-in % [:start :at]) #(get-in % [:end :at])) run)))))) ;; ========================================================================= ;; Suite 4 — draft rows <-> marks (the two-input editor)