fix: live-sync drawings/notes; cut per-frame sub cost; pen color+size

- backend: the scene PUT only broadcast to peers when annotation counts
  changed, so drawing/script-note edits saved but never synced live (only
  after a reload). Broadcast any gid that actually moved, regardless of type.
- perf: active-drawings/active-note-set recomputed fully on every playhead
  tick (active-drawings even re-resolved each annotation per mark). Split the
  bar computation into memoized ::drawing-bars/::note-bars (scene/ctx/segs
  only) and reduce per-frame work to cheap interval tests.
- perf: only subscribe drawing-layer to the boil clock when something is
  actually animating (no more 7fps idle re-renders).
- drawings: pen colour picker + stroke-size slider, still defaulting to the
  annotation colour per session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-07-01 19:29:32 -04:00
parent 9f06b51f76
commit 40ff2ecf52
4 changed files with 80 additions and 54 deletions

View file

@ -143,7 +143,8 @@ def scene(request, pk):
deleted = data.get("deleted") or [] deleted = data.get("deleted") or []
summary = None summary = None
if changed or deleted: 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(): for gid, patch in changed.items():
if isinstance(patch, dict) and isinstance(groups.get(gid), dict): if isinstance(patch, dict) and isinstance(groups.get(gid), dict):
groups[gid] = {**groups[gid], **patch} groups[gid] = {**groups[gid], **patch}
@ -154,17 +155,23 @@ def scene(request, pk):
merged = dict(p.scene or {}, groups=groups) merged = dict(p.scene or {}, groups=groups)
p.scene, summary, counts = apply_attribution( p.scene, summary, counts = apply_attribution(
p.scene, merged, request.user.get_username(), timezone.now().isoformat()) 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, Revision.objects.create(project=p, user=request.user, summary=summary,
annotations=annotation_layer(p.scene)) annotations=annotation_layer(p.scene))
# push the attributed delta to peers viewing this project # Live sync must cover EVERY group kind, not just annotations: script
scene_groups = (p.scene or {}).get("groups", {}) # 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)( async_to_sync(get_channel_layer().group_send)(
f"project_{p.pk}", f"project_{p.pk}",
{"type": "scene.delta", {"type": "scene.delta",
"delta": {"changed": {gid: scene_groups[gid] "delta": {"changed": touched,
for gid in changed if gid in scene_groups}, "deleted": removed,
"deleted": list(deleted),
"by": request.user.get_username()}}) "by": request.user.get_username()}})
if "fps" in data: if "fps" in data:
p.fps = data["fps"] p.fps = data["fps"]

View file

@ -95,6 +95,9 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px;
.draw-tools button.active { background: var(--ink); } .draw-tools button.active { background: var(--ink); }
.draw-tools button:disabled { opacity: .4; cursor: default; } .draw-tools button:disabled { opacity: .4; cursor: default; }
.draw-tools .draw-spacer { width: 8px; } .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; } .draw-done { font-weight: bold; }
.mark-draw { background: none; border: 1px solid transparent; border-radius: 0; .mark-draw { background: none; border: 1px solid transparent; border-radius: 0;
font-size: 12px; cursor: pointer; padding: 0 3px; line-height: 1; } font-size: 12px; cursor: pointer; padding: 0 3px; line-height: 1; }

View file

@ -151,49 +151,56 @@
(rf/reg-sub ::draw (fn [db] (get-in db [:view :draw]))) ; draw-mode state or nil (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]))) (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 ;; Bar precompute — the expensive part (resolving annotations into local bars)
;; playhead is inside — the same playhead-in-mark hook that lights an annotation. ;; 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 (rf/reg-sub
::active-drawings ::active-drawings
:<- [::scene] :<- [::context] :<- [::segments] :<- [::playhead] :<- [::scene] :<- [::drawing-bars] :<- [::playhead]
(fn [[scene ctx segs ph] _] (fn [[scene dbars ph] _]
(let [in? (fn [bars] (some (fn [[lo hi]] (<= lo ph hi)) bars))] (->> (gids-at dbars ph)
(->> (: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])] (keep (fn [dg] (when-let [d (get-in scene [:groups dg])]
(when (= :drawing (:type d)) (assoc d :id dg))))) (when (= :drawing (:type d)) (assoc d :id dg)))))
vec)))) vec)))
;; the set of note gids whose bound span currently contains the playhead — drives ;; note gids whose bound span currently contains the playhead — drives the >>>
;; the >>> "we're in range" indicator. Annotation-level bindings light while the ;; indicator (annotation-level lights on any bar; mark-level on that mark's bars).
;; 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.
(rf/reg-sub (rf/reg-sub
::active-note-set ::active-note-set
:<- [::scene] :<- [::context] :<- [::segments] :<- [::playhead] :<- [::note-bars] :<- [::playhead]
(fn [[scene ctx segs ph] _] (fn [[nbars ph] _] (gids-at nbars 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)))))

View file

@ -261,17 +261,21 @@
Editing state is local; only the committed strokes persist." Editing state is local; only the committed strokes persist."
[] []
(r/with-let [strokes (r/atom []) undo (r/atom []) cur (r/atom nil) (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]) (let [draw @(rf/subscribe [::subs/draw])
drawing (when draw @(rf/subscribe [::subs/drawing (:gid 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")) color (when draw (or (get-in @(rf/subscribe [::subs/scene]) [:groups (:ann draw) :color]) "#111"))
actives @(rf/subscribe [::subs/active-drawings]) actives @(rf/subscribe [::subs/active-drawings])
tick @boil ;; only subscribe to the boil clock when there's actually something to
variant (mod tick wiggle-variants)] ;; animate — otherwise this component re-renders 7×/s for nothing
;; seed local editing state at the start of a draw session 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))) (when (and draw (not= @sess (:gid draw)))
(reset! sess (:gid draw)) (reset! strokes (vec (:strokes drawing))) (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)) (when (and (nil? draw) @sess) (reset! sess nil))
(let [push! #(swap! undo conj @strokes) (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))] 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)] (let [xy (pointer-xy @svg e)]
(.setPointerCapture (.-target e) (.-pointerId e)) (.setPointerCapture (.-target e) (.-pointerId e))
(if (= @tool :eraser) (erase-at xy) (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 :on-pointer-move
(when draw (fn [^js e] (when draw (fn [^js e]
(let [xy (pointer-xy @svg e)] (let [xy (pointer-xy @svg e)]
@ -302,6 +306,11 @@
[:div.draw-tools [:div.draw-tools
[:button {:class (when (= @tool :pencil) "active") :title "Pencil" :on-click #(reset! tool :pencil)} "✏️"] [: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)} "🧽"] [: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) [:button {:title "Undo" :disabled (empty? @undo)
:on-click #(when (seq @undo) (reset! strokes (peek @undo)) (swap! undo pop))} "↶"] :on-click #(when (seq @undo) (reset! strokes (peek @undo)) (swap! undo pop))} "↶"]
[:span.draw-spacer] [:span.draw-spacer]