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 []
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
:<- [::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))))
|
||||
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)))
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue