local storage, mobile improvements, re-frame refactor, etc

This commit is contained in:
Your Name 2026-06-27 12:56:05 -04:00
parent 38367cd052
commit 6ed11fbca5
5 changed files with 351 additions and 87 deletions

View file

@ -15,38 +15,7 @@
;; decides what's currently visible. For now everything is hardcoded. ;; decides what's currently visible. For now everything is hardcoded.
:marks :marks
{:timeline-stack [:root] {:timeline-stack [:root]
:groups :groups {:root {:type :timeline :name "Sequence" :mark-groups "*"}}}
{:root {:type :timeline :name "Sequence" :mark-groups "*"}
:opening-wides
{:type :annotation :name "The opening wides"
:marks [:t0-c0 :t1-c0]
:content "# The opening wides\n\nWe **establish the geography** before a single face. Two wides, back to back — court, players, the *distance* between them. Everything tightens from here.\n\n- full court\n- baseline"}
:tashis-face
{:type :annotation :name "Tashi's gravity"
:marks [:t2-c0 :t2-c1 :t2-c2 :t2-c3 :t2-c4]
:content "## Tashi's gravity\n\nThe cut keeps *returning* to her. Five times in the first minute we come back to this **CU** — the edit can't leave her alone, and neither can they."}
:the-volley
{:type :annotation :name "Beat: the volley"
:marks [[1831 2660]]
:content "## Beat: the volley\n\nTashi → Art → Patrick, **MS**, accelerating. The rhythm of the cutting *is* the rally — shot lengths shrink as the point builds."}
:the-eyes
{:type :annotation :name "Eyes"
:marks [:t19-c0 :t20-c0 :t21-c0 :t19-c1 :t20-c1]
:content "## Eyes\n\nThree pairs of eyes, intercut. Hers searching, his certain, his afraid. The film **rhymes** them and lets you do the math."}
:a-spectator
{:type :annotation :name "A spectator"
:marks [[:at :t11-c0 0]]
:content "A spectator, caught for a single frame. *Remember this face.*"}
:slow-mo
{:type :annotation :name "Climax: the serve"
:marks [:t30-c0]
: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 ; X zoom: pixels per SECOND (px/frame = zoom/fps) :view {:zoom 50 ; X zoom: pixels per SECOND (px/frame = zoom/fps)
@ -54,6 +23,7 @@
:playhead 0.0 ; frames (media/display coordinate) :playhead 0.0 ; frames (media/display coordinate)
:playing? false :playing? false
:selected-clip nil :selected-clip nil
;; resizable layout (px): top region height, video pane width ;; resizable layout (relative): top region height as vh, video pane
:top-h 360 ;; width as % of the top row. Pixel minimums enforced in CSS.
:video-w 560}}) :top-h 46
:video-w 50}})

View file

@ -3,12 +3,19 @@
[re-frame.core :as rf] [re-frame.core :as rf]
[tl.db :as db] [tl.db :as db]
[tl.otio :as otio] [tl.otio :as otio]
[tl.storage :as storage]
[ajax.core :as ajax])) [ajax.core :as ajax]))
;; Persist the :marks map to localStorage as a side effect.
(rf/reg-fx :tl/save (fn [marks] (storage/save-marks! marks)))
(rf/reg-event-db (rf/reg-event-db
::initialize-db ::initialize-db
(fn [_ _] (fn [_ _]
db/default-db)) ;; hydrate the annotation layer from localStorage if present
(if-let [saved (storage/load-marks)]
(assoc db/default-db :marks saved)
db/default-db)))
;; --- load + parse --------------------------------------------------------- ;; --- load + parse ---------------------------------------------------------
@ -69,3 +76,38 @@
(rf/reg-event-db (rf/reg-event-db
::set-video-w ::set-video-w
(fn [db [_ w]] (assoc-in db [:view :video-w] w))) (fn [db [_ w]] (assoc-in db [:view :video-w] w)))
;; --- authoring draft (lives in app-db: reactive fields + live preview) ---
(rf/reg-event-db ::open-draft (fn [db [_ d]] (assoc db :draft d)))
(rf/reg-event-db ::close-draft (fn [db _] (dissoc db :draft)))
(rf/reg-event-db ::draft-set (fn [db [_ path v]] (assoc-in db (into [:draft] path) v)))
(rf/reg-event-db ::draft-conj-row (fn [db [_ row]] (update-in db [:draft :rows] (fnil conj []) row)))
(rf/reg-event-db ::draft-remove-row (fn [db [_ i]]
(update-in db [:draft :rows]
#(into (subvec % 0 i) (subvec % (inc i))))))
;; --- authoring: create / delete annotations (persisted) -----------------
(rf/reg-event-fx
::add-annotation
(fn [{:keys [db]} [_ {:keys [name content color marks]}]]
(let [id (keyword (str "ann-" (random-uuid)))
group (cond-> {:type :annotation :name name :color color :marks marks}
(seq content) (assoc :content content))
db' (assoc-in db [:marks :groups id] group)]
{:db db' :tl/save (:marks db')})))
(rf/reg-event-fx
::update-annotation
(fn [{:keys [db]} [_ id {:keys [name content color marks]}]]
(let [group (cond-> {:type :annotation :name name :color color :marks marks}
(seq content) (assoc :content content))
db' (assoc-in db [:marks :groups id] group)]
{:db db' :tl/save (:marks db')})))
(rf/reg-event-fx
::delete-annotation
(fn [{:keys [db]} [_ id]]
(let [db' (update-in db [:marks :groups] dissoc id)]
{:db db' :tl/save (:marks db')})))

16
tl/src/tl/storage.cljs Normal file
View file

@ -0,0 +1,16 @@
(ns tl.storage
"Persist the user's mark-groups to localStorage as EDN. The OTIO-derived clips
are NOT stored — only the authored annotation layer (:marks)."
(:require [cljs.reader :as reader]))
(def ^:private k "tl/marks")
(defn load-marks
"Read the saved :marks map, or nil if absent/corrupt."
[]
(when-let [s (.getItem js/localStorage k)]
(try (reader/read-string s)
(catch :default _ nil))))
(defn save-marks! [marks]
(.setItem js/localStorage k (pr-str marks)))

View file

@ -10,11 +10,32 @@
(rf/reg-sub ::duration :<- [::timeline] (fn [t] (:duration t))) (rf/reg-sub ::duration :<- [::timeline] (fn [t] (:duration t)))
(rf/reg-sub ::tracks :<- [::timeline] (fn [t] (:tracks t))) (rf/reg-sub ::tracks :<- [::timeline] (fn [t] (:tracks t)))
;; --- authoring draft -----------------------------------------------------
(rf/reg-sub ::draft (fn [db] (:draft db)))
(rf/reg-sub ::draft-at (fn [db [_ path]] (get-in db (into [:draft] path))))
;; --- mark-groups / annotations ------------------------------------------ ;; --- mark-groups / annotations ------------------------------------------
(rf/reg-sub ::groups (fn [db] (get-in db [:marks :groups]))) (rf/reg-sub ::groups (fn [db] (get-in db [:marks :groups])))
(rf/reg-sub ::timeline-stack (fn [db] (get-in db [:marks :timeline-stack]))) (rf/reg-sub ::timeline-stack (fn [db] (get-in db [:marks :timeline-stack])))
(rf/reg-sub ::clip-index :<- [::timeline] (fn [t] (when t (marks/clip-index t)))) (rf/reg-sub ::clip-index :<- [::timeline] (fn [t] (when t (marks/clip-index t))))
;; Flat catalog of clips for the authoring autocomplete: one entry per clip,
;; with its setup name + 0-based occurrence within that setup, ordered by time.
(rf/reg-sub
::clip-catalog
:<- [::clip-index]
(fn [idx _]
(when idx
(->> (vals idx)
(group-by :track)
(mapcat (fn [[setup clips]]
(map-indexed (fn [occ c]
{:clip-id (keyword (:id c)) :setup setup
:occ occ :start (:media-in c)})
(sort-by :media-in clips))))
(sort-by :start)
vec))))
(rf/reg-sub (rf/reg-sub
::current-timeline ::current-timeline
:<- [::groups] :<- [::timeline-stack] :<- [::groups] :<- [::timeline-stack]
@ -39,7 +60,8 @@
(filter (fn [[gid g]] (and (= :annotation (:type g)) (admits? rule gid)))) (filter (fn [[gid g]] (and (= :annotation (:type g)) (admits? rule gid))))
(map (fn [[gid g]] (map (fn [[gid g]]
(let [spans (marks/resolve-group idx groups gid)] (let [spans (marks/resolve-group idx groups gid)]
{:id gid :name (:name g) :content (:content g) {:id gid :name (:name g) :content (:content g) :color (:color g)
:marks (:marks g)
:spans spans :anchor (marks/anchor spans) :spans spans :anchor (marks/anchor spans)
:instant? (every? :instant? spans)}))) :instant? (every? :instant? spans)})))
(sort-by :anchor) (sort-by :anchor)

View file

@ -1,8 +1,10 @@
(ns tl.views (ns tl.views
(:require (:require
[clojure.string :as str]
[reagent.core :as r] [reagent.core :as r]
[re-frame.core :as rf] [re-frame.core :as rf]
[tl.subs :as subs] [tl.subs :as subs]
[tl.marks :as marks]
[tl.md :as md] [tl.md :as md]
[tl.events :as events])) [tl.events :as events]))
@ -42,6 +44,69 @@
(rf/dispatch [::events/set-playhead frame]) (rf/dispatch [::events/set-playhead frame])
(seek-to-frame! fps frame)) (seek-to-frame! fps frame))
(defn- jump-and-scroll!
"Jump (seek video + move playhead) AND scroll the timeline to center the frame."
[fps frame]
(jump! fps frame)
(center-on-playhead! fps (or @(rf/subscribe [::subs/zoom]) 50) frame))
;; --- authoring draft (in app-db at :draft; nil = closed) -----------------
;; A point is {:text "..."} (a typed frame) or {:clip <id> :setup <s> :occ <n>
;; :frame <n>} (a chosen clip). A row is {:a <point> :b <point>}; b empty => an
;; instant, b filled => a range.
(defn- blank-row [] {:a {:text ""} :b {:text ""}})
(defn- blank-draft [] {:name "" :content "" :color "#4e8fc2" :rows [(blank-row)]})
(defn- insert-clip!
"Clicking a clip while authoring appends a whole-clip range row (clip[0]→clip[-1])."
[clip-id]
(let [{:keys [setup occ]} (some #(when (= clip-id (:clip-id %)) %)
@(rf/subscribe [::subs/clip-catalog]))]
(when setup
(rf/dispatch [::events/draft-conj-row
{:a {:clip clip-id :setup setup :occ occ :frame 0}
:b {:clip clip-id :setup setup :occ occ :frame -1}}]))))
;; reverse of the form's row->mark: turn stored marks back into editor rows
(defn- clip-point [cat clip frame]
(let [{:keys [setup occ]} (some #(when (= clip (:clip-id %)) %) cat)]
{:clip clip :setup setup :occ occ :frame frame}))
(defn- mp->point [cat mp]
(cond
(number? mp) {:text (str mp)}
(and (vector? mp) (= :at (first mp))) (clip-point cat (second mp) (nth mp 2))
:else {:text ""}))
(defn- mark->row [cat m]
(cond
(number? m) {:a {:text (str m)} :b {:text ""}}
(keyword? m) {:a (clip-point cat m 0) :b (clip-point cat m -1)} ; whole-clip ref
(and (vector? m) (= :at (first m))) {:a (mp->point cat m) :b {:text ""}} ; instant
(and (vector? m) (= 2 (count m))) {:a (mp->point cat (first m)) ; range
:b (mp->point cat (second m))}
:else {:a {:text ""} :b {:text ""}}))
(defn- edit-draft [cat id {:keys [name content color marks]}]
{:id id :name name :content (or content "") :color (or color "#4e8fc2")
:rows (mapv #(mark->row cat %) marks)})
;; forward: editor row -> a mark (for save + the live timeline preview)
(defn- point->mp
"Point editor -> a mark point: a frame number, or [:at clip frame], or nil."
[{:keys [clip frame text]}]
(cond
clip [:at clip (or frame 0)]
(and text (re-matches #"\s*-?\d+\s*" text)) (js/parseInt (str/trim text))
:else nil))
(defn- row->mark [{:keys [a b]}]
(let [pa (point->mp a) pb (point->mp b)]
(cond (and pa pb) [pa pb] ; range
pa pa ; instant
:else nil)))
(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
@ -138,9 +203,12 @@
(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.commentary {:ref (fn [n] (reset! el n))} [:div.commentary {:ref (fn [n] (reset! el n))}
[:div.commentary-head
[:button.add-btn {:on-click #(rf/dispatch [::events/open-draft (blank-draft)])}
"+ Add annotation"]]
(if (seq anns) (if (seq anns)
(doall (doall
(for [{:keys [id content spans] nm :name} anns] (for [{:keys [id content spans] nm :name :as ann} anns]
(let [targets (vec (jump-targets fps nm spans)) (let [targets (vec (jump-targets fps nm spans))
one? (= 1 (count targets))] one? (= 1 (count targets))]
^{:key id} ^{:key id}
@ -148,14 +216,24 @@
:class (when (= id active) "active")} :class (when (= id active) "active")}
[:div.ann-head [:div.ann-head
[:div.ann-title nm] [:div.ann-title nm]
[:div.ann-actions
(if one? (if one?
[:button.jump-btn [:button.jump-btn
{:on-click #(jump! fps (:frame (first targets)))} "↪ jump"] {:on-click #(jump-and-scroll! fps (:frame (first targets)))} "↪ jump"]
;; native popover: the button toggles it; the browser ;; native popover: the button toggles it; the browser
;; handles light-dismiss + Escape + top-layer. Positioning is ;; handles light-dismiss + Escape + top-layer. Positioning is
;; the one bit of JS (anchor() misbehaves in a scroll box). ;; the one bit of JS (anchor() misbehaves in a scroll box).
[:button.jump-btn {:popovertarget (str "jp-" (name id))} [:button.jump-btn {:popovertarget (str "jp-" (name id))}
(str "↪ jump (" (count targets) ")")])] (str "↪ jump (" (count targets) ")")])
[:button.edit-btn
{:title "Edit annotation"
:on-click #(rf/dispatch [::events/open-draft
(edit-draft @(rf/subscribe [::subs/clip-catalog]) id ann)])}
"✎"]
[:button.del-btn {:title "Delete annotation"
:on-click #(when (js/confirm (str "Delete \"" nm "\"?"))
(rf/dispatch [::events/delete-annotation id]))}
"✕"]]]
(when-not one? (when-not one?
[:div.jump-pop {:popover "auto" :id (str "jp-" (name id)) [:div.jump-pop {:popover "auto" :id (str "jp-" (name id))
:ref position-popover-ref} :ref position-popover-ref}
@ -163,27 +241,25 @@
^{:key k} ^{:key k}
[:button {:popovertarget (str "jp-" (name id)) [:button {:popovertarget (str "jp-" (name id))
:popovertargetaction "hide" :popovertargetaction "hide"
:on-click #(jump! fps (:frame t))} :on-click #(jump-and-scroll! fps (:frame t))}
(:label t)])]) (:label t)])])
[md/render content]]))) [md/render content]])))
[:div.ann-empty "No annotations."])])))) [:div.ann-empty "No annotations."])]))))
;; --- timeline ------------------------------------------------------------- ;; --- timeline -------------------------------------------------------------
(def ^:private ann-colors ["#c2554e" "#4e8fc2" "#5ab07a" "#b08a3a" "#8a5ab0" "#3aa0a0"])
(defn annotation-lane (defn annotation-lane
"One labeled row per annotation, above the tracks. Bars are positioned in "One labeled row per annotation, above the tracks. Bars are positioned in
display frames (same coordinate as clips), so they align with the timeline." display frames (same coordinate as clips), so they align with the timeline."
[fps zoom annotations active] [fps zoom annotations active]
[:div [:div
(doall (doall
(map-indexed (for [{:keys [id name spans preview?] c :color} annotations]
(fn [i {:keys [id name spans]}] (let [color (or c "#4e8fc2")
(let [color (nth ann-colors (mod i (count ann-colors)))
on? (= id active)] on? (= id active)]
^{:key id} ^{:key id}
[:div.ann-lane-row [:div.ann-lane-row
(doall
(for [[j s] (map-indexed vector spans)] (for [[j s] (map-indexed vector spans)]
(let [left (* (secs (:start s) fps) zoom) (let [left (* (secs (:start s) fps) zoom)
w (* (secs (- (:end s) (:start s)) fps) zoom)] w (* (secs (- (:end s) (:start s)) fps) zoom)]
@ -191,23 +267,28 @@
[:div.ann-bar [:div.ann-bar
{:title name {:title name
:style {:left left :width (max 3 w) :style {:left left :width (max 3 w)
:background (if on? color (str color "80")) :background (cond preview? (str color "44") on? color :else (str color "80"))
:border (str "1px solid " color)}}])) :border (str (if preview? "1px dashed " "1px solid ") color)}}])))
(when-let [a (some-> spans first :start)] (when-let [a (some-> spans first :start)]
[:div.ann-bar-label {:style {:left (+ 4 (* (secs a fps) zoom))}} name])])) [:div.ann-bar-label {:style {:left (+ 4 (* (secs a fps) zoom))}} name])])))])
annotations))])
(defn clip-block [fps zoom row-h {:keys [name media-in duration] :as _clip}] (defn clip-block [fps zoom row-h authoring? {:keys [id 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.clip {:style {:left left :width width :height (- row-h 2) [:div.clip {:style {:left left :width width :height (- row-h 2)
:line-height (str (- row-h 2) "px")}} :line-height (str (- row-h 2) "px")}
;; while authoring, click a clip to drop it into the marks list
:class (when authoring? "clickable")
:on-mouse-down (when authoring?
(fn [e]
(.stopPropagation e) ; don't start a scrub
(insert-clip! (keyword id))))}
name])) name]))
(defn track-row [fps zoom row-h track] (defn track-row [fps zoom row-h authoring? track]
[:div.track-row {:style {:height row-h}} [:div.track-row {:style {:height row-h}}
(for [c (:clips track)] (for [c (:clips track)]
^{:key (:id c)} [clip-block fps zoom row-h c])]) ^{:key (:id c)} [clip-block fps zoom row-h authoring? 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
@ -246,8 +327,23 @@
playing? @(rf/subscribe [::subs/playing?]) playing? @(rf/subscribe [::subs/playing?])
anns @(rf/subscribe [::subs/annotations]) anns @(rf/subscribe [::subs/annotations])
active @(rf/subscribe [::subs/active-annotation]) active @(rf/subscribe [::subs/active-annotation])
;; live, un-saved preview of the annotation currently being authored
d @(rf/subscribe [::subs/draft])
preview (when d
(let [cidx @(rf/subscribe [::subs/clip-index])
groups @(rf/subscribe [::subs/groups])
marks (vec (keep row->mark (:rows d)))
spans (when (and cidx (seq marks))
(try (marks/resolve-marks cidx groups #{} marks)
(catch :default _ nil)))]
(when (seq spans)
{:id ::draft :preview? true :color (:color d)
:name (or (not-empty (str/trim (str (:name d)))) "(new)")
:spans spans})))
lane-anns (let [base (if (:id d) (remove #(= (:id %) (:id d)) anns) anns)]
(cond-> (vec base) preview (conj preview)))
vtracks (filter #(= :video (:kind %)) tracks) ; audio hidden vtracks (filter #(= :video (:kind %)) tracks) ; audio hidden
lane-h (* 18 (count anns)) lane-h (* 18 (count lane-anns))
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)))
@ -268,9 +364,9 @@
(let [x (* (secs playhead fps) zoom)] (let [x (* (secs playhead fps) zoom)]
[:div.playhead {:style {:left x}} [:div.playhead {:style {:left x}}
[:div.playhead-handle]]) [:div.playhead-handle]])
[annotation-lane fps zoom anns active] [annotation-lane fps zoom lane-anns active]
(for [t vtracks] (for [t vtracks]
^{:key (:id t)} [track-row fps zoom row-h t])]]])))) ^{:key (:id t)} [track-row fps zoom row-h (some? d) t])]]]))))
;; --- zoom + layout shell -------------------------------------------------- ;; --- zoom + layout shell --------------------------------------------------
@ -294,44 +390,162 @@
(defn zoom-controls [] (defn zoom-controls []
(let [zoom @(rf/subscribe [::subs/zoom]) (let [zoom @(rf/subscribe [::subs/zoom])
row-h @(rf/subscribe [::subs/row-h])] row-h @(rf/subscribe [::subs/row-h])
playing? @(rf/subscribe [::subs/playing?])]
[:div.toolbar [:div.toolbar
[:button.play-btn {:on-click toggle-play! :title "Play / pause (Space)"}
(if playing? "⏸" "▶")]
[slider "Zoom X" zoom 2 400 zoom-x!] [slider "Zoom X" zoom 2 400 zoom-x!]
[slider "Zoom Y" row-h 8 120 #(rf/dispatch [::events/set-row-h %])]])) [slider "Zoom Y" row-h 8 120 #(rf/dispatch [::events/set-row-h %])]]))
(defn- begin-resize! (defn- begin-resize!
"Drag a divider: dispatches `evt` with the new size, clamped to [lo hi]." "Drag a divider: dispatches `evt` with the new size as a relative percent,
[axis evt cur lo hi ev] clamped to [lo hi]. :x = % of the row width; :y = vh from the top."
[axis evt lo hi ev]
(.preventDefault ev) (.preventDefault ev)
(let [p0 (if (= axis :x) (.-clientX ev) (.-clientY ev)) (let [container (.. ev -currentTarget -parentNode) ; the .top row (for :x)
move (fn [e] move (fn [e]
(let [p (if (= axis :x) (.-clientX e) (.-clientY e)) (let [pct (if (= axis :x)
n (-> (+ cur (- p p0)) (max lo) (min hi))] (let [r (.getBoundingClientRect container)]
(* 100 (/ (- (.-clientX e) (.-left r)) (.-width r))))
(* 100 (/ (.-clientY e) (.-innerHeight js/window))))
n (-> pct (max lo) (min hi))]
(rf/dispatch-sync [evt n]))) (rf/dispatch-sync [evt n])))
up (fn up [_] up (fn up [_]
(.removeEventListener js/document "mousemove" move) (.removeEventListener js/document "pointermove" move)
(.removeEventListener js/document "mouseup" up))] (.removeEventListener js/document "pointerup" up))]
(.addEventListener js/document "mousemove" move) ;; pointer events so it works for touch (mouse events don't fire on touch)
(.addEventListener js/document "mouseup" up))) (.addEventListener js/document "pointermove" move)
(.addEventListener js/document "pointerup" up)))
;; --- authoring form -------------------------------------------------------
(defn- suggestions [catalog text fps]
(let [t (str/trim (or text ""))]
(cond
(re-matches #"-?\d+" t)
[{:type :frame :frame (js/parseInt t) :label (str "Frame " t " · " (tc (js/parseInt t) fps))}]
(seq t)
(->> catalog
(filter #(str/includes? (str/lower-case (:setup %)) (str/lower-case t)))
(map #(assoc % :type :clip
:label (str (:setup %) " [" (:occ %) "] · " (tc (:start %) fps))))
(take 40) vec)
:else [])))
(defn point-input
"A point editor: text input with autocomplete (frame or clip), or — once a
clip is chosen — a chip + a frame field. `path` is the path into :draft for
this point; the value lives in re-frame (the highlighted-row index is the only
local, ephemeral bit)."
[path _placeholder]
(let [hi (r/atom 0)] ; ephemeral: autocomplete highlight
(fn [path placeholder]
(let [p (or @(rf/subscribe [::subs/draft-at path]) {})
fps (or @(rf/subscribe [::subs/fps]) (/ 24000 1001))
set-point! (fn [v] (rf/dispatch [::events/draft-set path v]))]
(if (:clip p)
(let [dur (some-> @(rf/subscribe [::subs/clip-index]) (get (:clip p)) :duration)]
[:span.pt-chip
[:span.pt-chip-name (str (:setup p) " [" (:occ p) "]")]
[:input.pt-frame {:type "number" :value (:frame p)
:title "frame within clip (-1 = last)"
:on-change #(rf/dispatch [::events/draft-set (conj path :frame)
(js/parseInt (.. % -target -value))])}]
(when dur [:span.pt-dur (str "/" dur)])
[:button.pt-x {:title "clear" :on-click #(set-point! {:text ""})} "×"]])
(let [text (:text p "")
sugg (suggestions @(rf/subscribe [::subs/clip-catalog]) text fps)
preview! (fn [] (let [s (nth sugg @hi nil)]
(when (= :clip (:type s)) (jump-and-scroll! fps (:start s)))))
pick (fn [i] (when-let [s (nth sugg i nil)]
(set-point! (case (:type s)
:frame {:text (str (:frame s))}
:clip {:clip (:clip-id s) :setup (:setup s)
:occ (:occ s) :frame 0}))))]
[:span.pt-input
[:input.pt-text
{:value text :placeholder placeholder
:on-change #(do (set-point! {:text (.. % -target -value)}) (reset! hi 0))
:on-key-down (fn [e]
(case (.-key e)
"ArrowDown" (do (.preventDefault e)
(swap! hi #(min (max 0 (dec (count sugg))) (inc %)))
(preview!))
"ArrowUp" (do (.preventDefault e)
(swap! hi #(max 0 (dec %)))
(preview!))
"Enter" (do (.preventDefault e) (pick @hi))
nil))
:on-blur #(when (seq sugg) (pick @hi))}]
(when (seq sugg)
[:ul.pt-dropdown
(doall
(map-indexed
(fn [i s] ^{:key i}
[:li {:class (when (= i @hi) "hi")
:on-mouse-enter #(reset! hi i)
:on-mouse-down (fn [e] (.preventDefault e) (pick i))}
(:label s)])
sugg))])]))))))
(defn mark-row [i]
[:div.mark-row
[point-input [:rows i :a] "frame or clip…"]
[:span.mark-arrow "→"]
[point-input [:rows i :b] "end (optional)"]
[:button.row-x {:title "remove mark"
:on-click #(rf/dispatch [::events/draft-remove-row i])}
"✕"]])
(defn- save-draft! [d]
(let [nm (str/trim (:name d))
marks (vec (keep row->mark (:rows d)))]
(cond
(str/blank? nm) (js/alert "Name is required.")
(empty? marks) (js/alert "Add at least one valid mark.")
:else (let [payload {:name nm :content (:content d) :color (:color d) :marks marks}]
(if-let [id (:id d)]
(rf/dispatch [::events/update-annotation id payload])
(rf/dispatch [::events/add-annotation payload]))
(rf/dispatch [::events/close-draft])))))
(defn annotation-form []
(when-let [d @(rf/subscribe [::subs/draft])]
[:div.form
[:div.form-head (if (:id d) "Edit annotation" "New annotation")]
[:div.form-row
[:input.form-name {:placeholder "Name (required)" :value (:name d) :auto-focus true
:on-change #(rf/dispatch [::events/draft-set [:name] (.. % -target -value)])}]
[:input.form-color {:type "color" :value (:color d)
:on-change #(rf/dispatch [::events/draft-set [:color] (.. % -target -value)])}]]
[:textarea.form-content {:placeholder "Content (optional, markdown)" :value (:content d)
:on-change #(rf/dispatch [::events/draft-set [:content] (.. % -target -value)])}]
[:div.form-marks-label "Marks"]
(doall (for [i (range (count (:rows d)))] ^{:key i} [mark-row i]))
[:button.add-mark {:on-click #(rf/dispatch [::events/draft-conj-row (blank-row)])} "+ add mark"]
[:div.form-hint "Tip: click a clip in the timeline to drop it in as a whole-clip range."]
[:div.form-actions
[:button.save {:on-click #(save-draft! d)} "Save"]
[:button.cancel {:on-click #(rf/dispatch [::events/close-draft])} "Cancel"]]]))
(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))
top-h @(rf/subscribe [::subs/top-h]) top-h @(rf/subscribe [::subs/top-h])
video-w @(rf/subscribe [::subs/video-w])] video-w @(rf/subscribe [::subs/video-w])]
[:div.app [:div.app {:style {"--top-h" (str top-h "vh") "--video-w" (str video-w "%")}}
;; top region: video | annotation ;; top region: video | annotation (the annotation pane becomes the
[:div.top {:style {:height top-h}} ;; authoring form while a draft is open, leaving the timeline accessible)
[:div.video-pane {:style {:width video-w}} [:div.top
[:div.video-pane
[video-monitor fps]] [video-monitor fps]]
[:div.divider-v [:div.divider-v
{:on-mouse-down #(begin-resize! :x ::events/set-video-w video-w {:on-pointer-down #(begin-resize! :x ::events/set-video-w 12 88 %)}]
220 (- (.-innerWidth js/window) 280) %)}] [:div.annot-pane (if @(rf/subscribe [::subs/draft]) [annotation-form] [commentary])]]
[:div.annot-pane [commentary]]]
;; horizontal divider ;; horizontal divider
[:div.divider-h [:div.divider-h
{:on-mouse-down #(begin-resize! :y ::events/set-top-h top-h {:on-pointer-down #(begin-resize! :y ::events/set-top-h 12 88 %)}]
140 (- (.-innerHeight js/window) 160) %)}]
;; timeline region ;; timeline region
[:div.timeline-pane [:div.timeline-pane
[zoom-controls] [zoom-controls]