diff --git a/tl/data_model.org b/tl/data_model.org index 689956a..547ce0d 100644 --- a/tl/data_model.org +++ b/tl/data_model.org @@ -2,18 +2,35 @@ a mark-group is the fundamental datastructure of this app. the whole scene graph is composed of them. we use this same structure to represent the main timeline, the clips within the timeline, and the annotations on the timeline. a mark-group is just an ordered list of marks with a little bit of extra data hanging off them depending on the type. it also has a parent: the mark-group context it belongs to. -a mark can represent an instant or a range of time, and that instant or range can be defined in terms of frames (relative to the current timeline on the timeline stack) or frames w/r/t to other mark-groups and these can be mixed and matched. each mark can also optionally specify a target video track (video tracks and clips are initially sourced from an initial otio file). thus, the main timeline is a mark group with one mark: the start and end timestamp. a clip which belongs to that main timeline is a mark group with one mark: the start and end timestamps (within the main timeline) AND the video track to which it belongs. its parent is the main timeline (or should it have no parent? since these are the fundamental units maybe it's ok if they are seen everywhere. basically we have to make a choice: do we copy the clips into new contexts? like if we do it such that a clip has a parent, and the annotation on that clip has the same parent, they are siblings -- what does it mean to expand the annotation by putting it on the timeline stack w/r/t the underlying sibling clips, then? really in that case, the clip should become a child of the annotation, but it also needs to be the child of the main timeline as well, and any other sub annotation...seems better to have them parentless). an annotation is a mark group which conceptually represents a point or points of interest with optional commentary, though it looks not substantially different than a timeline or clip in the data structure, and really it's so flexible it could represent whole re-edits of clips. its marks can also be timestamps in the context of some timeline, or they can be the start and end frames of a given clip, defined relative to the clip, or defined relative to another annotation, or any combination thereof, in any amount, and with any combination of ranges and instants. (should an instant really just be a range of one frame? rather than its own separate thing?) +a mark can represent an instant or a range of time, and that instant or range can be defined in terms of frames (relative to the current timeline on the timeline stack) or frames w/r/t to other mark-groups and these can be mixed and matched. each mark can also optionally specify a target video track (video tracks and clips are initially sourced from an initial otio file; the otio is only a seed -- it populates :tracks and the initial clip + timeline mark-groups once, then we never look at it again. :tracks is the only thing in the whole app that isn't a mark-group). thus, the main timeline is a mark group with one mark: the start and end timestamp. a clip which belongs to that main timeline is a mark group with one mark: the start and end timestamps (within the main timeline) AND a video track. but the track hangs off the mark, optionally, not the root of the clip. a clip is only different from an annotation in that one of its marks specifies a track (and, soon, :thumbnails), so an annotation could target a track too. clips are parentless: they're a flat pool, referenced by id, never owned. :parent is an annotation-only thing -- the authoring/visibility context, i.e. which timeline i was in when i made it. this dodges the whole knot: if clips had parents, expanding an annotation would have to make the clip a child of the annotation AND the main timeline AND every sub-annotation at once. containment is a reference, not ownership. an annotation is a mark group which conceptually represents a point or points of interest with optional commentary, though it looks not substantially different than a timeline or clip in the data structure, and really it's so flexible it could represent whole re-edits of clips. its marks can also be timestamps in the context of some timeline, or they can be the start and end frames of a given clip, defined relative to the clip, or defined relative to another annotation, or any combination thereof, in any amount, and with any combination of ranges and instants. an instant is just a range where start == end (length 0), no separate type. concatenation goes by length, so an instant adds no duration -- it's a marker at the current offset, drawn as a diamond instead of a bar. because an annotation is just a mark-group, and a timeline is just a mark group, any annotation can be pushed onto the timeline-stack, replacing the main timeline. the clips within the marks in the mark group are laid end to end to form one continuous duration, a new timeline. the cool thing here is that you can now annotate within the context of this annotation. so if we are inside annotation A, annotation A is the :parent of our new Annotation B. if annotation B uses absolute timestamp marks, they are relative to the annotation A timeline, not the main timeline. and if annotation B uses clip based timestamps, they can only reference the frames of the underlying clip which are within range of the annotation (annotation A, the parent, may have start half way through the clip at the beginning, and end half way through the clip at the end). and then you can push annotation B onto the timeline stack, annotate within that, and on and on. -something about mark groups to note is that marks need not be defined in order. if the main timeline has clip A, B, and C laid end to end, an annotation X can have marks [[clipC[0], clipC[-1]], [clipA[0], clipA[-1]], [clipB[0], clipB[-1]]. -1 represents last available frame of the clip (note here that available frame may differ from absolute last frame of the underlying clip, because the annotation context we're in could cut off half the clip, for example). in this example, we have totally rearranged the clips into a timeline B C A, end to end. and you can also imagine we can cut clips in half, interleave them, repeat them and so on. +something about mark groups to note is that marks need not be defined in order. if the main timeline has clip A, B, and C laid end to end, an annotation X can have marks [[clipC[0], clipC[-1]], [clipA[0], clipA[-1]], [clipB[0], clipB[-1]]. -1 represents last available frame of the clip (note here that available frame may differ from absolute last frame of the underlying clip, because the annotation context we're in could cut off half the clip, for example). note this clamping only bites for raw clip refs across a trim -- if you reference the subclip (the parent's mark) instead, the trim is baked into the mark's range, so subclip[-1] = the subclip's own end, no clamp (more below). in this example, we have totally rearranged the clips into a timeline B C A, end to end. and you can also imagine we can cut clips in half, interleave them, repeat them and so on. hmm here's a struggle though. let's say i have interleaved half of A with half of B in an annotation which i pushed onto the timeline stack: marks: [[clipB[20], clipB[40]], [clipA[0], clipA[20]], [clipB[0], clipB[20]] [clipA[20], clipA[40]]] -we want to be able to mark these sub clips independently for a new annotation Y, right? but these are just 2 root clips that became 4. so how can we define annotation Y with respect to any of these 4 clips? we don't want to use absolute timeline time, but we also don't want to use absolute clip time because our marks can cross between the first sub clip (b 20 - 40) and the second (a 0 to a 20), so clip time means nothing here. we also need to remember that the parent timeline can clear out its marks at will. so we can definitely orphan annotations - that's ok, that's a UI concern we can display warnings for and just grey out basically (drop to bottom of annotation list, for example, with a warn emoji and let the user edit to specify its marks again, even warn on which marks are broken, and if at least one mark is still valid still display it there). so it rly seems like an annotation SHOULD create synthetic subclips which point at the raw clips so that we can use the synthetic subclips as our targets. but they need to be stable identities, serializable/deserializable. +we want to be able to mark these sub clips independently for a new annotation Y, right? but these are just 2 root clips that became 4. so how can we define annotation Y with respect to any of these 4 clips? we don't want to use absolute timeline time, but we also don't want to use absolute clip time because our marks can cross between the first sub clip (b 20 - 40) and the second (a 0 to a 20), so clip time means nothing here. we also need to remember that the parent timeline can clear out its marks at will. so we can definitely orphan annotations - that's ok, that's a UI concern we can display warnings for and just grey out basically (drop to bottom of annotation list, for example, with a warn emoji and let the user edit to specify its marks again, even warn on which marks are broken, and if at least one mark is still valid still display it there). so it rly seems like an annotation SHOULD create synthetic subclips which point at the raw clips so that we can use the synthetic subclips as our targets. but they need to be stable identities, serializable/deserializable. -this brings us to playing. since right now there is only one source video file, we need to be able to seek to arbitrary frames. we should have a playhead stack, or each annotation can maintain its own playhead that gets used when its the top of the timeline stack. when we hit play, in the example above of annotation X, we find the clip under the playhead of the current timeline on the timeline stack, find where we are relative to the root clip, and use that root clip's frame to seek to that frame in the source video file. and start playing. when the playhead advances and the clip under it has changed, we look to see if there is a difference between that absolute frame in the source file (computed from playhead position and the clip) and where we would expect to be in the file. if it differs, we seek to that absolute frame and continue playing, checking every frame if we need to seek. does that make sense? this should be an extremely simple function. because we're rendering the timeline already, we should already know which frame we should be on without even recursing, i think? +resolved: every mark gets a stable id (a uuid) at creation, and a clip/subclip-ref mark stays within ONE clip -- so the addressable subclip is just a single-clip mark, addressed by mark-id alone. a selection that crosses clip boundaries is stored as a RUN of per-clip marks (the lane sticks the contiguous run into one bar; the editor splits/merges at boundaries on edit). a mark is already a recursive structure pointing at a raw clip, the id just makes it addressable, so no separate entities, no recreation lifecycle. Y references A's subclips by mark-id: {:ref :at n}. +- edit a mark's range -> same id -> Y follows it +- reorder marks -> ids travel with them -> Y follows the content, not the slot +- delete a mark -> id gone -> Y dangles -> orphan (grey out, warn per broken mark, keep if at least one still resolves) +- add a mark -> new id +so "keep identity unless the whole thing is different" isn't an algorithm we run, it's a ui affordance: editing a row in place keeps the id, delete-row + add-row makes a new id. like keyed list editing / db rows with primary keys -- you carry stable keys, you never diff structure to guess identity. + +and the subclip bakes the trim into its own definition, so it's a clean map to source: subclip[n] = src-start + n, subclip[-1] = src-end, no clamp, no context lookup. that makes resolving a ref context-free: (mark-id, scene) -> source range, no timeline-stack needed. the stack only decides which context's local timeline you're looking at, not how a ref resolves. only absolute (bare number) points are context-dependent -- they're local frames of the context the mark lives in. + +so the whole mark grammar is two point kinds: +- ref point {:ref :at n} -> frame n of that mark's resolved range (context-free, n negative = from the end) +- absolute point -> a local frame of the context the mark lives in (resolved through that context's spans) +mix them in a single range, instant = start==end, track optional on the mark. a raw clip is just a mark whose range is full source + a track. a clip/subclip-ref mark keeps both endpoints on the SAME target, so it resolves to exactly one source segment; only an absolute mark may span several. + +decided: a clip/subclip-ref mark never crosses a clip boundary, so the "range whose endpoints are in different subclips" case just can't happen. a selection across clips is a run of single-clip marks instead, one per clip, laid end to end (genuinely contiguous -- the lane only draws them as one bar). this keeps resolve trivial (one source segment per ref mark), makes every piece addressable by mark-id alone, and makes a reorder follow each piece independently instead of swelling. the merge/unmerge is localized: dragging a boundary WITHIN a clip edits that end mark in place (id stable); dragging ACROSS a clip boundary adds/removes a whole clip from the selection (creates/destroys that end mark); the fully-contained middle clips never churn. the same rule applies at any depth -- "clip boundary" means a boundary in the fully-resolved footage, so it works the same whether you're at root crossing raw clips or nested crossing a parent mark's segments. the one multi-segment mark left is an absolute one (bare-number local range): it's arrangement-relative, you build it by dragging the timeline rather than by referencing, and it resolves via slice. + + +this brings us to playing. since right now there is only one source video file, we need to be able to seek to arbitrary frames. each context (mark-group) keeps its own local playhead, used when it's the top of the timeline stack. when we hit play, in the example above of annotation X, we find the clip under the playhead, compute the source frame, seek there, and start playing. one correction though: the local playhead has to be the master clock, not the video. you can't derive local position from currentTime -- once an annotation repeats or reorders clips, one source frame maps to several local frames, it's not invertible. so the local playhead advances on its own (wall-clock x fps while playing), and every frame we compute expected = group->media(local) and seek the video there only if round(currentTime*fps) != expected. within a clip, expected tracks the video's natural playback so no seek fires; at a mark boundary it jumps once and we seek. and right -- no recursion at play time: we resolve the current context once into flat ordered spans, and group->media is just the flat lookup the renderer already does. # how do we determine which tracks are included when we zoom into each annotation? for now it should just be if a clip is within the ranges of the mark-group, its track is included in the annotation. # automatically scroll to bottom-most track in mark group range when we hit the start mark? but what if it's massively spread out. maybe not then. scrolling should be an option turned on. thats ok. make it explicit. diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index aef0fea..cdd6514 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -20,12 +20,24 @@ body { overflow: hidden; } .top { display: flex; min-height: 140px; height: var(--top-h, 46vh); } .video-pane { + position: relative; flex-shrink: 0; width: var(--video-w, 50%); min-width: 200px; display: flex; align-items: center; justify-content: center; background: #000; overflow: hidden; } .video-pane video { max-width: 100%; max-height: 100%; display: block; background: #000; } +.frame-readout { + position: absolute; bottom: 6px; right: 8px; z-index: 4; + display: flex; gap: 8px; align-items: center; + font-family: monospace; font-size: 11px; color: #cfe3f5; + background: rgba(0,0,0,0.6); padding: 2px 7px; border-radius: 4px; + pointer-events: none; /* don't block video controls… */ +} +.frame-readout .fr-clip { pointer-events: auto; } /* …except the clip chip */ +.fr-clip.clickable { cursor: copy; color: #9ed7b0; } +.fr-clip.clickable:hover { color: #c7efd4; } + .annot-pane { flex: 1; min-width: 200px; display: flex; } /* --- draggable dividers ------------------------------------------------- */ @@ -56,11 +68,9 @@ body { overflow: hidden; } 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; +/* popover of jump targets for an annotation with discontinuous marks */ +.jump-pop { + position: absolute; right: 0; top: 100%; z-index: 20; margin-top: 2px; 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; @@ -100,6 +110,10 @@ body { overflow: hidden; } } .expand-btn:hover { color: #cfe3f5; border-color: #3a6ea5; } .expand-btn.has-children { color: #cfe3f5; border-color: #3a6ea5; background: #16263a; } +.nest-badge { + margin-left: 3px; font-size: 9px; line-height: 1; vertical-align: top; + background: #3a6ea5; color: #fff; border-radius: 8px; padding: 1px 4px; +} /* --- authoring form (fills the annotation pane) ------------------------- */ .form { diff --git a/tl/src/tl/core.cljs b/tl/src/tl/core.cljs index 66fbb4d..a5d94ff 100644 --- a/tl/src/tl/core.cljs +++ b/tl/src/tl/core.cljs @@ -19,15 +19,7 @@ (rdom/unmount-component-at-node root-el) (rdom/render [views/main-panel] root-el))) -;; one-time dev reset: wipe saved marks once so the nested-timeline seed data -;; shows, then flag it so persistence resumes normally. Safe to delete later. -(defn- reset-marks-once! [] - (when-not (.getItem js/localStorage "tl/seed-reset-2") - (.removeItem js/localStorage "tl/marks") - (.setItem js/localStorage "tl/seed-reset-2" "1"))) - (defn init [] - (reset-marks-once!) (re-frame/dispatch-sync [::events/initialize-db]) (re-frame/dispatch [::events/load-otio]) (dev-setup) diff --git a/tl/src/tl/db.cljs b/tl/src/tl/db.cljs index db269c0..7576034 100644 --- a/tl/src/tl/db.cljs +++ b/tl/src/tl/db.cljs @@ -1,78 +1,18 @@ (ns tl.db) (def default-db - {;; load lifecycle for the OTIO fetch - :load {:status :idle ; :idle | :loading | :ready | :error - :error nil} + {:load {:status :idle} ; :idle | :loading | :ready | :error + :fps (/ 24000 1001) - ;; the parsed, immutable-after-load document (tl.otio/parse output) - :timeline nil + ;; the whole scene graph (see tl.scene). Seeded from OTIO at load. + :scene {:tracks {} + :groups {:root {:type :timeline :parent nil + :marks [{:id :root-m :start 0 :end 0}]}}} - ;; Mark-groups: the annotation layer (see tl.marks for the grammar). - ;; Clips come from :timeline; these are the user-authored groups + the - ;; root timeline descriptor. - ;; - ;; Nesting: every annotation has a :parent (the context it lives in; :root = - ;; top level). "Expanding" an annotation pushes its id onto :timeline-stack; - ;; the top of the stack is the current context, and only annotations whose - ;; :parent matches it are surfaced — so a nested annotation is invisible on - ;; the main timeline. The context annotation's own spans crop the timeline - ;; (time window + involved tracks) it's expanded into. - ;; - ;; The annotations below are seed data (shown on a fresh localStorage) so the - ;; expand/nest flow is testable: "Opening rally" has two children. - ;; The seed is laid out to exercise lane packing. Root level (by frame): - ;; Act I 0–414 (section) created 1 -> lane 0 (earliest, top) - ;; Opening rally 129–385 created 2 -> lane 1 (overlaps Act I) - ;; Baseline 64–128 created 3 -> lane 1 (fits before rally) - ;; Tashi glance 150–175 created 4 -> lane 2 (overlaps both) - ;; Net-cam beat 542–609 created 5 -> lane 0 (collapses after Act I) - :marks - {:timeline-stack [:root] - :groups {:root {:type :timeline :name "Sequence"} - - :ann-act1 - {:type :annotation :parent :root :name "Act I — the warmup" :color "#8a8f3a" - :created 1 :content "The whole opening movement." - :marks [[0 414]]} - :ann-rally - {:type :annotation :parent :root :name "Opening rally" :color "#4e8fc2" - :created 2 - :content "The first volley — CU coverage across the three principals." - :marks [:t2-c0 :t3-c0 :t4-c0]} - :ann-baseline - {:type :annotation :parent :root :name "Baseline establishing" :color "#c28f4e" - :created 3 :marks [:t1-c0]} - :ann-glance - {:type :annotation :parent :root :name "Tashi glance" :color "#c24e9a" - :created 4 :marks [[150 175]]} - :ann-net - {:type :annotation :parent :root :name "Net-cam beat" :color "#9a7ac2" - :created 5 :marks [:t6-c0]} - - ;; children of "Opening rally" (visible only when expanded into it): - ;; Tashi's read 129–190 / Art reacts 192–266 -> share lane 0 - ;; the spin 140–205 -> lane 1 (overlaps both) - :ann-tashi-read - {:type :annotation :parent :ann-rally :name "Tashi's read" :color "#c2624e" - :created 6 :content "She clocks the spin early." - :marks [[[:at :t2-c0 0] [:at :t2-c0 -1]]]} - :ann-art-react - {:type :annotation :parent :ann-rally :name "Art reacts" :color "#5ab07a" - :created 7 :marks [:t3-c0]} - :ann-spin - {:type :annotation :parent :ann-rally :name "the spin" :color "#4ec2b0" - :created 8 :marks [[140 205]]}}} - - ;; transient view state (mutates while scrubbing/zooming) - :view {:zoom 50 ; X zoom: pixels per SECOND (px/frame = zoom/fps) - :row-h 28 ; Y zoom: track row height in px - ;; playhead per timeline context (frames): keyed by the timeline-stack - ;; top, so each (sub)timeline remembers where its playhead was. :root - ;; is the main timeline. - :playheads {} - :selected-clip nil - ;; resizable layout (relative): top region height as vh, video pane - ;; width as % of the top row. Pixel minimums enforced in CSS. - :top-h 46 - :video-w 50}}) + ;; view state + :view {:stack [:root] ; timeline-stack; top = current context + :playheads {} ; per-context local playhead + :playing? false + :zoom 50 ; px per second + :row-h 28 ; track row height (px) + :pt nil}}) ; authoring: next clip-click target diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index 4cca932..a15aa2d 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -3,21 +3,39 @@ [re-frame.core :as rf] [tl.db :as db] [tl.otio :as otio] + [tl.scene :as scene] [tl.storage :as storage] [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 ::initialize-db (fn [_ _] db/default-db)) -(rf/reg-event-db - ::initialize-db - (fn [_ _] - ;; hydrate the annotation layer from localStorage if present - (if-let [saved (storage/load-marks)] - (assoc db/default-db :marks saved) - db/default-db))) +;; after a group-mutating event, persist the authored annotation layer +(def persist (rf/after (fn [db] (storage/save! (:scene db))))) -;; --- load + parse --------------------------------------------------------- +;; --- load + seed ---------------------------------------------------------- + +(defn- demo-annotations + "A couple of seed annotations referencing real clips, so there's something to + render/expand. 'opening' takes the 1st and 3rd clips (skips the 2nd) to show + rearrange + gap removal when expanded." + [scene] + (let [clip-ids (->> (:groups scene) + (filter (fn [[_ g]] (= :clip (:type g)))) + (sort-by (fn [[_ g]] (-> g :marks first :start))) + (mapv first)) + whole (fn [mid clip] {:id mid :start {:ref clip :at 0} :end {:ref clip :at -1}}) + [a b c] (take 3 clip-ids)] + (cond-> scene + (and a b c) + (assoc-in [:groups :ann-opening] + {:type :annotation :parent :root :name "opening" :color "#4e8fc2" + :content "first and third shots, back to back — the middle is cut." + :marks [(whole :mo-a a) (whole :mo-c c)]}) + b + (assoc-in [:groups :ann-beat] + {:type :annotation :parent :root :name "the beat" :color "#c2624e" + :content "the second shot on its own." + :marks [(whole :mb-b b)]})))) (rf/reg-event-fx ::load-otio @@ -32,135 +50,74 @@ (rf/reg-event-db ::otio-loaded (fn [db [_ raw]] - (-> db - (assoc :timeline (otio/parse raw)) - (assoc-in [:load :status] :ready)))) + (let [parsed (otio/parse raw)] + (-> db + (assoc :fps (:fps parsed)) + (assoc :scene (let [scene (scene/from-otio parsed) + saved (storage/load)] + (if (nil? saved) + (demo-annotations scene) ; first run → seed demos + (update scene :groups merge saved)))) ; else restore authored layer + (assoc-in [:load :status] :ready))))) (rf/reg-event-db ::otio-error - (fn [db [_ err]] - (-> db - (assoc-in [:load :status] :error) - (assoc-in [:load :error] err)))) + (fn [db [_ err]] (-> db (assoc-in [:load :status] :error) (assoc-in [:load :error] err)))) -;; --- playhead cursor ----------------------------------------------------- -;; The playhead is a per-context timeline cursor (a media frame), moved by -;; scrub/jump. (Playback was removed and is to be rebuilt.) +;; --- view ----------------------------------------------------------------- -(defn- ctx-id [db] (or (last (get-in db [:marks :timeline-stack])) :root)) +(rf/reg-event-db ::set-playhead + (fn [db [_ ctx lf]] (assoc-in db [:view :playheads ctx] lf))) +(rf/reg-event-db ::set-playing (fn [db [_ p]] (assoc-in db [:view :playing?] p))) +(rf/reg-event-db ::set-zoom (fn [db [_ z]] (assoc-in db [:view :zoom] z))) +(rf/reg-event-db ::set-row-h (fn [db [_ h]] (assoc-in db [:view :row-h] h))) +(rf/reg-event-db ::expand (fn [db [_ gid]] (update-in db [:view :stack] conj gid))) +(rf/reg-event-db ::collapse (fn [db _] (update-in db [:view :stack] + (fn [s] (if (> (count s) 1) (pop s) s))))) +(rf/reg-event-db ::pop-to + (fn [db [_ gid]] + (update-in db [:view :stack] + (fn [s] (let [i (first (keep-indexed #(when (= gid %2) %1) s))] + (if i (subvec s 0 (inc i)) s)))))) + +;; --- authoring ------------------------------------------------------------ +;; The draft is just a normal annotation group, flagged :draft (:new while +;; unsaved, :edit while editing). The form holds the original (with-let) and +;; drives everything — field edits, frame nudges, save, cancel — through the +;; generic ::put-group. The only transient bit is [:view :pt]: the next clip +;; click's target — :new, or {:seg :f} once a start is pending. + +(rf/reg-event-db ::open-draft + (fn [db _] + (-> db + (assoc-in [:scene :groups (keyword (gensym "ann-"))] + {:type :annotation :parent (peek (get-in db [:view :stack])) + :draft :new :name "" :color "#4e8fc2" :marks []}) + (assoc-in [:view :pt] :new)))) + +(rf/reg-event-db ::edit-draft (fn [db [_ gid]] (-> db (assoc-in [:scene :groups gid :draft] :edit) + (assoc-in [:view :pt] :new)))) +(rf/reg-event-db ::put-group persist (fn [db [_ gid g]] (assoc-in db [:scene :groups gid] g))) +(rf/reg-event-db ::drop-group persist (fn [db [_ gid]] (update-in db [:scene :groups] dissoc gid))) +(rf/reg-event-db ::draft-focus (fn [db [_ pt]] (assoc-in db [:view :pt] pt))) +(rf/reg-event-db ::delete-annotation persist (fn [db [_ gid]] (update-in db [:scene :groups] dissoc gid))) + +;; click a clip while authoring: first click sets a pending start, the second +;; completes the span as a run of single-clip marks. `frame` (mark time) is +;; optional — clicking a clip uses whole-clip defaults (0 / clip end), clicking +;; the video frame-readout passes the exact frame under the playhead. (rf/reg-event-db - ::set-playhead - (fn [db [_ frames]] - ;; per-context slot, so expanding/collapsing doesn't clobber another - ;; timeline's remembered cursor position - (assoc-in db [:view :playheads (ctx-id db)] frames))) - -;; --- timeline zoom (x = px/second, y = track row height) ---------------- - -(rf/reg-event-db - ::set-zoom - (fn [db [_ px-per-sec]] (assoc-in db [:view :zoom] px-per-sec))) - -(rf/reg-event-db - ::set-row-h - (fn [db [_ h]] (assoc-in db [:view :row-h] h))) - -;; --- resizable layout ---------------------------------------------------- - -(rf/reg-event-db - ::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))) - -;; --- 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)))))) - -;; Which point a clicked clip fills. Set when a point input gains focus, kept -;; (not cleared on blur) so a subsequent clip click in the timeline lands here. -(rf/reg-event-db ::set-draft-active (fn [db [_ path]] (assoc-in db [:draft :active] path))) - -;; Drop a clicked clip into the active point, then advance: start -> end of the -;; same row; end -> start of the next row (appended if needed). Lets you build a -;; range, or a run of ranges, by clicking clips left to right. -(rf/reg-event-db - ::draft-fill-point - (fn [db [_ [_ i slot :as path] point]] - (let [db (assoc-in db (into [:draft] path) point)] - (if (= :a slot) - (assoc-in db [:draft :active] [:rows i :b]) - (-> db - (update-in [:draft :rows] - (fn [rows] (cond-> rows - (not (get rows (inc i))) (conj {:a {:text ""} :b {:text ""}})))) - (assoc-in [:draft :active] [:rows (inc i) :a])))))) - -;; --- nested timelines: expand an annotation / navigate back ------------- -;; The timeline-stack is the breadcrumb trail. Expanding pushes an annotation -;; id; the top of the stack is the current context (see ::context-id). - -(rf/reg-event-db - ::expand-annotation - (fn [db [_ id]] (update-in db [:marks :timeline-stack] conj id))) - -(rf/reg-event-db - ::collapse ; the back button: pop one level - (fn [db _] (update-in db [:marks :timeline-stack] - (fn [s] (if (> (count s) 1) (pop s) s))))) - -(rf/reg-event-db - ::pop-to ; a breadcrumb: truncate to that id - (fn [db [_ id]] - (update-in db [:marks :timeline-stack] - (fn [s] (let [i (first (keep-indexed #(when (= id %2) %1) s))] - (if i (subvec s 0 (inc i)) s)))))) - -;; --- authoring: create / delete annotations (persisted) ----------------- - -(rf/reg-event-fx - ::add-annotation - (fn [{:keys [db]} [_ {:keys [name content color marks]}]] - ;; new annotations are children of whatever context we're currently in - (let [id (keyword (str "ann-" (random-uuid))) - parent (last (get-in db [:marks :timeline-stack])) - group (cond-> {:type :annotation :parent parent :name name :color color - :marks marks :created (.now js/Date)} ; defines stacking order - (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]}]] - ;; merge so :parent (and any other group keys) survive an edit; drop a - ;; cleared :content explicitly rather than leaving the old text behind. - (let [group (cond-> {:name name :color color :marks marks :content nil} - (seq content) (assoc :content content)) - db' (update-in db [:marks :groups id] merge group)] - {:db db' :tl/save (:marks db')}))) - -(rf/reg-event-fx - ::delete-annotation - (fn [{:keys [db]} [_ id]] - ;; remove the annotation and its whole nested subtree (so children aren't - ;; orphaned), and pop the stack past anything we just deleted. - (let [groups (get-in db [:marks :groups]) - doomed (loop [acc #{id}] - (let [more (into acc (keep (fn [[gid g]] (when (acc (:parent g)) gid)) groups))] - (if (= more acc) acc (recur more)))) - db' (-> db - (update-in [:marks :groups] #(apply dissoc % doomed)) - (update-in [:marks :timeline-stack] - (fn [s] (let [s' (vec (take-while #(not (doomed %)) s))] - (if (seq s') s' [:root])))))] - {:db db' :tl/save (:marks db')}))) + ::draft-click-seg + (fn [db [_ seg-id frame]] + (let [scene (:scene db) + [gid g] (some (fn [[gid g]] (when (:draft g) [gid g])) (:groups scene)) + segs (scene/content-segments scene (:parent g)) + pt (get-in db [:view :pt])] + (if (map? pt) + (let [a (scene/seg-local segs (:seg pt) (:f pt)) + b (scene/seg-local segs seg-id (or frame (scene/seg-length segs seg-id))) + run (scene/selection->marks scene (:parent g) (min a b) (max a b))] + (-> db (update-in [:scene :groups gid :marks] into run) + (assoc-in [:view :pt] :new))) + (assoc-in db [:view :pt] {:seg seg-id :f (or frame 0)}))))) diff --git a/tl/src/tl/marks.cljs b/tl/src/tl/marks.cljs deleted file mode 100644 index cae2b0d..0000000 --- a/tl/src/tl/marks.cljs +++ /dev/null @@ -1,157 +0,0 @@ -(ns tl.marks - "Pure mark resolution: a *mark* resolves to one or more timeline *spans*. - No re-frame, no DOM — REPL/test friendly. Canonical unit is FRAMES (see tl.otio). - - Mark grammar (data, not a textual DSL): - ref :t0-c0 ; whole clip (or another mark-group) span - instant 1234 ; absolute timeline frame - [:at ref i] ; frame i within a clip; 0-based, -1 = last - range [p1 p2] ; p = absolute frame OR [:at ref i] - marks [mark ...] ; a mark-group's ordered list (an EDL / union) - - A *span* is {:start :end :track :clip :instant?} in DISPLAY frames — i.e. - media/source frames (position within the .mov), matching :media-in and the -