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:
parent
9f06b51f76
commit
40ff2ecf52
4 changed files with 80 additions and 54 deletions
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
(keep (fn [dg] (when-let [d (get-in scene [:groups dg])]
|
||||||
;; child annotations of the context OR the context annotation itself
|
(when (= :drawing (:type d)) (assoc d :id dg)))))
|
||||||
;; (when you push the owner onto the stack, ctx IS that annotation)
|
vec)))
|
||||||
(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))))
|
|
||||||
|
|
||||||
;; 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)))))
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue