diff --git a/scenes/views.py b/scenes/views.py index 1d758d5..6349a67 100644 --- a/scenes/views.py +++ b/scenes/views.py @@ -143,7 +143,8 @@ def scene(request, pk): deleted = data.get("deleted") or [] summary = None if changed or deleted: - groups = dict((p.scene or {}).get("groups", {})) + prev_groups = (p.scene or {}).get("groups", {}) + groups = dict(prev_groups) for gid, patch in changed.items(): if isinstance(patch, dict) and isinstance(groups.get(gid), dict): groups[gid] = {**groups[gid], **patch} @@ -154,17 +155,23 @@ def scene(request, pk): merged = dict(p.scene or {}, groups=groups) p.scene, summary, counts = apply_attribution( p.scene, merged, request.user.get_username(), timezone.now().isoformat()) - if any(counts.values()): # don't log a no-op save + scene_groups = (p.scene or {}).get("groups", {}) + if any(counts.values()): # history logs annotation edits only Revision.objects.create(project=p, user=request.user, summary=summary, annotations=annotation_layer(p.scene)) - # push the attributed delta to peers viewing this project - scene_groups = (p.scene or {}).get("groups", {}) + # Live sync must cover EVERY group kind, not just annotations: script + # notes and drawings aren't counted by attribution, so gating the + # broadcast on `counts` silently dropped their updates to peers (they + # only appeared on a full reload). Broadcast any gid that really moved. + touched = {gid: scene_groups[gid] for gid in changed + if gid in scene_groups and scene_groups[gid] != prev_groups.get(gid)} + removed = [gid for gid in deleted if gid in prev_groups] + if touched or removed: async_to_sync(get_channel_layer().group_send)( f"project_{p.pk}", {"type": "scene.delta", - "delta": {"changed": {gid: scene_groups[gid] - for gid in changed if gid in scene_groups}, - "deleted": list(deleted), + "delta": {"changed": touched, + "deleted": removed, "by": request.user.get_username()}}) if "fps" in data: p.fps = data["fps"] diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index df60675..561be78 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -95,6 +95,9 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px; .draw-tools button.active { background: var(--ink); } .draw-tools button:disabled { opacity: .4; cursor: default; } .draw-tools .draw-spacer { width: 8px; } +.draw-tools .draw-color { width: 22px; height: 20px; padding: 0; border: 1px solid var(--ink); + background: none; cursor: pointer; } +.draw-tools .draw-wid { width: 56px; } .draw-done { font-weight: bold; } .mark-draw { background: none; border: 1px solid transparent; border-radius: 0; font-size: 12px; cursor: pointer; padding: 0 3px; line-height: 1; } diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index f4f063c..71f90e0 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -151,49 +151,56 @@ (rf/reg-sub ::draw (fn [db] (get-in db [:view :draw]))) ; draw-mode state or nil (rf/reg-sub ::drawing :<- [::scene] (fn [scene [_ gid]] (get-in scene [:groups gid]))) -;; drawings to render over the video now: those bound to a mark whose span the -;; playhead is inside — the same playhead-in-mark hook that lights an annotation. +;; Bar precompute — the expensive part (resolving annotations into local bars) +;; depends only on scene/context/segments, NOT the playhead, so it's memoized here +;; and stays put while you scrub or play. The playhead-driven `active-*` subs below +;; then just do cheap interval tests. `bound` picks :notes or :drawings. +(defn- binding-bars [scene ctx segs bound] + (into [] + (mapcat + (fn [[gid g]] + ;; child annotations of the context OR the context annotation itself + ;; (pushing the owner onto the stack makes ctx that annotation) + (when (and (= :annotation (:type g)) (or (= ctx (:parent g)) (= ctx gid))) + (let [src-segs (scene/resolve scene gid) + by-mark (group-by :mark src-segs) + ->bars (fn [ss] (scene/merge-bars + (mapcat (fn [{[a b] :src}] (scene/pieces segs a b)) ss)))] + (concat + ;; annotation-level bindings (notes only; drawings bind per-mark) + (when-let [gs (seq (bound g))] [{:gids gs :bars (->bars src-segs)}]) + ;; per-mark bindings + (for [m (:marks g) :when (seq (bound m))] + {:gids (bound m) :bars (->bars (get by-mark (:id m)))})))))) + (:groups scene))) + +(rf/reg-sub ::drawing-bars + :<- [::scene] :<- [::context] :<- [::segments] + (fn [[scene ctx segs] _] (binding-bars scene ctx segs :drawings))) + +(rf/reg-sub ::note-bars + :<- [::scene] :<- [::context] :<- [::segments] + (fn [[scene ctx segs] _] (binding-bars scene ctx segs :notes))) + +(defn- gids-at [entries ph] + (persistent! + (reduce (fn [acc {:keys [gids bars]}] + (if (some (fn [[lo hi]] (<= lo ph hi)) bars) (reduce conj! acc gids) acc)) + (transient #{}) entries))) + +;; drawings to render over the video now (playhead in a bound mark's span). (rf/reg-sub ::active-drawings - :<- [::scene] :<- [::context] :<- [::segments] :<- [::playhead] - (fn [[scene ctx segs ph] _] - (let [in? (fn [bars] (some (fn [[lo hi]] (<= lo ph hi)) bars))] - (->> (:groups scene) - ;; child annotations of the context OR the context annotation itself - ;; (when you push the owner onto the stack, ctx IS that annotation) - (mapcat (fn [[gid g]] - (when (and (= :annotation (:type g)) (or (= ctx (:parent g)) (= ctx gid))) - (mapcat (fn [m] - (when (and (seq (:drawings m)) - (in? (scene/mark-bars scene gid (:id m) segs))) - (:drawings m))) - (:marks g))))) - distinct - (keep (fn [dg] (when-let [d (get-in scene [:groups dg])] - (when (= :drawing (:type d)) (assoc d :id dg))))) - vec)))) + :<- [::scene] :<- [::drawing-bars] :<- [::playhead] + (fn [[scene dbars ph] _] + (->> (gids-at dbars ph) + (keep (fn [dg] (when-let [d (get-in scene [:groups dg])] + (when (= :drawing (:type d)) (assoc d :id dg))))) + vec))) -;; the set of note gids whose bound span currently contains the playhead — drives -;; the >>> "we're in range" indicator. Annotation-level bindings light while the -;; playhead is in ANY of the annotation's bars; a mark-level binding lights only -;; while the playhead is in that specific mark's bars. +;; note gids whose bound span currently contains the playhead — drives the >>> +;; indicator (annotation-level lights on any bar; mark-level on that mark's bars). (rf/reg-sub ::active-note-set - :<- [::scene] :<- [::context] :<- [::segments] :<- [::playhead] - (fn [[scene ctx segs ph] _] - (let [in? (fn [bars] (some (fn [[lo hi]] (<= lo ph hi)) bars)) - bars (fn [src-segs] (scene/merge-bars - (mapcat (fn [{[a b] :src}] (scene/pieces segs a b)) src-segs)))] - (reduce - (fn [acc [gid g]] - (if (and (= :annotation (:type g)) (or (= ctx (:parent g)) (= ctx gid))) - (let [src-segs (scene/resolve scene gid) - acc (if (and (seq (:notes g)) (in? (bars src-segs))) - (into acc (:notes g)) acc)] - (reduce (fn [acc m] - (if (and (seq (:notes m)) - (in? (bars (filter #(= (:id m) (:mark %)) src-segs)))) - (into acc (:notes m)) acc)) - acc (:marks g))) - acc)) - #{} (:groups scene))))) + :<- [::note-bars] :<- [::playhead] + (fn [[nbars ph] _] (gids-at nbars ph))) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index a3ac915..65500e4 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -261,17 +261,21 @@ Editing state is local; only the committed strokes persist." [] (r/with-let [strokes (r/atom []) undo (r/atom []) cur (r/atom nil) - tool (r/atom :pencil) svg (atom nil) sess (atom nil)] + tool (r/atom :pencil) svg (atom nil) sess (atom nil) + pen-color (r/atom nil) pen-w (r/atom draw-width)] (let [draw @(rf/subscribe [::subs/draw]) drawing (when draw @(rf/subscribe [::subs/drawing (:gid draw)])) color (when draw (or (get-in @(rf/subscribe [::subs/scene]) [:groups (:ann draw) :color]) "#111")) actives @(rf/subscribe [::subs/active-drawings]) - tick @boil - variant (mod tick wiggle-variants)] - ;; seed local editing state at the start of a draw session + ;; only subscribe to the boil clock when there's actually something to + ;; animate — otherwise this component re-renders 7×/s for nothing + variant (if (or draw (seq actives)) (mod @boil wiggle-variants) 0)] + ;; seed local editing state at the start of a draw session — pen defaults to + ;; the annotation's colour (user can override for this session) (when (and draw (not= @sess (:gid draw))) (reset! sess (:gid draw)) (reset! strokes (vec (:strokes drawing))) - (reset! undo []) (reset! cur nil) (reset! tool :pencil)) + (reset! undo []) (reset! cur nil) (reset! tool :pencil) + (reset! pen-color color) (reset! pen-w draw-width)) (when (and (nil? draw) @sess) (reset! sess nil)) (let [push! #(swap! undo conj @strokes) erase-at (fn [xy] (when-let [i (first (keep-indexed (fn [i s] (when (near-stroke? xy s) i)) @strokes))] @@ -283,7 +287,7 @@ (let [xy (pointer-xy @svg e)] (.setPointerCapture (.-target e) (.-pointerId e)) (if (= @tool :eraser) (erase-at xy) - (reset! cur {:pts [xy] :color color :w draw-width}))))) + (reset! cur {:pts [xy] :color (or @pen-color color) :w @pen-w}))))) :on-pointer-move (when draw (fn [^js e] (let [xy (pointer-xy @svg e)] @@ -302,6 +306,11 @@ [:div.draw-tools [:button {:class (when (= @tool :pencil) "active") :title "Pencil" :on-click #(reset! tool :pencil)} "✏️"] [:button {:class (when (= @tool :eraser) "active") :title "Eraser (deletes a stroke)" :on-click #(reset! tool :eraser)} "🧽"] + [:input.draw-color {:type "color" :title "Pen colour (defaults to the annotation colour)" + :value (or @pen-color color "#111111") + :on-change #(reset! pen-color (.. % -target -value))}] + [:input.draw-wid {:type "range" :min 1 :max 8 :step 0.5 :title "Stroke size" + :value @pen-w :on-change #(reset! pen-w (js/parseFloat (.. % -target -value)))}] [:button {:title "Undo" :disabled (empty? @undo) :on-click #(when (seq @undo) (reset! strokes (peek @undo)) (swap! undo pop))} "↶"] [:span.draw-spacer]