Skip to content

perf(export): blit GPU composite result directly to canvas#125

Merged
walterlow merged 1 commit intomainfrom
develop
Mar 20, 2026
Merged

perf(export): blit GPU composite result directly to canvas#125
walterlow merged 1 commit intomainfrom
develop

Conversation

@walterlow
Copy link
Copy Markdown
Owner

@walterlow walterlow commented Mar 20, 2026

Summary

  • Blit GPU composite result directly to canvas instead of readback, avoiding an extra texture-to-CPU-to-canvas copy

Test plan

  • Verify export produces correct output with GPU compositing enabled
  • Verify preview renders correctly with blend modes and masks

Summary by CodeRabbit

  • New Features
    • Added GPU-backed compositing with direct canvas presentation for faster, smoother final-frame rendering and live previews.
  • Improvements
    • Lazy creation and reuse of GPU output buffers and smarter fallback to CPU redraws when GPU presentation isn't available.
    • Final frames now render the actual composited source (GPU or CPU) consistently.
  • Bug Fixes
    • Improved resource cleanup to reduce memory/leak issues after compositing.

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
freecut Ready Ready Preview, Comment Mar 20, 2026 2:13pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 20, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 42100042-d15e-4805-865a-a6cdbe1c2f54

📥 Commits

Reviewing files that changed from the base of the PR and between 939d2f4 and bf0d5c3.

📒 Files selected for processing (2)
  • src/features/export/utils/client-render-engine.ts
  • src/lib/gpu-compositor/compositor-pipeline.ts

📝 Walkthrough

Walkthrough

Adds a GPU-backed compositing path that can render directly to an OffscreenCanvas via a new blit pass; falls back to Canvas2D per-track redraw when GPU canvas presentation isn't available. Introduces lazy GPU composite output lifecycle and cleans up new state on dispose.

Changes

Cohort / File(s) Summary
GPU Composite Output Management
src/features/export/utils/client-render-engine.ts
Adds persistent GPU composite canvas state and ensureGpuCompositeOutput(width,height). Introduces finalCompositeSource selection between contentCanvas and GPU OffscreenCanvas. Changes non-normal blend flow to prefer gpuCompositor.compositeToCanvas(...) and fall back to Canvas2D per-track redraw; retains per-item pooled canvases until GPU attempt completes; updates disposal to clear GPU composite state.
Blit Pipeline & Canvas Targeting
src/lib/gpu-compositor/compositor-pipeline.ts
Adds WGSL blit shader and blit pipeline resources (blitPipeline, blitLayout, bind-group caching) aware of canvasFormat. Clears cached bind groups on texture realloc and adds compositeToCanvas(layers, width, height, outputCtx): boolean to blit composited ping/pong texture into a GPU canvas context with safe fallback when unavailable or on failure.

Sequence Diagram(s)

sequenceDiagram
    participant Frame as Frame Renderer
    participant GPUComp as GPU Compositor
    participant BlitPass as Blit Render Pass
    participant Canvas as GPU Canvas (OffscreenCanvas)
    participant CPU as Canvas2D Fallback

    rect rgba(100,150,200,0.5)
    Note over Frame,Canvas: Preferred GPU composite path
    Frame->>GPUComp: compositeToTexture(layers)
    GPUComp-->>Frame: composited GPU texture (ping/pong)
    Frame->>BlitPass: compositeToCanvas(texture, outputCtx)
    BlitPass->>Canvas: render full-screen quad sampling texture
    Canvas-->>Frame: presentation succeeded
    end

    rect rgba(200,100,150,0.5)
    Note over Frame,CPU: Fallback Canvas2D path
    Frame->>GPUComp: compositeToCanvas(...) fails / unavailable
    Frame->>CPU: redraw per-task masked results with globalCompositeOperation
    CPU-->>Frame: Canvas2D compositing complete
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

codex

Poem

🐇 I hopped through pixels, swift and bright,

Blitted quads beneath the night,
If GPU sleeps, I softly trace,
Canvas2D returns my grace,
A rabbit's render — quick delight!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ⚠️ Warning The title focuses on a specific GPU export performance optimization (direct blit to canvas), which is one aspect of the changeset, but the PR encompasses many large features including mask/pen editing, keyframe dopesheet redesign, transcription, and various other improvements. Update the title to reflect the primary scope of the PR. Consider: 'feat: masks with pen editing, dopesheet redesign, transcription, and export perf' or 'feat: comprehensive editor and export improvements with mask editing and keyframes'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

… readback

Replace the costly GPU→CPU readback path (mapAsync + putImageData) with a
blit render pass that draws the composited texture directly onto a
GPUCanvasContext. This eliminates per-frame buffer mapping, row-stride
unpacking, and ImageData allocation during blend-mode compositing.

Add a dedicated blit shader and pipeline to CompositorPipeline with
compositeToCanvas(), and wire the render engine to use a persistent
OffscreenCanvas with a configured GPU context. Falls back to the
Canvas2D compositor path when GPU presentation isn't available.
@walterlow walterlow changed the title Masks, scopes, transcription, keyframes, and export perf perf(export): blit GPU composite result directly to canvas Mar 20, 2026
@chatgpt-codex-connector
Copy link
Copy Markdown

💡 Codex Review

isOpen &&
!!selectedItemForEditor &&
editorHotkeyProperty !== null &&

P1 Badge Restore ADD_KEYFRAME hotkey when panel is closed

The new HOTKEYS.ADD_KEYFRAME binding is enabled only when isOpen is true, while the prior global K handler was removed from use-editing-shortcuts.ts. This means pressing K now does nothing unless the keyframe panel is open, which regresses the documented global shortcut behavior for timeline editing workflows.


const { canvas: maskedItemCanvas, ctx: maskedItemCtx } = rctx.canvasPool.acquire();
try {
await renderItem(maskedItemCtx, subItem, subItemTransform, localFrame, subRctx);
applyMasks(subContentCtx, maskedItemCanvas, applicableMasks, subMaskSettings);

P2 Badge Resize masked sub-comp canvas before applying masks

The new per-item sub-comp masking path acquires maskedItemCanvas from the shared pool and immediately renders into it, but unlike subCanvas/subContentCanvas it is never resized to item.compositionWidth/Height. Pool canvases are parent-sized, so when a sub-composition is larger than the parent render canvas, masked sub-item pixels are clipped before compositing into the sub-comp output.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@walterlow walterlow merged commit 7b18542 into main Mar 20, 2026
3 checks passed
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 20, 2026

Greptile Summary

This is a large, well-structured feature PR that introduces scoped GPU-native masks, a full path editor, deferred-commit keyframe interactions, and a significant GPU rendering performance improvement by eliminating the readBuffer.mapAsync readback in the compositor.

Key changes across the five major areas:

  • Scoped mask renderingdoesMaskAffectTrack(maskOrder, itemOrder) enforces that masks only affect content on higher-order (visually lower) tracks. This is applied consistently in the React preview layer (getMasksForTrackOrder), the Canvas2D export sub-comp renderer, and the main GPU composite pipeline (applyTrackScopedMasks).
  • GPU blit pathCompositorPipeline.compositeToCanvas replaces the 5-step GPU→CPU readback (readBuffer.mapAsync + putImageData) with a single blit render pass to a pre-configured GPUCanvasContext. Bind-group caches are correctly invalidated on texture resize.
  • Path editorMaskEditorOverlay gains box-select marquee, shape-body drag (via GizmoStore), double-click edge insertion, bezier segment hit-testing with cubicPointAt, and external knot-conversion requests via request-version counters. Preview vertex overrides are propagated through applyPreviewPathVerticesToShape without mutating the timeline store.
  • Keyframe dopesheet — Per-property row controls (prev/next/toggle, value input, auto-key), inline frame inputs (local + global), deferred drag commits (single onKeyframeMove on pointer-up rather than every pointer-move). Same deferred pattern applied to use-graph-interaction.ts.
  • Preview area HUD — Control bar replaced by a context-sensitive HUD that surfaces pen-tool vs. path-edit state with action buttons; all side panels correctly locked during isEditing (not just penMode).

Minor issues found:

  • getMaskPath in canvas-masks.ts hardcodes trackOrder: 0 on its return value; callers always override this field, but the internal value is semantically incorrect and could silently break if used unmodified.
  • scheduleDragPreviewFrames in dopesheet-editor/index.tsx is a synchronous wrapper around applyDragPreviewFrames — the "schedule" name implies deferred/batched execution that isn't present.
  • The ResizeObserver effect for bodyTimelineRef lists rows.length as a dependency, causing unnecessary reconnect whenever property rows are added or removed.

Confidence Score: 4/5

  • Safe to merge — changes are well-scoped, covered by targeted tests, and the three issues found are minor code-quality concerns rather than runtime bugs.
  • The PR is a large but coherent set of features, each with accompanying tests. The mask scoping logic (doesMaskAffectTrack) is simple, pure, and verified by unit tests. The GPU blit path correctly invalidates bind-group caches on resize. The deferred-commit pattern for keyframe dragging is applied consistently in both the dopesheet and value-graph editors. No data loss or rendering correctness regressions were found. The score is 4 (not 5) because of the getMaskPath trackOrder: 0 issue that could silently produce incorrect masking if the function is ever used without the override, and the slightly confusing naming in the dopesheet that could mislead future contributors.
  • src/features/export/utils/canvas-masks.ts (getMaskPath trackOrder default) and src/features/keyframes/components/dopesheet-editor/index.tsx (ResizeObserver dependency and scheduleDragPreviewFrames naming).

Important Files Changed

Filename Overview
src/shared/utils/mask-scope.ts New utility: single pure function doesMaskAffectTrack(maskOrder, itemOrder) = maskOrder < itemOrder — cleanly encapsulates the scoped-masking convention used throughout the PR.
src/features/composition-runtime/utils/mask-info.ts Adds trackOrder to MaskInfo and introduces getMasksForTrackOrder which filters the active mask list for a given item's track order using doesMaskAffectTrack. Stable identity is preserved via reuseStableMaskInfos.
src/features/export/utils/canvas-masks.ts Threads trackOrder through MaskEntry, PreparedMask, buildMaskFrameIndex, and getActiveMasksForFrame. Minor concern: getMaskPath hardcodes trackOrder: 0 and relies on callers always overriding it.
src/features/export/utils/canvas-item-renderer.ts Sub-comp rendering refactored: masks pre-collected in a first pass with trackOrder, then each content item acquires its own canvas and receives only the masks whose trackOrder < track.order. Canvas pool correctly released in all paths.
src/features/export/utils/client-render-engine.ts GPU compositing path improved: eliminates GPU→CPU readback by blitting directly to a pre-configured OffscreenCanvas via compositeToCanvas; per-track scoped masking via applyTrackScopedMasks applied before GPU upload or Canvas2D accumulation.
src/lib/gpu-compositor/compositor-pipeline.ts Adds compositeToCanvas method: composites layers to a ping-pong texture then blits directly to a GPUCanvasContext with a fullscreen quad shader, eliminating the costly readBuffer.mapAsync readback path. Bind group caches correctly invalidated on size change.
src/features/preview/stores/mask-editor-store.ts Extended with multi-vertex selection (selectedVertexIndices), request-version counters for finish/cancel/convert operations, and shape pen mode — all cleanly reset on startEditing/stopEditing/cancelPenMode.
src/features/preview/components/mask-editor-overlay.tsx Major expansion: adds box-select marquee, shape-body drag (via GizmoStore translate), double-click edge insertion, bezier hit testing with cubicPointAt, segment highlight, and knot-conversion request handling. Cursor logic updated — body hover → 'move', vertex/segment hover → 'crosshair'.
src/features/editor/components/preview-area.tsx Control bar replaced with context-sensitive HUD: pen-tool mode shows path vertex count/hints, path-edit mode shows Corner/Bezier conversion buttons and Done; standard controls shown otherwise. Side panels locked during any mask editing (not just pen mode).
src/features/keyframes/components/dopesheet-editor/index.tsx Large refactor: per-property row controls (prev/next/toggle keyframe buttons, value input, auto-key toggle), deferred drag commits (preview-only during drag, single onKeyframeMove on pointer-up), header local/global frame inputs, and playhead rendered as an overlapping clipped line. Two style issues: ResizeObserver re-subscription on rows.length and scheduleDragPreviewFrames naming.
src/features/keyframes/components/value-graph-editor/use-graph-interaction.ts Deferred drag commit (same pattern as dopesheet): keyframe moves collected as previewValues during drag, committed in batch on pointerUp. Zoom now focuses on selected/visible keyframes via zoomFocusPoint; ensureKeyframesRemainVisible prevents panning away from content.
src/features/keyframes/property-value-ranges.ts Extracted PROPERTY_VALUE_RANGES from value-graph-editor/types.ts into a standalone module so both the dopesheet row value inputs and the graph editor share the same range/unit/decimal definitions.

Sequence Diagram

sequenceDiagram
    participant UI as PreviewArea / MaskEditorOverlay
    participant Store as MaskEditorStore
    participant ItemVS as useItemVisualState
    participant RT as CompositionContent / MainComposition
    participant RE as ClientRenderEngine
    participant GPU as CompositorPipeline

    Note over UI,GPU: Mask Pen Tool — create new path
    UI->>Store: startShapePenMode()
    UI->>UI: user clicks vertices on canvas
    UI->>Store: addPenVertex(v)
    UI->>Store: requestFinishPenMode()
    Store-->>UI: finishPenRequestVersion++
    UI->>UI: finishPenMode() → writes ShapeItem to ItemsStore

    Note over UI,GPU: Path Edit Mode — drag vertices
    UI->>Store: startEditing(itemId)
    UI->>Store: updatePreview(vertices)
    Store-->>ItemVS: previewVertices live update
    ItemVS->>ItemVS: applyPreviewPathVerticesToShape(shape, getPreviewPathVertices)
    ItemVS-->>RT: updated SVG clip-path in DOM

    Note over RT,GPU: Scoped mask rendering (preview)
    RT->>RT: resolveActiveShapeMasksAtFrame(masksWithTrackOrder, …)
    RT->>RT: getMasksForTrackOrder(allMasks, itemTrackOrder)
    RT-->>UI: only masks above item's track applied

    Note over RE,GPU: Export — GPU composite blit
    RE->>RE: applyTrackScopedMasks(result, trackOrder)
    RE->>GPU: compositeToCanvas(layers, w, h, gpuCtx)
    GPU->>GPU: compositeToTexture() — ping-pong blend
    GPU->>GPU: blit shader → GPUCanvasContext
    GPU-->>RE: OffscreenCanvas (no CPU readback)
    RE->>RE: ctx.drawImage(gpuCompositeOutput.canvas, 0, 0)
Loading

Comments Outside Diff (3)

  1. src/features/export/utils/canvas-masks.ts, line 96-98 (link)

    P2 getMaskPath hardcodes trackOrder: 0

    getMaskPath always returns trackOrder: 0 as part of the PreparedMask object, but every caller immediately overrides this field via object spread. The hardcoded value is semantically incorrect — it doesn't reflect the mask's actual track order — and would silently produce wrong masking behavior if any future code path calls getMaskPath directly without the override:

    // caller in getActiveMasksForFrame — trackOrder is always replaced:
    activeMasks.push({
      ...getMaskPath(mask.shape, mask.transform, canvas),
      trackOrder: mask.trackOrder,  // must override the stale 0
    });

    Consider removing trackOrder from getMaskPath's return type and having the caller construct the final object, or accepting trackOrder as a parameter:

    (Add trackOrder as a parameter to getMaskPath so the returned value is always correct.)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/features/export/utils/canvas-masks.ts
    Line: 96-98
    
    Comment:
    **`getMaskPath` hardcodes `trackOrder: 0`**
    
    `getMaskPath` always returns `trackOrder: 0` as part of the `PreparedMask` object, but every caller immediately overrides this field via object spread. The hardcoded value is semantically incorrect — it doesn't reflect the mask's actual track order — and would silently produce wrong masking behavior if any future code path calls `getMaskPath` directly without the override:
    
    ```ts
    // caller in getActiveMasksForFrame — trackOrder is always replaced:
    activeMasks.push({
      ...getMaskPath(mask.shape, mask.transform, canvas),
      trackOrder: mask.trackOrder,  // must override the stale 0
    });
    ```
    
    Consider removing `trackOrder` from `getMaskPath`'s return type and having the caller construct the final object, or accepting `trackOrder` as a parameter:
    
    
    
    (Add `trackOrder` as a parameter to `getMaskPath` so the returned value is always correct.)
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Codex

  2. src/features/keyframes/components/dopesheet-editor/index.tsx, line 2816-2828 (link)

    P2 ResizeObserver re-subscribes on every row count change

    The effect that observes bodyTimelineRef for width changes lists rows.length in its dependency array. This causes the observer to be torn down and re-subscribed every time a keyframe property row is added or removed — potentially causing a brief window where bodyTimelineWidth is stale (0) and keyframe positions are calculated against the full container width.

    Since bodyTimelineRef.current points to a stable DOM node across row-count changes, the dependency on rows.length is not needed. Using an empty dependency array (combined with an effect that runs after initial mount) or a ref-callback approach would be more stable:

    If there's a real reason for the current dependency (e.g., bodyTimelineRef.current genuinely changes identity when rows change), a comment explaining that would help future readers.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/features/keyframes/components/dopesheet-editor/index.tsx
    Line: 2816-2828
    
    Comment:
    **`ResizeObserver` re-subscribes on every row count change**
    
    The effect that observes `bodyTimelineRef` for width changes lists `rows.length` in its dependency array. This causes the observer to be torn down and re-subscribed every time a keyframe property row is added or removed — potentially causing a brief window where `bodyTimelineWidth` is stale (0) and keyframe positions are calculated against the full container width.
    
    Since `bodyTimelineRef.current` points to a stable DOM node across row-count changes, the dependency on `rows.length` is not needed. Using an empty dependency array (combined with an effect that runs after initial mount) or a ref-callback approach would be more stable:
    
    
    
    If there's a real reason for the current dependency (e.g., `bodyTimelineRef.current` genuinely changes identity when rows change), a comment explaining that would help future readers.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Codex

  3. src/features/keyframes/components/dopesheet-editor/index.tsx, line 2963-2967 (link)

    P2 scheduleDragPreviewFrames is a synchronous identity wrapper

    The name "schedule" strongly implies deferred or batched execution (e.g., a requestAnimationFrame call), but the implementation simply delegates directly to applyDragPreviewFrames:

    const scheduleDragPreviewFrames = useCallback(
      (nextPreviewFrames: Record<string, number> | null) => {
        applyDragPreviewFrames(nextPreviewFrames);
      },
      [applyDragPreviewFrames]
    );

    This makes call sites that read as "schedule for later" confusing — the update actually happens synchronously in the pointer event handler. Consider either renaming to updateDragPreviewFrames (or similar) to match the implementation, or actually deferring via RAF if batching is the intent.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/features/keyframes/components/dopesheet-editor/index.tsx
    Line: 2963-2967
    
    Comment:
    **`scheduleDragPreviewFrames` is a synchronous identity wrapper**
    
    The name "schedule" strongly implies deferred or batched execution (e.g., a `requestAnimationFrame` call), but the implementation simply delegates directly to `applyDragPreviewFrames`:
    
    ```ts
    const scheduleDragPreviewFrames = useCallback(
      (nextPreviewFrames: Record<string, number> | null) => {
        applyDragPreviewFrames(nextPreviewFrames);
      },
      [applyDragPreviewFrames]
    );
    ```
    
    This makes call sites that read as "schedule for later" confusing — the update actually happens synchronously in the pointer event handler. Consider either renaming to `updateDragPreviewFrames` (or similar) to match the implementation, or actually deferring via RAF if batching is the intent.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code Fix in Codex

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/features/export/utils/canvas-masks.ts
Line: 96-98

Comment:
**`getMaskPath` hardcodes `trackOrder: 0`**

`getMaskPath` always returns `trackOrder: 0` as part of the `PreparedMask` object, but every caller immediately overrides this field via object spread. The hardcoded value is semantically incorrect — it doesn't reflect the mask's actual track order — and would silently produce wrong masking behavior if any future code path calls `getMaskPath` directly without the override:

```ts
// caller in getActiveMasksForFrame — trackOrder is always replaced:
activeMasks.push({
  ...getMaskPath(mask.shape, mask.transform, canvas),
  trackOrder: mask.trackOrder,  // must override the stale 0
});
```

Consider removing `trackOrder` from `getMaskPath`'s return type and having the caller construct the final object, or accepting `trackOrder` as a parameter:

```suggestion
    inverted: mask.maskInvert ?? false,
    feather,
    maskType,
    trackOrder,
```

(Add `trackOrder` as a parameter to `getMaskPath` so the returned value is always correct.)

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/features/keyframes/components/dopesheet-editor/index.tsx
Line: 2816-2828

Comment:
**`ResizeObserver` re-subscribes on every row count change**

The effect that observes `bodyTimelineRef` for width changes lists `rows.length` in its dependency array. This causes the observer to be torn down and re-subscribed every time a keyframe property row is added or removed — potentially causing a brief window where `bodyTimelineWidth` is stale (0) and keyframe positions are calculated against the full container width.

Since `bodyTimelineRef.current` points to a stable DOM node across row-count changes, the dependency on `rows.length` is not needed. Using an empty dependency array (combined with an effect that runs after initial mount) or a ref-callback approach would be more stable:

```suggestion
  }, []);
```

If there's a real reason for the current dependency (e.g., `bodyTimelineRef.current` genuinely changes identity when rows change), a comment explaining that would help future readers.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/features/keyframes/components/dopesheet-editor/index.tsx
Line: 2963-2967

Comment:
**`scheduleDragPreviewFrames` is a synchronous identity wrapper**

The name "schedule" strongly implies deferred or batched execution (e.g., a `requestAnimationFrame` call), but the implementation simply delegates directly to `applyDragPreviewFrames`:

```ts
const scheduleDragPreviewFrames = useCallback(
  (nextPreviewFrames: Record<string, number> | null) => {
    applyDragPreviewFrames(nextPreviewFrames);
  },
  [applyDragPreviewFrames]
);
```

This makes call sites that read as "schedule for later" confusing — the update actually happens synchronously in the pointer event handler. Consider either renaming to `updateDragPreviewFrames` (or similar) to match the implementation, or actually deferring via RAF if batching is the intent.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "perf(export): blit G..."

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant