vaarious fixes

This commit is contained in:
Your Name 2026-06-27 12:00:43 -04:00
parent bdc9928db8
commit 38367cd052
7 changed files with 368 additions and 272 deletions

View file

@ -0,0 +1,119 @@
/* tl — app styles. Dynamic geometry (positions/sizes derived from zoom/frames
or the resizable panes) stays inline in the views; everything static is here. */
html, body, #app { height: 100%; margin: 0; }
body { overflow: hidden; }
.app {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: sans-serif;
background: #0c0c0c;
color: #ddd;
}
/* --- top region: video | annotation ------------------------------------ */
.top { display: flex; min-height: 0; }
.video-pane {
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: #000; overflow: hidden; min-width: 0;
}
.video-pane video { max-width: 100%; max-height: 100%; display: block; background: #000; }
.annot-pane { flex: 1; min-width: 0; display: flex; }
/* --- draggable dividers ------------------------------------------------- */
.divider-v { flex-shrink: 0; width: 6px; cursor: col-resize; background: #222; }
.divider-h { flex-shrink: 0; height: 6px; cursor: row-resize; background: #222; }
.divider-v:hover, .divider-h:hover { background: #3a6ea5; }
/* --- commentary --------------------------------------------------------- */
.commentary {
position: relative; flex: 1; overflow-y: auto;
background: #111; padding: 8px 12px; box-sizing: border-box;
}
.ann {
position: relative;
padding: 8px 10px; margin-bottom: 8px; border-radius: 4px;
border: 1px solid transparent; background: #161616;
}
.ann.active { border-color: #3a6ea5; background: #152333; }
.ann-head { display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 4px; }
.ann-title { font-size: 10px; text-transform: uppercase; letter-spacing: 1px;
color: #7aa6d6; }
.ann-empty { color: #666; font-size: 12px; }
.jump-btn {
flex-shrink: 0; background: #1c2a38; color: #cfe3f5;
border: 1px solid #3a6ea5; border-radius: 4px;
padding: 1px 7px; cursor: pointer; font-size: 10px; white-space: nowrap;
}
/* native popover in the top layer; top/right set by JS (place-popover!) in
viewport coords so it tracks the commentary scroll. */
.jump-pop:popover-open {
position: fixed;
margin: 0; inset: auto;
min-width: 150px; max-height: 180px; overflow-y: auto;
background: #1b1b1b; border: 1px solid #3a6ea5; border-radius: 4px;
box-shadow: 0 4px 14px rgba(0,0,0,0.5); padding: 4px;
}
.jump-pop button {
display: block; width: 100%; text-align: left; background: none;
color: #cde; border: none; border-radius: 3px; padding: 4px 6px;
cursor: pointer; font-size: 11px; white-space: nowrap;
}
.jump-pop button:hover { background: #2a4a6a; }
/* --- markdown (tl.md output is wrapped in .md) -------------------------- */
.md h1 { font-size: 16px; margin: 0 0 6px; line-height: 1.25; }
.md h2 { font-size: 14px; margin: 0 0 6px; line-height: 1.25; }
.md h3 { font-size: 13px; margin: 0 0 6px; line-height: 1.25; }
.md h4, .md h5, .md h6 { font-size: 12px; margin: 0 0 6px; }
.md p { margin: 0 0 8px; }
.md ul { margin: 0 0 8px 18px; padding: 0; }
.md code { background: #222; padding: 0 3px; border-radius: 3px; font-size: 12px; }
/* --- timeline region ---------------------------------------------------- */
.timeline-pane { flex: 1; display: flex; flex-direction: column; min-height: 0; }
.toolbar { display: flex; gap: 20px; align-items: center; padding: 6px 8px;
background: #0c0c0c; border-bottom: 1px solid #222; flex-shrink: 0; }
.slider { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #9ab; }
.slider input { width: 150px; }
.timeline-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; }
.timeline { display: flex; align-items: flex-start; background: #111; }
.gutter { flex-shrink: 0; width: 150px; user-select: none; }
.gutter-label {
box-sizing: border-box;
border-bottom: 1px solid #2a2a2a; border-right: 1px solid #2a2a2a;
padding: 0 6px; font-size: 10px; color: #9ab; background: #141414;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.hscroll { overflow-x: auto; flex: 1; }
.content { position: relative; cursor: ew-resize; }
.track-row { position: relative; background: #1c1c1c; border-bottom: 1px solid #2a2a2a; }
.clip {
position: absolute; top: 1px; box-sizing: border-box; overflow: hidden;
background: #3a6ea5; border: 1px solid #16324f; border-radius: 2px;
font-size: 10px; color: #cfe3f5; white-space: nowrap; padding-left: 3px;
}
.playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #e33;
z-index: 10; pointer-events: none; }
.playhead-handle { position: absolute; top: 0; left: -5px; width: 12px; height: 10px;
background: #e33; border-radius: 2px; }
/* --- annotation lane ---------------------------------------------------- */
.ann-lane-row { position: relative; height: 18px; }
.ann-bar { position: absolute; top: 2px; height: 14px; border-radius: 2px; }
.ann-bar-label { position: absolute; top: 1px; font-size: 9px; line-height: 16px;
color: #eee; white-space: nowrap; pointer-events: none;
text-shadow: 0 1px 2px #000; }

View file

@ -4,6 +4,7 @@
<meta charset='utf-8'> <meta charset='utf-8'>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>tl</title> <title>tl</title>
<link rel="stylesheet" href="/css/app.css">
</head> </head>
<body> <body>
<noscript> <noscript>

View file

@ -49,8 +49,11 @@
:content "# Climax: the serve (slow-mo)\n\n3000+ frames on one motion. Time **dilates** at the decisive moment — the cut finally stops cutting and just *watches*."}}} :content "# Climax: the serve (slow-mo)\n\n3000+ frames on one motion. Time **dilates** at the decisive moment — the cut finally stops cutting and just *watches*."}}}
;; transient view state (mutates while scrubbing/zooming) ;; transient view state (mutates while scrubbing/zooming)
:view {:zoom 50 ; pixels per SECOND (px/frame = zoom/fps) :view {:zoom 50 ; X zoom: pixels per SECOND (px/frame = zoom/fps)
:row-h 28 ; Y zoom: track row height in px
:playhead 0.0 ; frames (media/display coordinate) :playhead 0.0 ; frames (media/display coordinate)
:playing? false :playing? false
:seg 0 ; current segment index within the active program :selected-clip nil
:selected-clip nil}}) ;; resizable layout (px): top region height, video pane width
:top-h 360
:video-w 560}})

View file

@ -50,24 +50,22 @@
(fn [db [_ playing?]] (fn [db [_ playing?]]
(assoc-in db [:view :playing?] playing?))) (assoc-in db [:view :playing?] playing?)))
;; --- timeline stack (zoom into / out of a mark-group) -------------------- ;; --- timeline zoom (x = px/second, y = track row height) ----------------
(rf/reg-event-db (rf/reg-event-db
::push-timeline ::set-zoom
(fn [db [_ gid]] (fn [db [_ px-per-sec]] (assoc-in db [:view :zoom] px-per-sec)))
(-> db
(update-in [:marks :timeline-stack] conj gid)
(assoc-in [:view :seg] 0))))
(rf/reg-event-db (rf/reg-event-db
::pop-timeline ::set-row-h
(fn [db _] (fn [db [_ h]] (assoc-in db [:view :row-h] h)))
(-> db
(update-in [:marks :timeline-stack] #(if (> (count %) 1) (pop %) %)) ;; --- resizable layout ----------------------------------------------------
(assoc-in [:view :seg] 0))))
;; current segment index within the active program (set by the player as it
;; jumps from span to span)
(rf/reg-event-db (rf/reg-event-db
::set-seg ::set-top-h
(fn [db [_ i]] (assoc-in db [:view :seg] i))) (fn [db [_ h]] (assoc-in db [:view :top-h] h)))
(rf/reg-event-db
::set-video-w
(fn [db [_ w]] (assoc-in db [:view :video-w] w)))

View file

@ -24,15 +24,15 @@
(defn- flush-para [out para] (defn- flush-para [out para]
(if (seq para) (if (seq para)
(conj out (into [:p {:style {:margin "0 0 8px"}}] (inline (str/join " " para)))) (conj out (into [:p] (inline (str/join " " para))))
out)) out))
(defn render (defn render
"Markdown string -> a Hiccup [:div ...] tree." "Markdown string -> a Hiccup [:div.md ...] tree (styled via the .md CSS rules)."
[md] [md]
(loop [ls (str/split-lines (or md "")), out [], para []] (loop [ls (str/split-lines (or md "")), out [], para []]
(if (empty? ls) (if (empty? ls)
(into [:div] (flush-para out para)) (into [:div.md] (flush-para out para))
(let [t (str/trim (first ls))] (let [t (str/trim (first ls))]
(cond (cond
(str/blank? t) (str/blank? t)
@ -43,8 +43,7 @@
txt (str/replace t #"^#+\s*" "") txt (str/replace t #"^#+\s*" "")
tag (keyword (str "h" (min n 6)))] tag (keyword (str "h" (min n 6)))]
(recur (rest ls) (recur (rest ls)
(conj (flush-para out para) (conj (flush-para out para) (into [tag] (inline txt)))
(into [tag {:style {:margin "0 0 6px" :lineHeight 1.25}}] (inline txt)))
[])) []))
(re-find #"^[-*]\s" t) (re-find #"^[-*]\s" t)
@ -52,7 +51,7 @@
lis (for [it items] lis (for [it items]
(into [:li] (inline (str/replace (str/trim it) #"^[-*]\s*" ""))))] (into [:li] (inline (str/replace (str/trim it) #"^[-*]\s*" ""))))]
(recur more (recur more
(conj (flush-para out para) (into [:ul {:style {:margin "0 0 8px 18px" :padding 0}}] lis)) (conj (flush-para out para) (into [:ul] lis))
[])) []))
:else :else

View file

@ -54,41 +54,10 @@
(:id (or (last (filter #(<= (:anchor %) ph) anns)) (:id (or (last (filter #(<= (:anchor %) ph) anns))
(first anns))))) (first anns)))))
;; --- playback program (the active timeline as an ordered EDL) ------------
(rf/reg-sub ::current-timeline-id :<- [::timeline-stack] (fn [st] (last st)))
(rf/reg-sub ::seg (fn [db] (get-in db [:view :seg])))
(defn- ->segments
"Lay spans end-to-end in virtual time, tagging each with its virtual offset."
[spans]
(loop [v 0, ss spans, out []]
(if-let [s (first ss)]
(let [len (max 1 (- (:end s) (:start s)))]
(recur (+ v len) (rest ss)
(conj out (assoc s :virt-start v :virt-len len :virt-end (+ v len)))))
out)))
;; A program = ordered segments {:start :end (media) :virt-start :virt-len :virt-end :track}.
;; :root (or any timeline without :marks) plays the whole media linearly as one
;; segment; a pushed mark-group plays its resolved spans, jumping between them.
(rf/reg-sub
::program
:<- [::current-timeline-id] :<- [::groups] :<- [::clip-index] :<- [::duration]
(fn [[gid groups idx dur] _]
(cond
(nil? dur)
{:root? true :id gid :segments [] :virt-len 0}
(or (= gid :root) (nil? (:marks (get groups gid))))
{:root? true :id gid :virt-len dur
:segments [{:start 0 :end dur :virt-start 0 :virt-len dur :virt-end dur :track "Sequence"}]}
:else
(let [segs (->segments (marks/resolve-group idx groups gid))]
{:root? false :id gid :segments segs
:virt-len (reduce + 0 (map :virt-len segs))}))))
;; --- view state (frames in; views multiply by zoom for pixels) --- ;; --- view state (frames in; views multiply by zoom for pixels) ---
(rf/reg-sub ::zoom (fn [db] (get-in db [:view :zoom]))) ; px per second (rf/reg-sub ::zoom (fn [db] (get-in db [:view :zoom]))) ; X: px per second
(rf/reg-sub ::row-h (fn [db] (get-in db [:view :row-h]))) ; Y: track row height px
(rf/reg-sub ::playhead (fn [db] (get-in db [:view :playhead]))) ; frames (rf/reg-sub ::playhead (fn [db] (get-in db [:view :playhead]))) ; frames
(rf/reg-sub ::playing? (fn [db] (get-in db [:view :playing?]))) (rf/reg-sub ::playing? (fn [db] (get-in db [:view :playing?])))
(rf/reg-sub ::top-h (fn [db] (get-in db [:view :top-h]))) ; layout: top region height
(rf/reg-sub ::video-w (fn [db] (get-in db [:view :video-w]))) ; layout: video pane width

View file

@ -6,7 +6,6 @@
[tl.md :as md] [tl.md :as md]
[tl.events :as events])) [tl.events :as events]))
(def ^:const row-h 28)
;; Served by dev/media_server.py (range-capable) rather than shadow's :dev-http, ;; Served by dev/media_server.py (range-capable) rather than shadow's :dev-http,
;; which doesn't do byte-range serving — Safari/iOS won't play <video> without it. ;; which doesn't do byte-range serving — Safari/iOS won't play <video> without it.
;; Host-relative so it works over localhost and Tailscale alike. ;; Host-relative so it works over localhost and Tailscale alike.
@ -20,6 +19,14 @@
(defn- secs [frames fps] (/ frames fps)) (defn- secs [frames fps] (/ frames fps))
(defn- tc
"frames -> m:ss timecode string."
[frames fps]
(let [s (/ frames fps)
m (js/Math.floor (/ s 60))
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! [fps zoom playhead]
(when-let [el @scroll-el] (when-let [el @scroll-el]
(let [px (* (secs playhead fps) zoom) (let [px (* (secs playhead fps) zoom)
@ -31,219 +38,176 @@
(when-let [v @video-el] (when-let [v @video-el]
(set! (.-currentTime v) (secs frames fps)))) (set! (.-currentTime v) (secs frames fps))))
(defn- jump! [fps frame]
(rf/dispatch [::events/set-playhead frame])
(seek-to-frame! fps frame))
(defn- toggle-play! [] (defn- toggle-play! []
(when-let [v @video-el] (when-let [v @video-el]
(if (.-paused v) (.play v) (.pause v)))) ; play/pause events drive :playing? + rAF (if (.-paused v) (.play v) (.pause v)))) ; play/pause events drive :playing? + rAF
(defn- editable-target?
"True for fields where Space should type a space, not toggle play."
[el]
(let [tag (some-> el .-tagName)]
(or (= "TEXTAREA" tag)
(and (= "INPUT" tag)
(not (#{"range" "checkbox" "radio" "button"} (.-type el))))
(boolean (and el (.-isContentEditable el))))))
(defn- on-keydown [e] (defn- on-keydown [e]
;; Space toggles play. If the <video> itself is focused, let its native ;; Space = play/pause globally. preventDefault stops it from scrolling the
;; control handle it so we don't double-toggle. ;; page or activating a focused button (and from the <video>'s own native
;; toggle, so we never double-fire). (Jump popovers are native — they
;; light-dismiss and Escape-close themselves.)
(when (and (= "Space" (.-code e)) (when (and (= "Space" (.-code e))
(not= @video-el (.-activeElement js/document))) (not (editable-target? (.-target e))))
(.preventDefault e) (.preventDefault e)
(toggle-play!))) (toggle-play!)))
(defonce ^:private _kbd ;; Re-bind on every (re)load so the live handler is the one attached —
(.addEventListener js/document "keydown" on-keydown)) ;; defonce alone would keep a stale listener across hot reloads.
(defonce ^:private kbd (atom nil))
(when-let [h @kbd] (.removeEventListener js/document "keydown" h))
(reset! kbd on-keydown)
(.addEventListener js/document "keydown" on-keydown)
(defn video-monitor (defn video-monitor
"Native-controls <video> that plays the active *program*. A program is an "Native-controls <video> that acts as the playback clock: while it's playing,
ordered list of source spans; while playing, a rAF loop pushes currentTime a requestAnimationFrame loop pushes currentTime (as frames) into :playhead.
(media frames) into :playhead AND, when it reaches the end of the current Also syncs on seek/pause so scrubbing (native controls or the timeline) moves
span, seeks the video to the next span's source frame — so a pushed the playhead. Sized by CSS (.video-pane video) to fit its pane."
sub-timeline plays its clips back-to-back, jumping across the source media.
:root plays the whole media as one span (unchanged behaviour)."
[fps] [fps]
(let [raf (atom nil) (let [raf (atom nil)
prog (atom nil) ; latest program (mirrored from the sub each render)
seg (atom 0) ; current segment index (hot path)
prev (atom :init) ; previous program id, to detect push/pop
seek! (fn [frame] (when-let [v @video-el]
(set! (.-currentTime v) (secs frame fps))))
push! (fn [] (when-let [v @video-el] push! (fn [] (when-let [v @video-el]
(rf/dispatch [::events/set-playhead (* (.-currentTime v) fps)]))) (rf/dispatch [::events/set-playhead (* (.-currentTime v) fps)])))
tick (fn tick [] tick (fn tick [] (push!) (reset! raf (js/requestAnimationFrame tick)))
(when-let [v @video-el]
(let [segs (:segments @prog)
i @seg
cur (get segs i)
mf (* (.-currentTime v) fps)]
(when cur
(when (>= mf (- (:end cur) 0.5)) ; reached end of this span
(if (< (inc i) (count segs))
(do (reset! seg (inc i))
(rf/dispatch [::events/set-seg (inc i)])
(seek! (:start (get segs (inc i)))))
(when-not (:root? @prog) (.pause v)))) ; end of program
(rf/dispatch [::events/set-playhead mf]))))
(reset! raf (js/requestAnimationFrame tick)))
stop (fn [] (when @raf (js/cancelAnimationFrame @raf) (reset! raf nil)))] stop (fn [] (when @raf (js/cancelAnimationFrame @raf) (reset! raf nil)))]
(fn [_fps] (fn [_fps]
(let [program @(rf/subscribe [::subs/program])]
(reset! prog program)
;; entered a different timeline: reset to its first span (and try to play)
(when (not= (:id program) @prev)
(reset! prev (:id program))
(reset! seg 0)
(when-not (:root? program)
(r/after-render
(fn []
(seek! (:start (first (:segments program))))
(when-let [v @video-el] (-> (.play v) (.catch (fn [_]))))))))
[:video {:src media-src [:video {:src media-src
:controls true :controls true
;; Keep playback inline on iOS instead of jumping to the ;; Keep playback inline on iOS instead of jumping to fullscreen.
;; native fullscreen player.
:plays-inline true :plays-inline true
:webkit-playsinline "true" :webkit-playsinline "true"
:ref (fn [node] (reset! video-el node)) :ref (fn [node] (reset! video-el node))
:on-play (fn [_] (reset! following? true) :on-play (fn [_] (reset! following? true)
(rf/dispatch [::events/set-playing true]) (stop) (tick)) (rf/dispatch [::events/set-playing true]) (stop) (tick))
:on-pause (fn [_] (rf/dispatch [::events/set-playing false]) (stop) (push!)) :on-pause (fn [_] (rf/dispatch [::events/set-playing false]) (stop) (push!))
:on-seeked (fn [_] (push!)) :on-seeked (fn [_] (push!))}])))
:style {:width 480 :display :block :background "#000"}}]))))
;; --- annotation layer ----------------------------------------------------- ;; --- commentary sidebar ---------------------------------------------------
(def ^:private ann-colors ["#c2554e" "#4e8fc2" "#5ab07a" "#b08a3a" "#8a5ab0" "#3aa0a0"]) (defn- jump-targets
"One jump option per span: {:frame :label}, label = setup name · timecode."
[fps nm spans]
(for [s spans]
{:frame (:start s)
:label (str (or (:track (:clip s)) nm) " · " (tc (:start s) fps))}))
(defn annotation-lane (defn- place-popover!
"One labeled row per annotation, drawn above the tracks. Bars are positioned "Anchor a native popover under its trigger button, in viewport coords (so it
in display frames (same coordinate as clips), so they align with the timeline." tracks the commentary's scroll). anchor() CSS misbehaves inside a scroll box."
[fps zoom annotations active] [pop]
[:div {:style {:position :relative}} (when-let [btn (.querySelector js/document (str "[popovertarget='" (.-id pop) "']"))]
(doall (let [r (.getBoundingClientRect btn)]
(map-indexed (set! (.. pop -style -top) (str (+ (.-bottom r) 4) "px"))
(fn [i {:keys [id name spans] :as _ann}] (set! (.. pop -style -right) (str (- (.-innerWidth js/window) (.-right r)) "px"))
(let [color (nth ann-colors (mod i (count ann-colors))) (set! (.. pop -style -left) "auto"))))
on? (= id active)]
^{:key id} (defn- position-popover-ref
[:div {:style {:position :relative :height 18}} "Ref for a .jump-pop: position it just before it opens (beforetoggle fires
(for [[j s] (map-indexed vector spans)] pre-paint, so there's no flash from the default position)."
(let [left (* (secs (:start s) fps) zoom) [node]
w (* (secs (- (:end s) (:start s)) fps) zoom)] (when node
^{:key j} (.addEventListener node "beforetoggle"
[:div {:title name (fn [e] (when (= "open" (.-newState e)) (place-popover! node))))))
:style {:position :absolute :left left :top 2 :height 14
:width (max 3 w)
:background (if on? color (str color "80"))
:border (str "1px solid " color) :border-radius 2}}]))
(when-let [a (some-> spans first :start)]
[:div {:style {:position :absolute :left (+ 4 (* (secs a fps) zoom)) :top 1
:font-size 9 :line-height "16px" :color "#eee"
:white-space :nowrap :pointer-events :none
:text-shadow "0 1px 2px #000"}}
name])]))
annotations))])
(defn commentary (defn commentary
"Markdown commentary sidebar. Each annotation is a block anchored to its start "Markdown commentary. Each annotation is a block anchored to its start frame;
frame; the active one is highlighted and scrolled to ~1/3 from the top as the the active one is highlighted and scrolled into view. A jump button seeks to
video plays." the annotation's start — or opens a popover of starts if it has several."
[] []
(let [el (atom nil)] (let [el (atom nil)] ; scroll container (no reactivity needed)
(fn [] (fn []
(let [anns @(rf/subscribe [::subs/annotations]) (let [anns @(rf/subscribe [::subs/annotations])
active @(rf/subscribe [::subs/active-annotation])] active @(rf/subscribe [::subs/active-annotation])
fps @(rf/subscribe [::subs/fps])]
(r/after-render (r/after-render
(fn [] (fn []
(when-let [c @el] (when-let [c @el]
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))] (when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
(set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3)))))))) (set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3))))))))
[:div {:ref (fn [n] (reset! el n)) [:div.commentary {:ref (fn [n] (reset! el n))}
:style {:position :relative :width 360 :height 270 :overflow-y :auto
:background "#111" :border "1px solid #333" :border-radius 4
:padding "8px 12px" :box-sizing :border-box}}
(if (seq anns) (if (seq anns)
(for [{:keys [id content instant?] nm :name} anns] (doall
(for [{:keys [id content spans] nm :name} anns]
(let [targets (vec (jump-targets fps nm spans))
one? (= 1 (count targets))]
^{:key id} ^{:key id}
[:div {:id (str "ann-" (name id)) [:div.ann {:id (str "ann-" (name id))
:style {:padding "8px 10px" :margin-bottom 8 :border-radius 4 :class (when (= id active) "active")}
:border (if (= id active) "1px solid #3a6ea5" "1px solid transparent") [:div.ann-head
:background (if (= id active) "#152333" "#161616")}} [:div.ann-title nm]
[:div {:style {:display :flex :align-items :center :justify-content :space-between (if one?
:margin-bottom 4}} [:button.jump-btn
[:div {:style {:font-size 10 :text-transform :uppercase :letter-spacing 1 {:on-click #(jump! fps (:frame (first targets)))} "↪ jump"]
:color "#7aa6d6"}} nm] ;; native popover: the button toggles it; the browser
(when-not instant? ;; handles light-dismiss + Escape + top-layer. Positioning is
[:button {:on-click #(rf/dispatch [::events/push-timeline id]) ;; the one bit of JS (anchor() misbehaves in a scroll box).
:title "Play these shots back-to-back" [:button.jump-btn {:popovertarget (str "jp-" (name id))}
:style {:background "#1c2a38" :color "#cfe3f5" (str "↪ jump (" (count targets) ")")])]
:border "1px solid #3a6ea5" :border-radius 4 (when-not one?
:padding "1px 7px" :cursor :pointer :font-size 10}} [:div.jump-pop {:popover "auto" :id (str "jp-" (name id))
"▶ zoom in"])] :ref position-popover-ref}
[md/render content]]) (for [[k t] (map-indexed vector targets)]
[:div {:style {:color "#666" :font-size 12}} "No annotations."])])))) ^{:key k}
[:button {:popovertarget (str "jp-" (name id))
:popovertargetaction "hide"
:on-click #(jump! fps (:frame t))}
(:label t)])])
[md/render content]])))
[:div.ann-empty "No annotations."])]))))
;; --- timeline stack: zoom into a mark-group + program strip --------------- ;; --- timeline -------------------------------------------------------------
(defn breadcrumb [] (def ^:private ann-colors ["#c2554e" "#4e8fc2" "#5ab07a" "#b08a3a" "#8a5ab0" "#3aa0a0"])
(let [stack @(rf/subscribe [::subs/timeline-stack])
groups @(rf/subscribe [::subs/groups])] (defn annotation-lane
[:div {:style {:display :flex :align-items :center :gap 8 :margin-bottom 8 "One labeled row per annotation, above the tracks. Bars are positioned in
:font-size 12 :min-height 24}} display frames (same coordinate as clips), so they align with the timeline."
(when (> (count stack) 1) [fps zoom annotations active]
[:button {:on-click #(rf/dispatch [::events/pop-timeline]) [:div
:style {:background "#222" :color "#cfe3f5" :border "1px solid #3a6ea5" (doall
:border-radius 4 :padding "2px 8px" :cursor :pointer :font-size 11}}
"← back"])
(into [:div {:style {:display :flex :gap 6 :align-items :center}}]
(map-indexed (map-indexed
(fn [i gid] (fn [i {:keys [id name spans]}]
^{:key i} (let [color (nth ann-colors (mod i (count ann-colors)))
[:span (when (pos? i) {}) on? (= id active)]
(when (pos? i) [:span {:style {:color "#555" :margin-right 6}} "›"]) ^{:key id}
[:span {:style {:color (if (= gid (last stack)) "#cfe3f5" "#888")}} [:div.ann-lane-row
(get-in groups [gid :name] (name gid))]]) (for [[j s] (map-indexed vector spans)]
stack))])) (let [left (* (secs (:start s) fps) zoom)
w (* (secs (- (:end s) (:start s)) fps) zoom)]
^{:key j}
[:div.ann-bar
{:title name
:style {:left left :width (max 3 w)
:background (if on? color (str color "80"))
:border (str "1px solid " color)}}]))
(when-let [a (some-> spans first :start)]
[:div.ann-bar-label {:style {:left (+ 4 (* (secs a fps) zoom))}} name])]))
annotations))])
(defn program-strip (defn clip-block [fps zoom row-h {:keys [name media-in duration] :as _clip}]
"Contiguous view of the active sub-timeline: each source span laid end-to-end
in virtual time, current span highlighted, smooth virtual playhead."
[]
(let [program @(rf/subscribe [::subs/program])
seg @(rf/subscribe [::subs/seg])
playhead @(rf/subscribe [::subs/playhead])]
(when-not (:root? program)
(let [segs (:segments program)
vlen (max 1 (:virt-len program))
w 852
sc (/ w vlen)
cur (get segs seg)
vp (when cur (+ (:virt-start cur) (- playhead (:start cur))))]
[:div {:style {:position :relative :width w :height 34 :margin-bottom 12
:background "#111" :border "1px solid #333" :border-radius 4
:overflow :hidden}}
(for [[i s] (map-indexed vector segs)]
^{:key i}
[:div {:style {:position :absolute :top 0 :bottom 0
:left (* (:virt-start s) sc)
:width (max 1 (- (* (:virt-len s) sc) 1))
:background (if (= i seg) "#2a4a6a" "#1c2a38")
:box-sizing :border-box :overflow :hidden
:font-size 9 :color "#bcd" :padding "3px 4px" :white-space :nowrap}}
(:track s)])
(when vp
[:div {:style {:position :absolute :top 0 :bottom 0 :left (* vp sc)
:width 2 :background "#e33" :pointer-events :none}}])]))))
(defn clip-block [fps zoom {:keys [name media-in duration] :as _clip}]
(let [left (* (secs media-in fps) zoom) (let [left (* (secs media-in fps) zoom)
width (max 1 (* (secs duration fps) zoom))] width (max 1 (* (secs duration fps) zoom))]
[:div {:style {:position :absolute :left left :top 1 [:div.clip {:style {:left left :width width :height (- row-h 2)
:width width :height (- row-h 2) :line-height (str (- row-h 2) "px")}}
:box-sizing :border-box :overflow :hidden
:background "#3a6ea5" :border "1px solid #16324f"
:border-radius 2 :font-size 10 :line-height "26px"
:color "#cfe3f5" :white-space :nowrap :padding-left 3}}
name])) name]))
(defn track-row [fps zoom track] (defn track-row [fps zoom row-h track]
[:div {:style {:position :relative :height row-h [:div.track-row {:style {:height row-h}}
:background (if (= :audio (:kind track)) "#161616" "#1c1c1c")
:border-bottom "1px solid #2a2a2a"}}
(for [c (:clips track)] (for [c (:clips track)]
^{:key (:id c)} [clip-block fps zoom c])]) ^{:key (:id c)} [clip-block fps zoom row-h c])])
(defn- ev->frames (defn- ev->frames
"Pointer clientX -> clamped timeline frame, relative to the content div "Pointer clientX -> clamped timeline frame, relative to the content div
@ -270,13 +234,12 @@
(.addEventListener js/document "mousemove" move) (.addEventListener js/document "mousemove" move)
(.addEventListener js/document "mouseup" up))) (.addEventListener js/document "mouseup" up)))
(def ^:private gutter-w 150)
(defn timeline [] (defn timeline []
(let [content (atom nil)] (let [content (atom nil)]
(fn [] (fn []
(let [fps @(rf/subscribe [::subs/fps]) (let [fps @(rf/subscribe [::subs/fps])
zoom @(rf/subscribe [::subs/zoom]) zoom @(rf/subscribe [::subs/zoom])
row-h @(rf/subscribe [::subs/row-h])
duration @(rf/subscribe [::subs/duration]) duration @(rf/subscribe [::subs/duration])
tracks @(rf/subscribe [::subs/tracks]) tracks @(rf/subscribe [::subs/tracks])
playhead @(rf/subscribe [::subs/playhead]) playhead @(rf/subscribe [::subs/playhead])
@ -288,48 +251,92 @@
width (* (secs duration fps) zoom)] width (* (secs duration fps) zoom)]
(when (and playing? @following?) (when (and playing? @following?)
(r/after-render #(center-on-playhead! fps zoom playhead))) (r/after-render #(center-on-playhead! fps zoom playhead)))
[:div {:style {:display :flex :align-items :flex-start [:div.timeline
:border "1px solid #333" :background "#111"}}
;; fixed left gutter: setup (track) names, always visible ;; fixed left gutter: setup (track) names, always visible
[:div {:style {:flex-shrink 0 :width gutter-w :user-select :none}} [:div.gutter
[:div {:style {:height lane-h}}] ; spacer aligning labels with rows [:div {:style {:height lane-h}}] ; spacer aligning labels with rows
(for [t vtracks] (for [t vtracks]
^{:key (:id t)} ^{:key (:id t)}
[:div {:style {:height row-h :box-sizing :border-box [:div.gutter-label {:style {:height row-h :line-height (str (dec row-h) "px")}}
:border-bottom "1px solid #2a2a2a" :border-right "1px solid #2a2a2a"
:padding "0 6px" :font-size 10 :line-height (str (dec row-h) "px")
:color "#9ab" :background "#141414"
:white-space :nowrap :overflow :hidden :text-overflow :ellipsis}}
(:name t)])] (:name t)])]
;; horizontally-scrolling timeline ;; horizontally-scrolling timeline
[:div {:ref (fn [n] (reset! scroll-el n)) [:div.hscroll {:ref (fn [n] (reset! scroll-el n))
:on-wheel (fn [_] (reset! following? false)) :on-wheel (fn [_] (reset! following? false))}
:style {:overflow-x :auto :flex 1}} [:div.content {:ref (fn [n] (reset! content n))
[:div {: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 duration e))
:style {:position :relative :width width :cursor "ew-resize"}} :style {:width width}}
;; playhead: line spanning all rows + a grab handle at the top
(let [x (* (secs playhead fps) zoom)] (let [x (* (secs playhead fps) zoom)]
[:div {:style {:position :absolute :top 0 :bottom 0 :left x [:div.playhead {:style {:left x}}
:width 2 :background "#e33" :z-index 10 [:div.playhead-handle]])
:pointer-events "none"}}
[:div {:style {:position :absolute :top 0 :left -5 :width 12 :height 10
:background "#e33" :border-radius 2}}]])
[annotation-lane fps zoom anns active] [annotation-lane fps zoom anns active]
(for [t vtracks] (for [t vtracks]
^{:key (:id t)} [track-row fps zoom t])]]])))) ^{:key (:id t)} [track-row fps zoom row-h t])]]]))))
;; --- zoom + layout shell --------------------------------------------------
(defn- slider [label v lo hi on]
[:label.slider label
[:input {:type "range" :min lo :max hi :value v
:on-change #(on (js/parseFloat (.. % -target -value)))}]])
(defn- zoom-x!
"Set X zoom while keeping the playhead pinned to its on-screen position
(Resolve-style: zoom around the playhead, not the left edge)."
[z1]
(let [el @scroll-el
fps @(rf/subscribe [::subs/fps])
ph @(rf/subscribe [::subs/playhead])
z0 @(rf/subscribe [::subs/zoom])]
(rf/dispatch [::events/set-zoom z1])
(when el
(let [target (max 0 (+ (.-scrollLeft el) (* (secs ph fps) (- z1 z0))))]
(r/after-render #(set! (.-scrollLeft el) target))))))
(defn zoom-controls []
(let [zoom @(rf/subscribe [::subs/zoom])
row-h @(rf/subscribe [::subs/row-h])]
[:div.toolbar
[slider "Zoom X" zoom 2 400 zoom-x!]
[slider "Zoom Y" row-h 8 120 #(rf/dispatch [::events/set-row-h %])]]))
(defn- begin-resize!
"Drag a divider: dispatches `evt` with the new size, clamped to [lo hi]."
[axis evt cur lo hi ev]
(.preventDefault ev)
(let [p0 (if (= axis :x) (.-clientX ev) (.-clientY ev))
move (fn [e]
(let [p (if (= axis :x) (.-clientX e) (.-clientY e))
n (-> (+ cur (- p p0)) (max lo) (min hi))]
(rf/dispatch-sync [evt n])))
up (fn up [_]
(.removeEventListener js/document "mousemove" move)
(.removeEventListener js/document "mouseup" up))]
(.addEventListener js/document "mousemove" move)
(.addEventListener js/document "mouseup" up)))
(defn main-panel [] (defn main-panel []
(let [status @(rf/subscribe [::subs/load-status]) (let [status @(rf/subscribe [::subs/load-status])
fps (or @(rf/subscribe [::subs/fps]) (/ 24000 1001))] fps (or @(rf/subscribe [::subs/fps]) (/ 24000 1001))
[:div {:style {:font-family "sans-serif" :background "#0c0c0c" top-h @(rf/subscribe [::subs/top-h])
:color "#ddd" :min-height "100vh" :padding 12}} video-w @(rf/subscribe [::subs/video-w])]
[breadcrumb] [:div.app
[program-strip] ;; top region: video | annotation
[:div {:style {:display :flex :gap 12 :align-items :flex-start :margin-bottom 12}} [:div.top {:style {:height top-h}}
[video-monitor fps] [:div.video-pane {:style {:width video-w}}
[commentary]] [video-monitor fps]]
[:div.divider-v
{:on-mouse-down #(begin-resize! :x ::events/set-video-w video-w
220 (- (.-innerWidth js/window) 280) %)}]
[:div.annot-pane [commentary]]]
;; horizontal divider
[:div.divider-h
{:on-mouse-down #(begin-resize! :y ::events/set-top-h top-h
140 (- (.-innerHeight js/window) 160) %)}]
;; timeline region
[:div.timeline-pane
[zoom-controls]
[:div.timeline-scroll
(case status (case status
:ready [timeline] :ready [timeline]
:error [:div {:style {:color "#e66"}} "Failed to load OTIO"] :error [:div {:style {:color "#e66" :padding 12}} "Failed to load OTIO"]
[:div "Loading timeline…"])])) [:div {:style {:padding 12}} "Loading timeline…"])]]]))