feat: expandable annotations with nested timelines

Each annotation can be expanded into its own nested timeline. Reuses the
dormant :timeline-stack: annotations carry a :parent (:root = top level),
expanding pushes the annotation id, and only annotations whose :parent
matches the stack top are surfaced — so nested annotations stay invisible
on the main timeline.

- crop: the expanded annotation's own spans define a time window + the set
  of involved tracks; non-involved tracks collapse to height 0 (CSS-
  transitioned, so tracks animate away and survivors slide up), and the
  view offsets/crops to the window
- breadcrumb bar + back button (shown only when nested) fully restore the
  main timeline
- new annotations attach to the current context; the expand button shows a
  child count and delete removes the whole subtree
- seed data (Opening rally + 2 children, Net-cam beat) for testing, plus a
  one-time localStorage reset so it shows

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-27 16:01:11 -04:00
parent f67baf4b99
commit 30cfbf3a1f
6 changed files with 241 additions and 60 deletions

View file

@ -94,6 +94,13 @@ body { overflow: hidden; }
.clip.clickable { cursor: copy; outline: 1px solid #5ab07a; }
.expand-btn {
background: none; color: #7aa6d6; border: 1px solid transparent;
border-radius: 4px; padding: 1px 6px; cursor: pointer; font-size: 11px;
}
.expand-btn:hover { color: #cfe3f5; border-color: #3a6ea5; }
.expand-btn.has-children { color: #cfe3f5; border-color: #3a6ea5; background: #16263a; }
/* --- authoring form (fills the annotation pane) ------------------------- */
.form {
flex: 1; min-width: 0; overflow-y: auto;
@ -172,6 +179,21 @@ body { overflow: hidden; }
/* --- timeline region ---------------------------------------------------- */
.timeline-pane { flex: 1; display: flex; flex-direction: column; min-height: 150px; }
/* --- breadcrumb bar (only present when expanded into a nested timeline) -- */
.crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
padding: 5px 8px; background: #0c0c0c; border-bottom: 1px solid #222;
flex-shrink: 0; font-size: 12px; }
.back-btn { background: #1c2a38; color: #cfe3f5; border: 1px solid #3a6ea5;
border-radius: 4px; padding: 2px 10px; cursor: pointer; font-size: 12px;
margin-right: 4px; }
.back-btn:hover { background: #24384e; }
.crumb-item { display: inline-flex; align-items: center; gap: 6px; }
.crumb-sep { color: #555; }
.crumb { background: none; border: none; color: #7aa6d6; cursor: pointer;
font-size: 12px; padding: 2px 2px; }
.crumb:hover { color: #cfe3f5; text-decoration: underline; }
.crumb.current { color: #cfe3f5; cursor: default; text-decoration: none; }
.toolbar { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center;
padding: 6px 8px; background: #0c0c0c; border-bottom: 1px solid #222;
flex-shrink: 0; }
@ -194,9 +216,16 @@ body { overflow: hidden; }
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.hscroll { overflow-x: auto; flex: 1; }
.content { position: relative; cursor: ew-resize; }
/* overflow:hidden crops clips that fall outside the (possibly windowed) view */
.content { position: relative; cursor: ew-resize; overflow: hidden; }
.track-row { position: relative; background: #1c1c1c; border-bottom: 1px solid #2a2a2a; }
/* height transitions so cropped-out tracks animate away and the survivors
slide up to fill the gap ("tracks leave / get rearranged") */
.track-row { position: relative; background: #1c1c1c; border-bottom: 1px solid #2a2a2a;
overflow: hidden; transition: height .3s ease, opacity .3s ease; }
.track-row.collapsed { opacity: 0; border-bottom: none; }
.gutter-label { transition: height .3s ease, opacity .3s ease; }
.gutter-label.collapsed { opacity: 0; border-bottom: none; padding: 0; }
.clip {
position: absolute; top: 1px; box-sizing: border-box; overflow: hidden;
background: #3a6ea5; border: 1px solid #16324f; border-radius: 2px;

View file

@ -19,7 +19,15 @@
(rdom/unmount-component-at-node root-el)
(rdom/render [views/main-panel] root-el)))
;; one-time dev reset: wipe saved marks once so the nested-timeline seed data
;; shows, then flag it so persistence resumes normally. Safe to delete later.
(defn- reset-marks-once! []
(when-not (.getItem js/localStorage "tl/seed-reset-1")
(.removeItem js/localStorage "tl/marks")
(.setItem js/localStorage "tl/seed-reset-1" "1")))
(defn init []
(reset-marks-once!)
(re-frame/dispatch-sync [::events/initialize-db])
(re-frame/dispatch [::events/load-otio])
(dev-setup)

View file

@ -10,12 +10,36 @@
;; Mark-groups: the annotation layer (see tl.marks for the grammar).
;; Clips come from :timeline; these are the user-authored groups + the
;; timeline descriptors. A timeline's :mark-groups says which annotations
;; IT surfaces ("*" | [ids] | {:except #{ids}}); the top of :timeline-stack
;; decides what's currently visible. For now everything is hardcoded.
;; root timeline descriptor.
;;
;; Nesting: every annotation has a :parent (the context it lives in; :root =
;; top level). "Expanding" an annotation pushes its id onto :timeline-stack;
;; the top of the stack is the current context, and only annotations whose
;; :parent matches it are surfaced — so a nested annotation is invisible on
;; the main timeline. The context annotation's own spans crop the timeline
;; (time window + involved tracks) it's expanded into.
;;
;; The annotations below are seed data (shown on a fresh localStorage) so the
;; expand/nest flow is testable: "Opening rally" has two children.
:marks
{:timeline-stack [:root]
:groups {:root {:type :timeline :name "Sequence" :mark-groups "*"}}}
:groups {:root {:type :timeline :name "Sequence"}
:ann-rally
{:type :annotation :parent :root :name "Opening rally" :color "#4e8fc2"
:content "The first volley — CU coverage across the three principals."
:marks [:t2-c0 :t3-c0 :t4-c0]}
:ann-tashi-read
{:type :annotation :parent :ann-rally :name "Tashi's read" :color "#c2624e"
:content "She clocks the spin early."
:marks [[[:at :t2-c0 0] [:at :t2-c0 -1]]]}
:ann-art-react
{:type :annotation :parent :ann-rally :name "Art reacts" :color "#5ab07a"
:marks [:t3-c0]}
:ann-net
{:type :annotation :parent :root :name "Net-cam beat" :color "#9a7ac2"
:marks [:t6-c0]}}}
;; transient view state (mutates while scrubbing/zooming)
:view {:zoom 50 ; X zoom: pixels per SECOND (px/frame = zoom/fps)

View file

@ -87,13 +87,35 @@
(update-in db [:draft :rows]
#(into (subvec % 0 i) (subvec % (inc i))))))
;; --- nested timelines: expand an annotation / navigate back -------------
;; The timeline-stack is the breadcrumb trail. Expanding pushes an annotation
;; id; the top of the stack is the current context (see ::context-id).
(rf/reg-event-db
::expand-annotation
(fn [db [_ id]] (update-in db [:marks :timeline-stack] conj id)))
(rf/reg-event-db
::collapse ; the back button: pop one level
(fn [db _] (update-in db [:marks :timeline-stack]
(fn [s] (if (> (count s) 1) (pop s) s)))))
(rf/reg-event-db
::pop-to ; a breadcrumb: truncate to that id
(fn [db [_ id]]
(update-in db [:marks :timeline-stack]
(fn [s] (let [i (first (keep-indexed #(when (= id %2) %1) s))]
(if i (subvec s 0 (inc i)) s))))))
;; --- authoring: create / delete annotations (persisted) -----------------
(rf/reg-event-fx
::add-annotation
(fn [{:keys [db]} [_ {:keys [name content color marks]}]]
;; new annotations are children of whatever context we're currently in
(let [id (keyword (str "ann-" (random-uuid)))
group (cond-> {:type :annotation :name name :color color :marks marks}
parent (last (get-in db [:marks :timeline-stack]))
group (cond-> {:type :annotation :parent parent :name name :color color :marks marks}
(seq content) (assoc :content content))
db' (assoc-in db [:marks :groups id] group)]
{:db db' :tl/save (:marks db')})))
@ -101,13 +123,25 @@
(rf/reg-event-fx
::update-annotation
(fn [{:keys [db]} [_ id {:keys [name content color marks]}]]
(let [group (cond-> {:type :annotation :name name :color color :marks marks}
;; merge so :parent (and any other group keys) survive an edit; drop a
;; cleared :content explicitly rather than leaving the old text behind.
(let [group (cond-> {:name name :color color :marks marks :content nil}
(seq content) (assoc :content content))
db' (assoc-in db [:marks :groups id] group)]
db' (update-in db [:marks :groups id] merge group)]
{:db db' :tl/save (:marks db')})))
(rf/reg-event-fx
::delete-annotation
(fn [{:keys [db]} [_ id]]
(let [db' (update-in db [:marks :groups] dissoc id)]
;; remove the annotation and its whole nested subtree (so children aren't
;; orphaned), and pop the stack past anything we just deleted.
(let [groups (get-in db [:marks :groups])
doomed (loop [acc #{id}]
(let [more (into acc (keep (fn [[gid g]] (when (acc (:parent g)) gid)) groups))]
(if (= more acc) acc (recur more))))
db' (-> db
(update-in [:marks :groups] #(apply dissoc % doomed))
(update-in [:marks :timeline-stack]
(fn [s] (let [s' (vec (take-while #(not (doomed %)) s))]
(if (seq s') s' [:root])))))]
{:db db' :tl/save (:marks db')})))

View file

@ -36,37 +36,74 @@
(sort-by :start)
vec))))
;; The current context = top of the timeline-stack. :root is the main timeline;
;; otherwise it's the annotation whose nested timeline we're expanded into.
(rf/reg-sub
::current-timeline
:<- [::groups] :<- [::timeline-stack]
(fn [[groups stack] _] (get groups (last stack))))
(defn- admits?
"Does a timeline's :mark-groups rule surface annotation `gid`?"
[rule gid]
(cond
(= "*" rule) true
(vector? rule) (boolean (some #{gid} rule))
(map? rule) (not (contains? (set (:except rule)) gid))
:else false))
::context-id
:<- [::timeline-stack]
(fn [stack _] (or (last stack) :root)))
;; Annotations surfaced right now: those whose :parent is the current context.
;; (A missing :parent means top-level, i.e. :root — keeps old saved data valid.)
;; Each is enriched with how many children it has, so the UI can flag/expand it.
(rf/reg-sub
::annotations
:<- [::groups] :<- [::clip-index] :<- [::current-timeline]
(fn [[groups idx cur] _]
:<- [::groups] :<- [::clip-index] :<- [::context-id]
(fn [[groups idx ctx] _]
(when idx
(let [rule (:mark-groups cur "*")]
(->> groups
(filter (fn [[gid g]] (and (= :annotation (:type g)) (admits? rule gid))))
(let [anns (filter (fn [[_ g]] (= :annotation (:type g))) groups)
child-count (frequencies (keep (fn [[_ g]] (:parent g :root)) anns))]
(->> anns
(filter (fn [[_ g]] (= ctx (:parent g :root))))
(map (fn [[gid g]]
(let [spans (marks/resolve-group idx groups gid)]
{:id gid :name (:name g) :content (:content g) :color (:color g)
:marks (:marks g)
:marks (:marks g) :children (get child-count gid 0)
:spans spans :anchor (marks/anchor spans)
:instant? (every? :instant? spans)})))
(sort-by :anchor)
vec)))))
;; Breadcrumb trail for the stack: root + each expanded annotation's name.
(rf/reg-sub
::breadcrumbs
:<- [::groups] :<- [::timeline-stack]
(fn [[groups stack] _]
(mapv (fn [gid] {:id gid :name (:name (groups gid) (name gid))}) stack)))
;; Crop: when expanded into an annotation, its own spans define the visible
;; window (time range) and the set of involved tracks. nil at the root.
(rf/reg-sub
::crop
:<- [::groups] :<- [::clip-index] :<- [::context-id]
(fn [[groups idx ctx] _]
(when (and idx (not= :root ctx) (groups ctx))
(let [spans (marks/resolve-group idx groups ctx)]
(when (seq spans)
{:window [(reduce min (map :start spans)) (reduce max (map :end spans))]
:tracks (set (keep :track spans))})))))
;; Video tracks to render, filtered to the crop's involved tracks when expanded.
;; (Audio is never shown.) Returns {:track ... :visible? bool} so the timeline
;; can collapse — rather than drop — hidden rows and animate them out.
(rf/reg-sub
::visible-tracks
:<- [::tracks] :<- [::crop]
(fn [[tracks crop] _]
(let [tset (when (seq (:tracks crop)) (:tracks crop))]
(->> tracks
(filter #(= :video (:kind %)))
(mapv (fn [t] (assoc t :visible? (or (nil? tset) (contains? tset (:name t))))))))))
;; View transform applied to the timeline: a frame offset (so the crop window's
;; start sits at the left edge) and the visible span length in frames.
(rf/reg-sub
::view-offset :<- [::crop]
(fn [crop _] (if crop (first (:window crop)) 0)))
(rf/reg-sub
::view-frames :<- [::crop] :<- [::duration]
(fn [[crop dur] _] (if crop (- (second (:window crop)) (first (:window crop))) dur)))
;; The annotation the playhead is currently "in" — the latest one whose anchor
;; we've reached. Drives the commentary scroll + highlight.
(rf/reg-sub

View file

@ -29,9 +29,12 @@
sec (js/Math.floor (mod s 60))]
(str m ":" (when (< sec 10) "0") sec)))
(defn- center-on-playhead! [fps zoom playhead]
(defn- center-on-playhead!
"Scroll the timeline so `playhead` sits at the horizontal centre. `off` is the
crop offset in frames (0 at the root; the window start when expanded)."
[fps zoom off playhead]
(when-let [el @scroll-el]
(let [px (* (secs playhead fps) zoom)
(let [px (* (secs (- playhead off) fps) zoom)
max-sl (max 0 (- (.-scrollWidth el) (.-clientWidth el)))
target (-> (- px (/ (.-clientWidth el) 2)) (max 0) (min max-sl))]
(set! (.-scrollLeft el) target))))
@ -48,7 +51,8 @@
"Jump (seek video + move playhead) AND scroll the timeline to center the frame."
[fps frame]
(jump! fps frame)
(center-on-playhead! fps (or @(rf/subscribe [::subs/zoom]) 50) frame))
(center-on-playhead! fps (or @(rf/subscribe [::subs/zoom]) 50)
(or @(rf/subscribe [::subs/view-offset]) 0) frame))
;; --- authoring draft (in app-db at :draft; nil = closed) -----------------
;; A point is {:text "..."} (a typed frame) or {:clip <id> :setup <s> :occ <n>
@ -151,6 +155,7 @@
(fn [_fps]
[:video {:src media-src
:controls true
:preload "auto"
;; Keep playback inline on iOS instead of jumping to fullscreen.
:plays-inline true
:webkit-playsinline "true"
@ -208,7 +213,7 @@
"+ Add annotation"]]
(if (seq anns)
(doall
(for [{:keys [id content spans] nm :name :as ann} anns]
(for [{:keys [id content spans children] nm :name :as ann} anns]
(let [targets (vec (jump-targets fps nm spans))
one? (= 1 (count targets))]
^{:key id}
@ -225,6 +230,13 @@
;; the one bit of JS (anchor() misbehaves in a scroll box).
[:button.jump-btn {:popovertarget (str "jp-" (name id))}
(str "↪ jump (" (count targets) ")")])
[:button.expand-btn
{:title (if (pos? children)
(str "Expand — " children " nested")
"Expand into a nested timeline")
:class (when (pos? children) "has-children")
:on-click #(rf/dispatch [::events/expand-annotation id])}
(if (pos? children) (str "⤢ " children) "⤢")]
[:button.edit-btn
{:title "Edit annotation"
:on-click #(rf/dispatch [::events/open-draft
@ -250,8 +262,9 @@
(defn annotation-lane
"One labeled row per annotation, above the tracks. Bars are positioned in
display frames (same coordinate as clips), so they align with the timeline."
[fps zoom annotations active]
display frames (same coordinate as clips), so they align with the timeline.
`off` is the crop offset (frames): positions are drawn relative to it."
[fps zoom off annotations active]
[:div
(doall
(for [{:keys [id name spans preview?] c :color} annotations]
@ -261,7 +274,7 @@
[:div.ann-lane-row
(doall
(for [[j s] (map-indexed vector spans)]
(let [left (* (secs (:start s) fps) zoom)
(let [left (* (secs (- (:start s) off) fps) zoom)
w (* (secs (- (:end s) (:start s)) fps) zoom)]
^{:key j}
[:div.ann-bar
@ -270,10 +283,10 @@
:background (cond preview? (str color "44") on? color :else (str color "80"))
:border (str (if preview? "1px dashed " "1px solid ") color)}}])))
(when-let [a (some-> spans first :start)]
[:div.ann-bar-label {:style {:left (+ 4 (* (secs a fps) zoom))}} name])])))])
[:div.ann-bar-label {:style {:left (+ 4 (* (secs (- a off) fps) zoom))}} name])])))])
(defn clip-block [fps zoom row-h authoring? {:keys [id name media-in duration] :as _clip}]
(let [left (* (secs media-in fps) zoom)
(defn clip-block [fps zoom off row-h authoring? {:keys [id name media-in duration] :as _clip}]
(let [left (* (secs (- media-in off) fps) zoom)
width (max 1 (* (secs duration fps) zoom))]
[:div.clip {:style {:left left :width width :height (- row-h 2)
:line-height (str (- row-h 2) "px")}
@ -285,26 +298,34 @@
(insert-clip! (keyword id))))}
name]))
(defn track-row [fps zoom row-h authoring? track]
[:div.track-row {:style {:height row-h}}
(defn track-row
"A track lane. When cropped out of the current view it collapses to height 0
(CSS transitions the height) rather than vanishing, so tracks animate away."
[fps zoom off row-h authoring? track]
(let [vis? (:visible? track true)]
[:div.track-row {:class (when-not vis? "collapsed")
:style {:height (if vis? row-h 0)}}
;; clips render even while collapsing (overflow:hidden clips them) so the
;; row's contents shrink away with it instead of blanking instantly
(for [c (:clips track)]
^{:key (:id c)} [clip-block fps zoom row-h authoring? c])])
^{:key (:id c)} [clip-block fps zoom off row-h authoring? c])]))
(defn- ev->frames
"Pointer clientX -> clamped timeline frame, relative to the content div
(whose bounding rect already accounts for horizontal scroll)."
[content clientX zoom fps duration]
(whose bounding rect already accounts for horizontal scroll). `off`/`end`
are the visible window bounds in frames."
[content clientX zoom fps off end]
(let [px (- clientX (.-left (.getBoundingClientRect content)))
frames (* (/ px zoom) fps)]
(-> frames (max 0) (min duration))))
frames (+ off (* (/ px zoom) fps))]
(-> frames (max off) (min end))))
(defn- begin-scrub!
"mousedown on the timeline: seek to the click, then follow the mouse (even
outside the element) until mouseup."
[content zoom fps duration ev]
[content zoom fps off end ev]
(.preventDefault ev)
(let [to (fn [clientX]
(let [f (ev->frames content clientX zoom fps duration)]
(let [f (ev->frames content clientX zoom fps off end)]
(rf/dispatch [::events/set-playhead f]) ; move the red line now
(seek-to-frame! fps f))) ; and seek the video
move (fn [e] (to (.-clientX e)))
@ -321,12 +342,15 @@
(let [fps @(rf/subscribe [::subs/fps])
zoom @(rf/subscribe [::subs/zoom])
row-h @(rf/subscribe [::subs/row-h])
duration @(rf/subscribe [::subs/duration])
tracks @(rf/subscribe [::subs/tracks])
off @(rf/subscribe [::subs/view-offset])
vframes @(rf/subscribe [::subs/view-frames])
playhead @(rf/subscribe [::subs/playhead])
playing? @(rf/subscribe [::subs/playing?])
anns @(rf/subscribe [::subs/annotations])
active @(rf/subscribe [::subs/active-annotation])
;; all video tracks, each flagged :visible? by the crop (hidden ones
;; collapse rather than disappear, so they animate out)
vtracks @(rf/subscribe [::subs/visible-tracks])
;; live, un-saved preview of the annotation currently being authored
d @(rf/subscribe [::subs/draft])
preview (when d
@ -342,31 +366,34 @@
:spans spans})))
lane-anns (let [base (if (:id d) (remove #(= (:id %) (:id d)) anns) anns)]
(cond-> (vec base) preview (conj preview)))
vtracks (filter #(= :video (:kind %)) tracks) ; audio hidden
lane-h (* 18 (count lane-anns))
width (* (secs duration fps) zoom)]
end (+ off vframes)
width (* (secs vframes fps) zoom)]
(when (and playing? @following?)
(r/after-render #(center-on-playhead! fps zoom playhead)))
(r/after-render #(center-on-playhead! fps zoom off playhead)))
[:div.timeline
;; fixed left gutter: setup (track) names, always visible
;; fixed left gutter: setup (track) names, in lockstep with the rows
;; (collapsed tracks shrink here too so labels stay aligned)
[:div.gutter
[:div {:style {:height lane-h}}] ; spacer aligning labels with rows
(for [t vtracks]
^{:key (:id t)}
[:div.gutter-label {:style {:height row-h :line-height (str (dec row-h) "px")}}
[:div.gutter-label {:class (when-not (:visible? t) "collapsed")
:style {:height (if (:visible? t) row-h 0)
:line-height (str (dec row-h) "px")}}
(:name t)])]
;; horizontally-scrolling timeline
[:div.hscroll {:ref (fn [n] (reset! scroll-el n))
:on-wheel (fn [_] (reset! following? false))}
[:div.content {:ref (fn [n] (reset! content n))
:on-mouse-down (fn [e] (begin-scrub! @content zoom fps duration e))
:on-mouse-down (fn [e] (begin-scrub! @content zoom fps off end e))
:style {:width width}}
(let [x (* (secs playhead fps) zoom)]
(let [x (* (secs (- playhead off) fps) zoom)]
[:div.playhead {:style {:left x}}
[:div.playhead-handle]])
[annotation-lane fps zoom lane-anns active]
[annotation-lane fps zoom off lane-anns active]
(for [t vtracks]
^{:key (:id t)} [track-row fps zoom row-h (some? d) t])]]]))))
^{:key (:id t)} [track-row fps zoom off row-h (some? d) t])]]]))))
;; --- zoom + layout shell --------------------------------------------------
@ -382,12 +409,33 @@
(let [el @scroll-el
fps @(rf/subscribe [::subs/fps])
ph @(rf/subscribe [::subs/playhead])
off (or @(rf/subscribe [::subs/view-offset]) 0)
z0 @(rf/subscribe [::subs/zoom])]
(rf/dispatch [::events/set-zoom z1])
(when el
(let [target (max 0 (+ (.-scrollLeft el) (* (secs ph fps) (- z1 z0))))]
(let [target (max 0 (+ (.-scrollLeft el) (* (secs (- ph off) fps) (- z1 z0))))]
(r/after-render #(set! (.-scrollLeft el) target))))))
(defn breadcrumbs
"Trail of the expanded-timeline stack. Only shown when nested. The back button
pops one level; clicking a crumb jumps straight back to it. Either way the
main timeline is fully restored."
[]
(let [crumbs @(rf/subscribe [::subs/breadcrumbs])]
(when (> (count crumbs) 1)
[:div.crumbs
[:button.back-btn {:title "Back one level" :on-click #(rf/dispatch [::events/collapse])}
"← back"]
(doall
(for [[i {:keys [id name]}] (map-indexed vector crumbs)]
(let [last? (= i (dec (count crumbs)))]
^{:key id}
[:span.crumb-item
(when (pos? i) [:span.crumb-sep "›"])
[:button.crumb {:class (when last? "current") :disabled last?
:on-click #(rf/dispatch [::events/pop-to id])}
name]])))])))
(defn zoom-controls []
(let [zoom @(rf/subscribe [::subs/zoom])
row-h @(rf/subscribe [::subs/row-h])
@ -548,6 +596,7 @@
{:on-pointer-down #(begin-resize! :y ::events/set-top-h 12 88 %)}]
;; timeline region
[:div.timeline-pane
[breadcrumbs]
[zoom-controls]
[:div.timeline-scroll
(case status