diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index 38f661c..431a333 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -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; diff --git a/tl/src/tl/core.cljs b/tl/src/tl/core.cljs index a5d94ff..2576a09 100644 --- a/tl/src/tl/core.cljs +++ b/tl/src/tl/core.cljs @@ -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) diff --git a/tl/src/tl/db.cljs b/tl/src/tl/db.cljs index 8f12cf5..5b11ac9 100644 --- a/tl/src/tl/db.cljs +++ b/tl/src/tl/db.cljs @@ -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) diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index 6de46e6..cd83575 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -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')}))) diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index 218a723..4a8faa0 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -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 diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 5766373..fedc7ae 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -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 :setup :occ @@ -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}} - (for [c (:clips track)] - ^{:key (:id c)} [clip-block fps zoom row-h authoring? c])]) +(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 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