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 name="viewport" content="width=device-width,initial-scale=1">
<title>tl</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<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*."}}}
;; 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)
: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?]]
(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
::push-timeline
(fn [db [_ gid]]
(-> db
(update-in [:marks :timeline-stack] conj gid)
(assoc-in [:view :seg] 0))))
::set-zoom
(fn [db [_ px-per-sec]] (assoc-in db [:view :zoom] px-per-sec)))
(rf/reg-event-db
::pop-timeline
(fn [db _]
(-> db
(update-in [:marks :timeline-stack] #(if (> (count %) 1) (pop %) %))
(assoc-in [:view :seg] 0))))
::set-row-h
(fn [db [_ h]] (assoc-in db [:view :row-h] h)))
;; --- resizable layout ----------------------------------------------------
;; current segment index within the active program (set by the player as it
;; jumps from span to span)
(rf/reg-event-db
::set-seg
(fn [db [_ i]] (assoc-in db [:view :seg] i)))
::set-top-h
(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]
(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))
(defn render
"Markdown string -> a Hiccup [:div ...] tree."
"Markdown string -> a Hiccup [:div.md ...] tree (styled via the .md CSS rules)."
[md]
(loop [ls (str/split-lines (or md "")), out [], para []]
(if (empty? ls)
(into [:div] (flush-para out para))
(into [:div.md] (flush-para out para))
(let [t (str/trim (first ls))]
(cond
(str/blank? t)
@ -43,8 +43,7 @@
txt (str/replace t #"^#+\s*" "")
tag (keyword (str "h" (min n 6)))]
(recur (rest ls)
(conj (flush-para out para)
(into [tag {:style {:margin "0 0 6px" :lineHeight 1.25}}] (inline txt)))
(conj (flush-para out para) (into [tag] (inline txt)))
[]))
(re-find #"^[-*]\s" t)
@ -52,7 +51,7 @@
lis (for [it items]
(into [:li] (inline (str/replace (str/trim it) #"^[-*]\s*" ""))))]
(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

View file

@ -54,41 +54,10 @@
(:id (or (last (filter #(<= (:anchor %) ph) 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) ---
(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 ::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.events :as events]))
(def ^:const row-h 28)
;; 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.
;; Host-relative so it works over localhost and Tailscale alike.
@ -20,6 +19,14 @@
(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]
(when-let [el @scroll-el]
(let [px (* (secs playhead fps) zoom)
@ -31,219 +38,176 @@
(when-let [v @video-el]
(set! (.-currentTime v) (secs frames fps))))
(defn- jump! [fps frame]
(rf/dispatch [::events/set-playhead frame])
(seek-to-frame! fps frame))
(defn- toggle-play! []
(when-let [v @video-el]
(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]
;; Space toggles play. If the <video> itself is focused, let its native
;; control handle it so we don't double-toggle.
;; Space = play/pause globally. preventDefault stops it from scrolling the
;; 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))
(not= @video-el (.-activeElement js/document)))
(not (editable-target? (.-target e))))
(.preventDefault e)
(toggle-play!)))
(defonce ^:private _kbd
(.addEventListener js/document "keydown" on-keydown))
;; Re-bind on every (re)load so the live handler is the one attached —
;; 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
"Native-controls <video> that plays the active *program*. A program is an
ordered list of source spans; while playing, a rAF loop pushes currentTime
(media frames) into :playhead AND, when it reaches the end of the current
span, seeks the video to the next span's source frame — so a pushed
sub-timeline plays its clips back-to-back, jumping across the source media.
:root plays the whole media as one span (unchanged behaviour)."
"Native-controls <video> that acts as the playback clock: while it's playing,
a requestAnimationFrame loop pushes currentTime (as frames) into :playhead.
Also syncs on seek/pause so scrubbing (native controls or the timeline) moves
the playhead. Sized by CSS (.video-pane video) to fit its pane."
[fps]
(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]
(rf/dispatch [::events/set-playhead (* (.-currentTime v) fps)])))
tick (fn 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)))
tick (fn tick [] (push!) (reset! raf (js/requestAnimationFrame tick)))
stop (fn [] (when @raf (js/cancelAnimationFrame @raf) (reset! raf nil)))]
(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
:controls true
;; Keep playback inline on iOS instead of jumping to the
;; native fullscreen player.
;; Keep playback inline on iOS instead of jumping to fullscreen.
:plays-inline true
:webkit-playsinline "true"
:ref (fn [node] (reset! video-el node))
:on-play (fn [_] (reset! following? true)
(rf/dispatch [::events/set-playing true]) (stop) (tick))
:on-pause (fn [_] (rf/dispatch [::events/set-playing false]) (stop) (push!))
:on-seeked (fn [_] (push!))
:style {:width 480 :display :block :background "#000"}}]))))
:on-seeked (fn [_] (push!))}])))
;; --- 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
"One labeled row per annotation, drawn above the tracks. Bars are positioned
in display frames (same coordinate as clips), so they align with the timeline."
[fps zoom annotations active]
[:div {:style {:position :relative}}
(doall
(map-indexed
(fn [i {:keys [id name spans] :as _ann}]
(let [color (nth ann-colors (mod i (count ann-colors)))
on? (= id active)]
^{:key id}
[:div {:style {:position :relative :height 18}}
(for [[j s] (map-indexed vector spans)]
(let [left (* (secs (:start s) fps) zoom)
w (* (secs (- (:end s) (:start s)) fps) zoom)]
^{:key j}
[:div {:title name
: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- place-popover!
"Anchor a native popover under its trigger button, in viewport coords (so it
tracks the commentary's scroll). anchor() CSS misbehaves inside a scroll box."
[pop]
(when-let [btn (.querySelector js/document (str "[popovertarget='" (.-id pop) "']"))]
(let [r (.getBoundingClientRect btn)]
(set! (.. pop -style -top) (str (+ (.-bottom r) 4) "px"))
(set! (.. pop -style -right) (str (- (.-innerWidth js/window) (.-right r)) "px"))
(set! (.. pop -style -left) "auto"))))
(defn- position-popover-ref
"Ref for a .jump-pop: position it just before it opens (beforetoggle fires
pre-paint, so there's no flash from the default position)."
[node]
(when node
(.addEventListener node "beforetoggle"
(fn [e] (when (= "open" (.-newState e)) (place-popover! node))))))
(defn commentary
"Markdown commentary sidebar. Each annotation is a block anchored to its start
frame; the active one is highlighted and scrolled to ~1/3 from the top as the
video plays."
"Markdown commentary. Each annotation is a block anchored to its start frame;
the active one is highlighted and scrolled into view. A jump button seeks to
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 []
(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
(fn []
(when-let [c @el]
(when-let [node (and active (.querySelector c (str "#ann-" (name active))))]
(set! (.-scrollTop c) (max 0 (- (.-offsetTop node) (/ (.-clientHeight c) 3))))))))
[:div {: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}}
[:div.commentary {:ref (fn [n] (reset! el n))}
(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}
[:div {:id (str "ann-" (name id))
:style {:padding "8px 10px" :margin-bottom 8 :border-radius 4
:border (if (= id active) "1px solid #3a6ea5" "1px solid transparent")
:background (if (= id active) "#152333" "#161616")}}
[:div {:style {:display :flex :align-items :center :justify-content :space-between
:margin-bottom 4}}
[:div {:style {:font-size 10 :text-transform :uppercase :letter-spacing 1
:color "#7aa6d6"}} nm]
(when-not instant?
[:button {:on-click #(rf/dispatch [::events/push-timeline id])
:title "Play these shots back-to-back"
:style {:background "#1c2a38" :color "#cfe3f5"
:border "1px solid #3a6ea5" :border-radius 4
:padding "1px 7px" :cursor :pointer :font-size 10}}
"▶ zoom in"])]
[md/render content]])
[:div {:style {:color "#666" :font-size 12}} "No annotations."])]))))
[:div.ann {:id (str "ann-" (name id))
:class (when (= id active) "active")}
[:div.ann-head
[:div.ann-title nm]
(if one?
[:button.jump-btn
{:on-click #(jump! fps (:frame (first targets)))} "↪ jump"]
;; native popover: the button toggles it; the browser
;; handles light-dismiss + Escape + top-layer. Positioning is
;; the one bit of JS (anchor() misbehaves in a scroll box).
[:button.jump-btn {:popovertarget (str "jp-" (name id))}
(str "↪ jump (" (count targets) ")")])]
(when-not one?
[:div.jump-pop {:popover "auto" :id (str "jp-" (name id))
:ref position-popover-ref}
(for [[k t] (map-indexed vector targets)]
^{: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 []
(let [stack @(rf/subscribe [::subs/timeline-stack])
groups @(rf/subscribe [::subs/groups])]
[:div {:style {:display :flex :align-items :center :gap 8 :margin-bottom 8
:font-size 12 :min-height 24}}
(when (> (count stack) 1)
[:button {:on-click #(rf/dispatch [::events/pop-timeline])
:style {:background "#222" :color "#cfe3f5" :border "1px solid #3a6ea5"
:border-radius 4 :padding "2px 8px" :cursor :pointer :font-size 11}}
"← back"])
(into [:div {:style {:display :flex :gap 6 :align-items :center}}]
(def ^:private ann-colors ["#c2554e" "#4e8fc2" "#5ab07a" "#b08a3a" "#8a5ab0" "#3aa0a0"])
(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]
[:div
(doall
(map-indexed
(fn [i gid]
^{:key i}
[:span (when (pos? i) {})
(when (pos? i) [:span {:style {:color "#555" :margin-right 6}} "›"])
[:span {:style {:color (if (= gid (last stack)) "#cfe3f5" "#888")}}
(get-in groups [gid :name] (name gid))]])
stack))]))
(fn [i {:keys [id name spans]}]
(let [color (nth ann-colors (mod i (count ann-colors)))
on? (= id active)]
^{:key id}
[:div.ann-lane-row
(for [[j s] (map-indexed vector spans)]
(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
"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}]
(defn clip-block [fps zoom row-h {:keys [name media-in duration] :as _clip}]
(let [left (* (secs media-in fps) zoom)
width (max 1 (* (secs duration fps) zoom))]
[:div {:style {:position :absolute :left left :top 1
:width width :height (- row-h 2)
: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}}
[:div.clip {:style {:left left :width width :height (- row-h 2)
:line-height (str (- row-h 2) "px")}}
name]))
(defn track-row [fps zoom track]
[:div {:style {:position :relative :height row-h
:background (if (= :audio (:kind track)) "#161616" "#1c1c1c")
:border-bottom "1px solid #2a2a2a"}}
(defn track-row [fps zoom row-h track]
[:div.track-row {:style {:height row-h}}
(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
"Pointer clientX -> clamped timeline frame, relative to the content div
@ -270,13 +234,12 @@
(.addEventListener js/document "mousemove" move)
(.addEventListener js/document "mouseup" up)))
(def ^:private gutter-w 150)
(defn timeline []
(let [content (atom nil)]
(fn []
(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])
playhead @(rf/subscribe [::subs/playhead])
@ -288,48 +251,92 @@
width (* (secs duration fps) zoom)]
(when (and playing? @following?)
(r/after-render #(center-on-playhead! fps zoom playhead)))
[:div {:style {:display :flex :align-items :flex-start
:border "1px solid #333" :background "#111"}}
[:div.timeline
;; 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
(for [t vtracks]
^{:key (:id t)}
[:div {:style {:height row-h :box-sizing :border-box
: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}}
[:div.gutter-label {:style {:height row-h :line-height (str (dec row-h) "px")}}
(:name t)])]
;; horizontally-scrolling timeline
[:div {:ref (fn [n] (reset! scroll-el n))
:on-wheel (fn [_] (reset! following? false))
:style {:overflow-x :auto :flex 1}}
[:div {:ref (fn [n] (reset! content n))
[: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))
:style {:position :relative :width width :cursor "ew-resize"}}
;; playhead: line spanning all rows + a grab handle at the top
:style {:width width}}
(let [x (* (secs playhead fps) zoom)]
[:div {:style {:position :absolute :top 0 :bottom 0 :left x
:width 2 :background "#e33" :z-index 10
:pointer-events "none"}}
[:div {:style {:position :absolute :top 0 :left -5 :width 12 :height 10
:background "#e33" :border-radius 2}}]])
[:div.playhead {:style {:left x}}
[:div.playhead-handle]])
[annotation-lane fps zoom anns active]
(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 []
(let [status @(rf/subscribe [::subs/load-status])
fps (or @(rf/subscribe [::subs/fps]) (/ 24000 1001))]
[:div {:style {:font-family "sans-serif" :background "#0c0c0c"
:color "#ddd" :min-height "100vh" :padding 12}}
[breadcrumb]
[program-strip]
[:div {:style {:display :flex :gap 12 :align-items :flex-start :margin-bottom 12}}
[video-monitor fps]
[commentary]]
fps (or @(rf/subscribe [::subs/fps]) (/ 24000 1001))
top-h @(rf/subscribe [::subs/top-h])
video-w @(rf/subscribe [::subs/video-w])]
[:div.app
;; top region: video | annotation
[:div.top {:style {:height top-h}}
[:div.video-pane {:style {:width video-w}}
[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
:ready [timeline]
:error [:div {:style {:color "#e66"}} "Failed to load OTIO"]
[:div "Loading timeline…"])]))
:error [:div {:style {:color "#e66" :padding 12}} "Failed to load OTIO"]
[:div {:style {:padding 12}} "Loading timeline…"])]]]))