Detect broken child mark-refs; restore the timeline/top divider

- scene: point-frame rejects out-of-range offsets so broken-marks catches
  boundary shifts and cascades, not just deleted refs; add broken-reason
- subs: annotations carry :broken/:reason and sort broken ones to the bottom
- views: ⚠ on broken annotations (hover = reason); drag-resize the top/timeline
  split via --top-h (vh)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-28 23:45:43 -04:00
parent c03756d3e1
commit 7a960afb5c
4 changed files with 55 additions and 5 deletions

View file

@ -97,8 +97,9 @@
(map? point) (map? point)
(when-let [{:keys [xs xe track]} (target-range scene (:ref point))] (when-let [{:keys [xs xe track]} (target-range scene (:ref point))]
(let [n (:at point)] (let [n (:at point)
{:frame (if (neg? n) (+ xe n 1) (+ xs n)) :track track})))) f (if (neg? n) (+ xe n 1) (+ xs n))]
(when (<= xs f xe) {:frame f :track track}))))) ; nil if trimmed out of range
(defn resolve-mark (defn resolve-mark
"One mark → its segment(s) (1 for a ref mark, 1+ for an absolute mark), with "One mark → its segment(s) (1 for a ref mark, 1+ for an absolute mark), with
@ -141,6 +142,13 @@
(filterv (fn [m] (empty? (resolve-mark scene gid m)))) (filterv (fn [m] (empty? (resolve-mark scene gid m))))
(mapv :id))) (mapv :id)))
(defn broken-reason
"A short why for `gid`'s first broken mark, or nil if nothing's broken."
[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"))))
;; --- what the current timeline is made of -------------------------------- ;; --- what the current timeline is made of --------------------------------
(defn content-segments (defn content-segments

View file

@ -62,13 +62,15 @@
(keep (fn [[gid g]] (keep (fn [[gid g]]
(when (and (= :annotation (:type g)) (= ctx (:parent g))) (when (and (= :annotation (:type g)) (= ctx (:parent g)))
(let [src-segs (scene/resolve scene gid) (let [src-segs (scene/resolve scene gid)
bars (vec (mapcat (fn [{[a b] :src}] (scene/pieces segs a b)) src-segs))] bars (vec (mapcat (fn [{[a b] :src}] (scene/pieces segs a b)) src-segs))
reason (scene/broken-reason scene gid)]
{:id gid :name (:name g) :color (or (:color g) "#4e8fc2") {:id gid :name (:name g) :color (or (:color g) "#4e8fc2")
:content (:content g) :children (count (:marks g)) :content (:content g) :children (count (:marks g))
:nested (get nested gid 0) :nested (get nested gid 0)
:draft (boolean (:draft g)) :draft (boolean (:draft g))
:broken (boolean reason) :reason reason
:start (or (ffirst bars) 0) :bars bars})))) :start (or (ffirst bars) 0) :bars bars}))))
(sort-by :start) (sort-by (juxt :broken :start)) ; broken annotations sink to the bottom
vec)))) vec))))
;; the annotation the playhead is currently inside (or the latest one passed) — ;; the annotation the playhead is currently inside (or the latest one passed) —

View file

@ -228,7 +228,7 @@
^{:key (:id a)} ^{:key (:id a)}
[:div.ann {:id (str "ann-" (name (:id a))) :class (when (= (:id a) active) "active")} [:div.ann {:id (str "ann-" (name (:id a))) :class (when (= (:id a) active) "active")}
[:div.ann-head [:div.ann-head
[:div.ann-title (:name a)] [:div.ann-title (when (:broken a) [:span {:title (:reason a)} "⚠ "]) (:name a)]
[:div.ann-actions [:div.ann-actions
[jump-control open a] [jump-control open a]
[:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])} [:button.expand-btn {:title "Expand" :on-click #(rf/dispatch [::events/expand (:id a)])}
@ -324,6 +324,17 @@
[:input {:type "range" :min 8 :max 120 :value row-h [:input {:type "range" :min 8 :max 120 :value row-h
:on-change #(rf/dispatch [::events/set-row-h (js/parseFloat (.. % -target -value))])}]]])) :on-change #(rf/dispatch [::events/set-row-h (js/parseFloat (.. % -target -value))])}]]]))
(defn- drag-top!
"Drag the horizontal divider: set --top-h (in vh) from the pointer's Y."
[ev]
(.preventDefault ev)
(letfn [(move [e] (let [vh (-> (.-clientY e) (/ (.-innerHeight js/window)) (* 100) (max 10) (min 85))]
(.setProperty (.. js/document -documentElement -style) "--top-h" (str vh "vh"))))
(up [_] (.removeEventListener js/document "pointermove" move)
(.removeEventListener js/document "pointerup" up))]
(.addEventListener js/document "pointermove" move)
(.addEventListener js/document "pointerup" up)))
(defn main-panel [] (defn main-panel []
(let [status @(rf/subscribe [::subs/status]) (let [status @(rf/subscribe [::subs/status])
draft? (some? @(rf/subscribe [::subs/draft-group]))] draft? (some? @(rf/subscribe [::subs/draft-group]))]
@ -331,6 +342,7 @@
[:div.top [:div.top
[:div.video-pane [video-monitor] [frame-readout]] [:div.video-pane [video-monitor] [frame-readout]]
[:div.annot-pane (if draft? [annotation-form] [commentary])]] [:div.annot-pane (if draft? [annotation-form] [commentary])]]
[:div.divider-h {:on-pointer-down drag-top!}]
[:div.timeline-pane [:div.timeline-pane
[breadcrumbs] [breadcrumbs]
[toolbar] [toolbar]

View file

@ -229,6 +229,34 @@
(is (= {:seg :clip-a :f 0} (:s (first rows)))) (is (= {:seg :clip-a :f 0} (:s (first rows))))
(is (= {:seg :clip-a :f 100} (:e (first rows))))))) (is (= {:seg :clip-a :f 100} (:e (first rows)))))))
;; =========================================================================
;; Suite 5 — a parent breaking a child's mark references
;; =========================================================================
(deftest broken-when-referenced-mark-deleted
(testing "deleting the referenced subclip dangles the child's mark"
(let [del (update-in x+y [:groups :ann-x :marks] #(vec (rest %)))] ; drop s0
(is (= [:m/y0] (s/broken-marks del :ann-y)))
(is (= "referenced clip deleted" (s/broken-reason del :ann-y))))))
(deftest broken-when-boundaries-shift-out-of-range
(testing "shrinking s0 below the child's :at makes the offset unreferenceable"
;; y0 refs s0 at 10..20; trim s0 to length 15 so 20 is past its end
(let [trim (assoc-in x+y [:groups :ann-x :marks 0 :end] {:ref :clip-b :at 65})]
(is (= 15 (s/length (s/resolve-mark trim :ann-x (get-in trim [:groups :ann-x :marks 0])))))
(is (= [:m/y0] (s/broken-marks trim :ann-y)))
(is (= "reference trimmed away" (s/broken-reason trim :ann-y))))))
(deftest broken-cascades-through-a-subclip
(testing "if the subclip's own ref breaks, the child referencing it breaks too"
(let [gone (update x+y :groups dissoc :clip-b)] ; s0 refs clip-b, y0 refs s0
(is (= [:m/y0] (s/broken-marks gone :ann-y)))
(is (= "referenced clip deleted" (s/broken-reason gone :ann-y))))))
(deftest healthy-annotation-has-no-reason
(testing "broken-reason is nil when every mark resolves"
(is (nil? (s/broken-reason x+y :ann-y)))))
(deftest repeats-are-unambiguous (deftest repeats-are-unambiguous
(testing "two instances of A share a source frame but distinct locals (local is master)" (testing "two instances of A share a source frame but distinct locals (local is master)"
(let [scene (with-group base :ann (let [scene (with-group base :ann