local storage, mobile improvements, re-frame refactor, etc
This commit is contained in:
parent
38367cd052
commit
6ed11fbca5
5 changed files with 351 additions and 87 deletions
|
|
@ -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}})
|
||||||
|
|
|
||||||
|
|
@ -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
16
tl/src/tl/storage.cljs
Normal 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)))
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue