vaarious fixes
This commit is contained in:
parent
bdc9928db8
commit
38367cd052
7 changed files with 368 additions and 272 deletions
119
tl/resources/public/css/app.css
Normal file
119
tl/resources/public/css/app.css
Normal 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; }
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}})
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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…"])]]]))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue