Skip to content

Conversation

@jtydhr88
Copy link
Collaborator

@jtydhr88 jtydhr88 commented Nov 21, 2025

re-create a PR to trigger Claude Review for #6767

┆Issue is synchronized with this Notion page by Unito

Summary by CodeRabbit

New Features

  • GPU-powered brush rendering engine delivers significantly improved drawing performance and responsiveness

Improvements

  • Increased maximum brush thickness from 100 to 500 pixels for greater flexibility
  • Replaced smoothing precision setting with stepsize control for finer stroke adjustments
  • Enhanced brush gradient rendering and hardness calculations

Chores

  • Added WebGPU and TypeGPU runtime dependencies

✏️ Tip: You can customize this high-level summary in your review settings.

@dosubot dosubot bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Nov 21, 2025
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 21, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

The PR introduces GPU-accelerated brush rendering via WebGPU to the mask editor. New dependencies (TypeGPU, WebGPU types) and configuration enable GPU pipelines (shaders, renderer). Stroke processing gains equidistant point resampling using Catmull-Rom spline interpolation. Brush controls shift from smoothing precision to step size. Canvas history now supports ImageBitmap for efficient GPU texture transfers. Multiple test suites validate new stroke and brush utilities.

Changes

Cohort / File(s) Summary
Package & Config Setup
package.json, pnpm-workspace.yaml, tsconfig.json, vite.config.mts
Added WebGPU and TypeGPU dependencies; registered typegpuPlugin in Vite config; extended TypeScript types to include WebGPU globals.
GPU Infrastructure
src/composables/maskeditor/gpu/GPUBrushRenderer.ts, src/composables/maskeditor/gpu/brushShaders.ts, src/composables/maskeditor/gpu/gpuSchema.ts
Introduced complete GPU rendering pipeline: GPUBrushRenderer class with shader pipelines (render, accumulate, composite, erase, blit, readback), texture management, and per-stroke rendering; shader definitions for vertex/fragment/compute stages; schema structs for BrushUniforms and StrokePoint.
Stroke Processing & Math Utilities
src/composables/maskeditor/StrokeProcessor.ts, src/composables/maskeditor/splineUtils.ts, src/composables/maskeditor/brushUtils.ts
Added StrokeProcessor for equidistant point generation via Catmull-Rom splines; exported spline and resampling utilities (catmullRomSpline, resampleSegment); introduced effective brush size/hardness calculations for adaptive brush rendering.
Drawing & Canvas History
src/composables/maskeditor/useBrushDrawing.ts, src/composables/maskeditor/useCanvasHistory.ts
Expanded useBrushDrawing with GPU initialization, resource management, stroke accumulation, and CPU/GPU synchronization; extended useCanvasHistory to support ImageBitmap storage alongside ImageData for efficient texture transfers.
Type & Store Updates
src/extensions/core/maskeditor/types.ts, src/stores/maskEditorStore.ts
Replaced brushSettings.smoothingPrecision with stepSize; expanded brush size limit to 500; added GPU root ref and clear trigger mechanism; renamed setBrushSmoothingPrecision → setBrushStepSize.
Component Updates
src/components/maskeditor/BrushCursor.vue, src/components/maskeditor/BrushSettingsPanel.vue, src/components/maskeditor/MaskEditorContent.vue, src/components/maskeditor/dialog/TopBarHeader.vue
Updated BrushCursor gradient to use effective brush size/hardness; changed BrushSettingsPanel thickness max to 500 and replaced smoothing precision UI with step size control; added GPU canvas init/cleanup in MaskEditorContent; added clear trigger in TopBarHeader.
Test Suites
src/composables/maskeditor/StrokeProcessor.test.ts, src/composables/maskeditor/ShiftClick.test.ts, src/composables/maskeditor/brushUtils.test.ts, tests-ui/tests/composables/maskeditor/useCanvasHistory.test.ts
Validated StrokeProcessor equidistant point generation, ShiftClick resampling across segments, brush utility functions (effective size/hardness), and canvas history with ImageBitmap support.

Sequence Diagram(s)

sequenceDiagram
    participant User as User Input
    participant UI as UI (Component)
    participant Draw as useBrushDrawing
    participant GPU as GPUBrushRenderer
    participant Canvas as CPU Canvas
    participant History as Canvas History
    
    User->>UI: Start drawing stroke
    UI->>Draw: startDrawing()
    Draw->>Draw: Initialize StrokeProcessor
    
    loop For each mouse move
        User->>UI: Mouse position
        UI->>Draw: handleDrawing(point)
        Draw->>Draw: StrokeProcessor.addPoint(point)
        Draw->>Draw: resampleSegment(Catmull-Rom)
        
        alt GPU Path
            Draw->>GPU: gpuDrawPoint(points)
            GPU->>GPU: renderStrokeToAccumulator()
        else CPU Path (fallback)
            Draw->>Canvas: Direct canvas rendering
        end
    end
    
    User->>UI: End drawing
    UI->>Draw: drawEnd()
    Draw->>GPU: compositeStroke(targetView)
    GPU->>GPU: Copy accum texture → main texture
    Draw->>Draw: updateGPUFromCanvas()
    Draw->>History: saveState(ImageBitmap)
    History->>History: Store or replace state
    
    Note over Draw,History: On undo/redo
    History->>Draw: restoreState(ImageBitmap)
    Draw->>GPU: copyGpuToCanvas()
    GPU->>Canvas: readback → blit to canvas
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

  • GPUBrushRenderer.ts and brushShaders.ts: Dense GPU/WebGPU pipeline logic with multiple shaders, bind groups, and texture management—verify correctness of render passes, uniform updates, and resource lifecycle.
  • StrokeProcessor.ts and splineUtils.ts: Math-heavy resampling and spline interpolation; validate point distribution, remainder propagation, and edge cases (collinear points, segment boundaries).
  • useBrushDrawing.ts: Large composable with intricate GPU/CPU sync, state management, and dirty-rect tracking; check initialization order, resource cleanup, and canvas coherence.
  • useCanvasHistory.ts: Type broadening to ImageBitmap; verify that bitmap closure logic prevents leaks and that restore paths handle both ImageData and ImageBitmap correctly.
  • Integration points: Ensure GPU canvas init/teardown in MaskEditorContent, store clear trigger in TopBarHeader, and brush setting rename propagation across components and store.

Poem

🐰 A brush stroke now rides the GPU's speed,
Where textures bloom and shaders lead,
Catmull-Rom curves dance through the night,
Equidistant points drawn just right!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description does not follow the required template and lacks essential information about changes, dependencies, and design decisions. Replace the description with the required template structure: include a Summary section explaining what changed and why, a Changes section detailing core functionality and dependencies (particularly WebGPU integration), and a Review Focus section highlighting critical design decisions for GPU-based rendering.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Web gpu maskeditor rendering' accurately describes the main focus of the changeset, which introduces WebGPU-based GPU rendering for the mask editor.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch webGPU-maskeditor-rendering

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


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

@github-actions
Copy link

github-actions bot commented Nov 21, 2025

🎨 Storybook Build Status

Build completed successfully!

⏰ Completed at: 11/21/2025, 08:38:58 PM UTC

🔗 Links


🎉 Your Storybook is ready for review!

@jtydhr88 jtydhr88 added the claude-review Add to trigger a PR code review from Claude Code label Nov 21, 2025
@github-actions
Copy link

github-actions bot commented Nov 21, 2025

🎭 Playwright Test Results

⚠️ Tests passed with flaky tests

⏰ Completed at: 11/21/2025, 08:48:48 PM UTC

📈 Summary

  • Total Tests: 496
  • Passed: 484 ✅
  • Failed: 0
  • Flaky: 3 ⚠️
  • Skipped: 9 ⏭️

📊 Test Reports by Browser

  • chromium: View Report • ✅ 475 / ❌ 0 / ⚠️ 3 / ⏭️ 9
  • chromium-2x: View Report • ✅ 2 / ❌ 0 / ⚠️ 0 / ⏭️ 0
  • chromium-0.5x: View Report • ✅ 1 / ❌ 0 / ⚠️ 0 / ⏭️ 0
  • mobile-chrome: View Report • ✅ 6 / ❌ 0 / ⚠️ 0 / ⏭️ 0

🎉 Click on the links above to view detailed test results for each browser configuration.

@socket-security
Copy link

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​webgpu/​types@​0.1.51 ⏵ 0.1.66100 +1100100 +192 -1100

View full report

@github-actions
Copy link

Bundle Size Report

Summary

  • Raw size: 14.2 MB baseline 13.7 MB — 🔴 +418 kB
  • Gzip: 2.84 MB baseline 2.76 MB — 🔴 +80.5 kB
  • Brotli: 2.23 MB baseline 2.16 MB — 🔴 +62.1 kB
  • Bundles: 92 current • 92 baseline • 43 added / 43 removed

Category Glance
Vendor & Third-Party 🔴 +374 kB (5.7 MB) · App Entry Points 🔴 +44 kB (3.18 MB) · Other ⚪ 0 B (3.87 MB) · Graph Workspace ⚪ 0 B (945 kB) · Panels & Settings ⚪ 0 B (306 kB) · UI Components ⚪ 0 B (141 kB) · + 3 more

Per-category breakdown
App Entry Points — 3.18 MB (baseline 3.13 MB) • 🔴 +44 kB

Main entry bundles and manifests

File Before After Δ Raw Δ Gzip Δ Brotli
assets/index-BbR_fJTy.js (removed) 2.77 MB 🟢 -2.77 MB 🟢 -578 kB 🟢 -440 kB
assets/index-CmhJVUnK.js (new) 2.77 MB 🔴 +2.77 MB 🔴 +578 kB 🔴 +440 kB
assets/index-C55I7HeO.js (new) 408 kB 🔴 +408 kB 🔴 +83.7 kB 🔴 +68.2 kB
assets/index-bOmVPMTE.js (removed) 364 kB 🟢 -364 kB 🟢 -75.2 kB 🟢 -61.4 kB
assets/index-DvdrFyIX.js (removed) 345 B 🟢 -345 B 🟢 -244 B 🟢 -202 B
assets/index-WDKAp6C1.js (new) 345 B 🔴 +345 B 🔴 +246 B 🔴 +230 B

Status: 3 added / 3 removed

Graph Workspace — 945 kB (baseline 945 kB) • ⚪ 0 B

Graph editor runtime, canvas, workflow orchestration

File Before After Δ Raw Δ Gzip Δ Brotli
assets/GraphView-Chd4AFl_.js (removed) 945 kB 🟢 -945 kB 🟢 -183 kB 🟢 -140 kB
assets/GraphView-Dq8SggWE.js (new) 945 kB 🔴 +945 kB 🔴 +183 kB 🔴 +140 kB

Status: 1 added / 1 removed

Views & Navigation — 7.97 kB (baseline 7.97 kB) • ⚪ 0 B

Top-level views, pages, and routed surfaces

File Before After Δ Raw Δ Gzip Δ Brotli
assets/UserSelectView-COXO2z2o.js (removed) 7.97 kB 🟢 -7.97 kB 🟢 -2.43 kB 🟢 -2.13 kB
assets/UserSelectView-DsvbLjui.js (new) 7.97 kB 🔴 +7.97 kB 🔴 +2.43 kB 🔴 +2.14 kB

Status: 1 added / 1 removed

Panels & Settings — 306 kB (baseline 306 kB) • ⚪ 0 B

Configuration panels, inspectors, and settings screens

File Before After Δ Raw Δ Gzip Δ Brotli
assets/CreditsPanel-DWzsQ7Yh.js (new) 22.9 kB 🔴 +22.9 kB 🔴 +5.46 kB 🔴 +4.78 kB
assets/CreditsPanel-yLpKByEJ.js (removed) 22.9 kB 🟢 -22.9 kB 🟢 -5.46 kB 🟢 -4.79 kB
assets/KeybindingPanel-CEhrZE9k.js (removed) 15.1 kB 🟢 -15.1 kB 🟢 -3.73 kB 🟢 -3.29 kB
assets/KeybindingPanel-wRgQtB4T.js (new) 15.1 kB 🔴 +15.1 kB 🔴 +3.73 kB 🔴 +3.3 kB
assets/ExtensionPanel-Bvhm9Kty.js (new) 11.9 kB 🔴 +11.9 kB 🔴 +2.79 kB 🔴 +2.45 kB
assets/ExtensionPanel-D_LW6rI0.js (removed) 11.9 kB 🟢 -11.9 kB 🟢 -2.79 kB 🟢 -2.45 kB
assets/AboutPanel-Bw0_HOC1.js (new) 10.1 kB 🔴 +10.1 kB 🔴 +2.62 kB 🔴 +2.31 kB
assets/AboutPanel-Cl_GfZaP.js (removed) 10.1 kB 🟢 -10.1 kB 🟢 -2.62 kB 🟢 -2.31 kB
assets/ServerConfigPanel-cqI78NLZ.js (removed) 8.02 kB 🟢 -8.02 kB 🟢 -2.12 kB 🟢 -1.88 kB
assets/ServerConfigPanel-DUc-Dd3-.js (new) 8.02 kB 🔴 +8.02 kB 🔴 +2.12 kB 🔴 +1.88 kB
assets/UserPanel-CoCm3rel.js (new) 7.74 kB 🔴 +7.74 kB 🔴 +2.02 kB 🔴 +1.77 kB
assets/UserPanel-zWxYp9j5.js (removed) 7.74 kB 🟢 -7.74 kB 🟢 -2.03 kB 🟢 -1.77 kB
assets/settings-BXTtSH4O.js 33.3 kB 33.3 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-C9Pzn-NG.js 25.2 kB 25.2 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-CCy2fA_h.js 27.3 kB 27.3 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-CQpqEFfl.js 26.6 kB 26.6 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-DHcnxypw.js 21.7 kB 21.7 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-DhFTK9fY.js 25.1 kB 25.1 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-DlT4t_ui.js 25.9 kB 25.9 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-DRgSrIdD.js 24.2 kB 24.2 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/settings-tjkeqiZq.js 21.1 kB 21.1 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 6 added / 6 removed

UI Components — 141 kB (baseline 141 kB) • ⚪ 0 B

Reusable component library chunks

File Before After Δ Raw Δ Gzip Δ Brotli
assets/Load3D.vue_vue_type_script_setup_true_lang-C_m2PBIu.js (new) 53.9 kB 🔴 +53.9 kB 🔴 +8.43 kB 🔴 +7.23 kB
assets/Load3D.vue_vue_type_script_setup_true_lang-rJBVPrzE.js (removed) 53.9 kB 🟢 -53.9 kB 🟢 -8.43 kB 🟢 -7.23 kB
assets/WidgetSelect.vue_vue_type_script_setup_true_lang-CfUokXvm.js (removed) 48.1 kB 🟢 -48.1 kB 🟢 -10.3 kB 🟢 -8.96 kB
assets/WidgetSelect.vue_vue_type_script_setup_true_lang-D9aUjdwk.js (new) 48.1 kB 🔴 +48.1 kB 🔴 +10.3 kB 🔴 +8.93 kB
assets/WidgetInputNumber.vue_vue_type_script_setup_true_lang-Bz0UasZv.js (new) 12.7 kB 🔴 +12.7 kB 🔴 +3.3 kB 🔴 +2.92 kB
assets/WidgetInputNumber.vue_vue_type_script_setup_true_lang-USfR3vGy.js (removed) 12.7 kB 🟢 -12.7 kB 🟢 -3.3 kB 🟢 -2.92 kB
assets/ComfyQueueButton-B9pCrSmR.js (new) 9.22 kB 🔴 +9.22 kB 🔴 +2.5 kB 🔴 +2.2 kB
assets/ComfyQueueButton-CO5gSuMT.js (removed) 9.22 kB 🟢 -9.22 kB 🟢 -2.5 kB 🟢 -2.2 kB
assets/WidgetLayoutField.vue_vue_type_script_setup_true_lang-C7ONxcko.js (removed) 2.14 kB 🟢 -2.14 kB 🟢 -795 B 🟢 -695 B
assets/WidgetLayoutField.vue_vue_type_script_setup_true_lang-QgKwu8z2.js (new) 2.14 kB 🔴 +2.14 kB 🔴 +795 B 🔴 +694 B
assets/MediaTitle.vue_vue_type_script_setup_true_lang-CWcNz601.js (new) 848 B 🔴 +848 B 🔴 +476 B 🔴 +416 B
assets/MediaTitle.vue_vue_type_script_setup_true_lang-Dtvav2K4.js (removed) 848 B 🟢 -848 B 🟢 -475 B 🟢 -413 B
assets/LazyImage.vue_vue_type_script_setup_true_lang-Wi-CcgaU.js 10.7 kB 10.7 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/UserAvatar.vue_vue_type_script_setup_true_lang-D2s8tnS2.js 1.26 kB 1.26 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/WidgetButton-ByrPd5jr.js 1.62 kB 1.62 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 6 added / 6 removed

Data & Services — 12.5 kB (baseline 12.5 kB) • ⚪ 0 B

Stores, services, APIs, and repositories

File Before After Δ Raw Δ Gzip Δ Brotli
assets/keybindingService-CdB4mzEE.js (new) 7.51 kB 🔴 +7.51 kB 🔴 +1.83 kB 🔴 +1.58 kB
assets/keybindingService-tGvMqxzD.js (removed) 7.51 kB 🟢 -7.51 kB 🟢 -1.83 kB 🟢 -1.58 kB
assets/serverConfigStore-B8VVfOcG.js (new) 2.79 kB 🔴 +2.79 kB 🔴 +890 B 🔴 +780 B
assets/serverConfigStore-BoHtzifw.js (removed) 2.79 kB 🟢 -2.79 kB 🟢 -886 B 🟢 -780 B
assets/audioService-CWBSb7Vt.js (removed) 2.2 kB 🟢 -2.2 kB 🟢 -960 B 🟢 -819 B
assets/audioService-DUB2kq3P.js (new) 2.2 kB 🔴 +2.2 kB 🔴 +960 B 🔴 +823 B

Status: 3 added / 3 removed

Utilities & Hooks — 2.94 kB (baseline 2.94 kB) • ⚪ 0 B

Helpers, composables, and utility bundles

File Before After Δ Raw Δ Gzip Δ Brotli
assets/audioUtils-B3laTuMG.js (removed) 1.41 kB 🟢 -1.41 kB 🟢 -650 B 🟢 -550 B
assets/audioUtils-DwBVOdVV.js (new) 1.41 kB 🔴 +1.41 kB 🔴 +651 B 🔴 +549 B
assets/mathUtil-CTARWQ-l.js 1.07 kB 1.07 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeFilterUtil-CXKCRJ-m.js 460 B 460 B ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 1 added / 1 removed

Vendor & Third-Party — 5.7 MB (baseline 5.32 MB) • 🔴 +374 kB

External libraries and shared vendor chunks

File Before After Δ Raw Δ Gzip Δ Brotli
assets/vendor-other-BccPILzc.js (new) 3.59 MB 🔴 +3.59 MB 🔴 +757 kB 🔴 +605 kB
assets/vendor-other-z0ajCJVX.js (removed) 3.22 MB 🟢 -3.22 MB 🟢 -685 kB 🟢 -549 kB
assets/vendor-tiptap-CmlTruXJ.js (new) 232 kB 🔴 +232 kB 🔴 +45.7 kB 🔴 +37.7 kB
assets/vendor-tiptap-D2zb6Fg1.js (removed) 232 kB 🟢 -232 kB 🟢 -45.7 kB 🟢 -37.7 kB
assets/vendor-vue-aBQ_uOio.js (removed) 92.6 kB 🟢 -92.6 kB 🟢 -23.9 kB 🟢 -20.8 kB
assets/vendor-vue-CIBLfoY2.js (new) 92.6 kB 🔴 +92.6 kB 🔴 +23.9 kB 🔴 +20.8 kB
assets/vendor-primevue-PESgPnbc.js 517 B 517 B ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-three-aR6ntw5X.js 1.37 MB 1.37 MB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/vendor-xterm-BZLod3g9.js 407 kB 407 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 3 added / 3 removed

Other — 3.87 MB (baseline 3.87 MB) • ⚪ 0 B

Bundles that do not match a named category

File Before After Δ Raw Δ Gzip Δ Brotli
assets/WidgetRecordAudio-C2DqU4_U.js (removed) 21.9 kB 🟢 -21.9 kB 🟢 -5.52 kB 🟢 -4.87 kB
assets/WidgetRecordAudio-CEf3AZVo.js (new) 21.9 kB 🔴 +21.9 kB 🔴 +5.52 kB 🔴 +4.88 kB
assets/AudioPreviewPlayer-DgDaZV6y.js (removed) 14.9 kB 🟢 -14.9 kB 🟢 -3.69 kB 🟢 -3.29 kB
assets/AudioPreviewPlayer-DSSKqVGX.js (new) 14.9 kB 🔴 +14.9 kB 🔴 +3.69 kB 🔴 +3.3 kB
assets/WidgetGalleria-BYez8HQB.js (removed) 5.56 kB 🟢 -5.56 kB 🟢 -1.75 kB 🟢 -1.54 kB
assets/WidgetGalleria-E7gUUHAf.js (new) 5.56 kB 🔴 +5.56 kB 🔴 +1.74 kB 🔴 +1.54 kB
assets/WidgetColorPicker--6CsF_mR.js (new) 4.87 kB 🔴 +4.87 kB 🔴 +1.69 kB 🔴 +1.48 kB
assets/WidgetColorPicker-uNA3JldQ.js (removed) 4.87 kB 🟢 -4.87 kB 🟢 -1.69 kB 🟢 -1.48 kB
assets/WidgetMarkdown-DNONs9-L.js (new) 4.6 kB 🔴 +4.6 kB 🔴 +1.6 kB 🔴 +1.39 kB
assets/WidgetMarkdown-j1OWXnXF.js (removed) 4.6 kB 🟢 -4.6 kB 🟢 -1.6 kB 🟢 -1.4 kB
assets/WidgetAudioUI-BWMPNYIC.js (removed) 4.33 kB 🟢 -4.33 kB 🟢 -1.44 kB 🟢 -1.28 kB
assets/WidgetAudioUI-C51VkObT.js (new) 4.33 kB 🔴 +4.33 kB 🔴 +1.44 kB 🔴 +1.29 kB
assets/WidgetTextarea-BdkN9uLd.js (removed) 3.72 kB 🟢 -3.72 kB 🟢 -1.3 kB 🟢 -1.14 kB
assets/WidgetTextarea-DmiAtxOl.js (new) 3.72 kB 🔴 +3.72 kB 🔴 +1.3 kB 🔴 +1.15 kB
assets/WidgetInputText-DEf5r33W.js (removed) 3.45 kB 🟢 -3.45 kB 🟢 -1.23 kB 🟢 -1.09 kB
assets/WidgetInputText-DrCtpCsa.js (new) 3.45 kB 🔴 +3.45 kB 🔴 +1.23 kB 🔴 +1.09 kB
assets/WidgetToggleSwitch-BsdCQvvO.js (removed) 3.23 kB 🟢 -3.23 kB 🟢 -1.14 kB 🟢 -1.01 kB
assets/WidgetToggleSwitch-C8VwKImN.js (new) 3.23 kB 🔴 +3.23 kB 🔴 +1.14 kB 🔴 +1.01 kB
assets/MediaImageBottom-DKYEdjci.js (new) 3.05 kB 🔴 +3.05 kB 🔴 +1.05 kB 🔴 +914 B
assets/MediaImageBottom-pnUwaBVv.js (removed) 3.05 kB 🟢 -3.05 kB 🟢 -1.05 kB 🟢 -907 B
assets/MediaAudioBottom-BIV5wQN5.js (new) 3 kB 🔴 +3 kB 🔴 +1.05 kB 🔴 +920 B
assets/MediaAudioBottom-D7sHxyvy.js (removed) 3 kB 🟢 -3 kB 🟢 -1.05 kB 🟢 -927 B
assets/Media3DTop-Bets-6AV.js (removed) 3 kB 🟢 -3 kB 🟢 -1.08 kB 🟢 -924 B
assets/Media3DTop-BtQB5aX7.js (new) 3 kB 🔴 +3 kB 🔴 +1.08 kB 🔴 +912 B
assets/MediaVideoBottom-C6ZKf9TW.js (removed) 3 kB 🟢 -3 kB 🟢 -1.05 kB 🟢 -924 B
assets/MediaVideoBottom-yyOPfNEX.js (new) 3 kB 🔴 +3 kB 🔴 +1.04 kB 🔴 +920 B
assets/Media3DBottom-BsaWEu_w.js (removed) 2.98 kB 🟢 -2.98 kB 🟢 -1.04 kB 🟢 -909 B
assets/Media3DBottom-YMIRRj16.js (new) 2.98 kB 🔴 +2.98 kB 🔴 +1.04 kB 🔴 +913 B
assets/WidgetSelect-DEG6g_TG.js (removed) 2.17 kB 🟢 -2.17 kB 🟢 -674 B 🟢 -577 B
assets/WidgetSelect-DXXRMZ8Z.js (new) 2.17 kB 🔴 +2.17 kB 🔴 +674 B 🔴 +569 B
assets/WidgetInputNumber-B3tAs_eg.js (new) 2.12 kB 🔴 +2.12 kB 🔴 +662 B 🔴 +557 B
assets/WidgetInputNumber-Igtq8ttN.js (removed) 2.12 kB 🟢 -2.12 kB 🟢 -662 B 🟢 -552 B
assets/Load3D-B0M77pv_.js (new) 1.94 kB 🔴 +1.94 kB 🔴 +595 B 🔴 +499 B
assets/Load3D-BkJZEHA0.js (removed) 1.94 kB 🟢 -1.94 kB 🟢 -598 B 🟢 -536 B
assets/WidgetLegacy-Cdwa6NEq.js (new) 1.88 kB 🔴 +1.88 kB 🔴 +560 B 🔴 +472 B
assets/WidgetLegacy-Dn7PmlP6.js (removed) 1.88 kB 🟢 -1.88 kB 🟢 -561 B 🟢 -507 B
assets/MediaImageTop-BEdbKgBt.js (new) 1.68 kB 🔴 +1.68 kB 🔴 +802 B 🔴 +680 B
assets/MediaImageTop-Cxl4dc80.js (removed) 1.68 kB 🟢 -1.68 kB 🟢 -803 B 🟢 -685 B
assets/commands-_s-RvhJR.js 13.6 kB 13.6 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-BuUILW6P.js 13 kB 13 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-BV4R6fLx.js 14.9 kB 14.9 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-CLwPdnT6.js 14.2 kB 14.2 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-CWMchBmd.js 15.9 kB 15.9 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-DazTQhtc.js 12.9 kB 12.9 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-DmWrOe93.js 13.7 kB 13.7 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-DwiH7Kr6.js 13.8 kB 13.8 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/commands-mS3LCNPn.js 14.5 kB 14.5 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-5lOBdqcC.js 84.5 kB 84.5 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-BOCuaVpE.js 73.4 kB 73.4 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-ClrEFGUz.js 72.4 kB 72.4 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-CyNU0iQX.js 99.3 kB 99.3 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-D7gwLxft.js 114 kB 114 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-DC8o4BCt.js 86.8 kB 86.8 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-DKiesCV4.js 94.3 kB 94.3 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-Hq2q-OtB.js 83.6 kB 83.6 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/main-USAlAlnj.js 82 kB 82 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/MediaAudioTop-RTI8pWy9.js 1.42 kB 1.42 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/MediaVideoTop-BB0lT7C5.js 2.7 kB 2.7 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-_Px5dSNW.js 306 kB 306 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-7z21KPoS.js 285 kB 285 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-BWKZzBPK.js 346 kB 346 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-CGbgH4Yl.js 320 kB 320 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-CjjjdWkV.js 313 kB 313 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-CVrNtxvj.js 288 kB 288 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-DLRSA0IK.js 309 kB 309 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-DQV2gnwA.js 372 kB 372 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/nodeDefs-ofqLG5vz.js 310 kB 310 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/WidgetChart-rDmYEWg5.js 2.39 kB 2.39 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/WidgetImageCompare-Ds3K3ULR.js 2.15 kB 2.15 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B
assets/widgetPropFilter-BIbGSUAt.js 1.28 kB 1.28 kB ⚪ 0 B ⚪ 0 B ⚪ 0 B

Status: 19 added / 19 removed

if (store.tgpuRoot) return

try {
const root = await TGPU.init()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[security] high Priority

Issue: Missing WebGPU feature capability checking before device usage
Context: Direct GPU device initialization without checking if WebGPU is supported, which can cause runtime errors on unsupported browsers/devices
Suggestion: Add feature detection with graceful fallback: if (!navigator.gpu) { /* fallback to canvas */ } before TGPU.init()

this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)

// 2. Batch Instance Data
const batchSize = Math.min(points.length, MAX_STROKES)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance] high Priority

Issue: Missing GPU resource bounds checking for MAX_STROKES limit
Context: StrokeProcessor and GPUBrushRenderer can exceed MAX_STROKES=10000 limit without validation, potentially causing buffer overflow or silent truncation
Suggestion: Add validation in renderStrokeInternal: if (points.length > MAX_STROKES) { /* batch process or warn */ }

let a = color.a;
if (a > 0.0) {
r = r / a;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[quality] medium Priority

Issue: Potential division by zero in shader readback
Context: Alpha unpremultiplication 'r = r / a' can cause undefined behavior when alpha is very small but not exactly zero
Suggestion: Add epsilon check: if (a > 0.001) { r = r / a; } else { r = 0.0; } to prevent numerical instability

? this.erasePipeline
: this.compositePipeline

const bindGroup0 = this.device.createBindGroup({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance] high Priority

Issue: Missing cleanup and memory leak for GPU bind groups
Context: bind groups are created repeatedly in compositeStroke, blitToCanvas without tracking or cleanup, causing GPU memory accumulation
Suggestion: Cache bind groups by key or implement proper cleanup in destroy() method to prevent GPU memory leaks

drawShape(point, interpolatedOpacity)
if (renderer && compositionOp === CompositionOperation.SourceOver) {
gpuRender(points)
} else {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[architecture] high Priority

Issue: Mixed GPU and CPU rendering paths lack proper fallback coordination
Context: GPU path failures silently fall back to CPU rendering without state synchronization, potentially causing visual inconsistencies and performance issues
Suggestion: Implement proper error boundaries and state synchronization between GPU/CPU paths, or fail fast with clear user feedback when GPU initialization fails

"semver": "^7.7.2",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"typegpu": "catalog:",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[quality] medium Priority

Issue: Missing TypeGPU library usage validation and version dependencies
Context: Adding typegpu as a production dependency without proper capability detection increases bundle size for users who cannot use WebGPU
Suggestion: Consider making TypeGPU an optional dynamic import or add capability detection to avoid loading on unsupported devices

// Generate dense points for the segment
const densePoints: Point[] = []

const samples = 20
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[performance] medium Priority

Issue: Fixed sample count causes unnecessary computation for short segments
Context: Using fixed 20 samples for all segments regardless of length wastes computation on short segments and may undersample long segments
Suggestion: Calculate sample count dynamically based on segment length: const samples = Math.max(2, Math.ceil(segmentLength / minSampleDistance))

@@ -6,7 +6,6 @@
"lib": [
"ES2023",
"ES2023.Array",
"ESNext.Iterator",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[quality] low Priority

Issue: Removing ESNext.Iterator from TypeScript lib array limits modern iterator support
Context: Removing ESNext.Iterator might break compatibility with advanced iterator features that may be used elsewhere in the codebase
Suggestion: Verify this removal is intentional and doesn't break iterator-related functionality elsewhere

import { StrokeProcessor } from './StrokeProcessor'
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'

// GPU Resources
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[architecture] critical Priority

Issue: Global module state creates memory leaks and prevents multiple component instances
Context: Global variables for GPU resources (device, renderer, textures) at module level prevent proper cleanup and break component isolation
Suggestion: Move all GPU state into a composable or service class that can be properly instantiated, destroyed, and scoped to component lifecycle

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comprehensive PR Review

This review is generated by Claude. It may not always be accurate, as with human reviewers. If you believe that any of the comments are invalid or incorrect, please state why for each. For others, please implement the changes in one way or another.

Review Summary

PR: Web gpu maskeditor rendering (#6812)
Impact: 2685 additions, 344 deletions across 23 files

Issue Distribution

  • Critical: 1
  • High: 3
  • Medium: 3
  • Low: 1

Category Breakdown

  • Architecture: 2 issues
  • Security: 1 issue
  • Performance: 3 issues
  • Code Quality: 3 issues

Key Findings

Architecture & Design

This PR introduces a comprehensive WebGPU rendering system for the mask editor, which is architecturally sound but has some critical design issues:

  1. Global State Management (CRITICAL): GPU resources are managed at module level rather than component lifecycle, creating memory leaks and preventing multiple instances
  2. Mixed Rendering Paths: GPU and CPU rendering paths lack proper coordination and error handling

Security Considerations

The main security concern is the lack of WebGPU feature detection, which could cause runtime errors on unsupported browsers rather than graceful degradation.

Performance Impact

The implementation shows good GPU optimization patterns but has several performance concerns:

  • Missing bounds checking for MAX_STROKES limit could cause buffer overflows
  • GPU bind groups are created repeatedly without caching, causing memory accumulation
  • Fixed sample counts in stroke processing waste computation on short segments

Integration Points

The integration follows ComfyUI patterns well, properly using the store system and Vue composables. The TypeGPU library integration is reasonable but could be optimized for bundle size.

Positive Observations

  • Excellent WebGPU pipeline architecture with proper render and compute pass separation
  • Good use of TypeGPU for type-safe shader development
  • Comprehensive stroke smoothing with Catmull-Rom splines
  • Proper alpha blending and premultiplied alpha handling
  • Well-structured composable pattern following Vue 3 best practices
  • Extensive test coverage for new functionality

References

Next Steps

  1. Address critical global state management issues before merge
  2. Add proper WebGPU capability detection with fallback
  3. Implement GPU resource cleanup and memory management
  4. Consider bundle size optimization for TypeGPU dependency

This is a comprehensive automated review. For architectural decisions requiring human judgment, please request additional manual review.

@github-actions github-actions bot removed the claude-review Add to trigger a PR code review from Claude Code label Nov 21, 2025
@jtydhr88 jtydhr88 marked this pull request as draft November 21, 2025 20:43
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (22)
src/composables/maskeditor/useCanvasHistory.ts (3)

7-9: Consider extracting a shared history state type alias

The { mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap } shape is now repeated for states, the restoreState parameter, and implicitly at call sites. Extracting a type CanvasHistoryState = { mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap } (or similar) would reduce duplication and keep future changes to this structure localized.

Also applies to: 126-129, 163-163


58-61: saveState behavior with provided data vs. context availability

The new saveState(providedMaskData?, providedRgbData?) overload is useful, and the cleanup of shifted ImageBitmaps via close() is good for avoiding leaks. Two behavioral points to double-check:

  • if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) return short-circuits even when both providedMaskData/providedRgbData are supplied, which prevents using history purely as a bitmap container if the 2D contexts/canvases are not ready yet.
  • The provided data branch only triggers when both providedMaskData and providedRgbData are truthy; mixed cases (one provided, one not) silently fall back to reading both from the canvases.
    If callers are expected to sometimes push GPU-produced bitmaps before 2D contexts exist, or supply only one channel, you may want to relax the context guard for the “provided” path and/or support per-channel overrides.

Also applies to: 76-90, 95-103


134-146: ImageBitmap restore/cleanup semantics and environment robustness

The ImageBitmap branches in restoreState and the explicit close() calls in both the rotation and clearStates paths look correct and should help manage GPU/bitmap memory. A couple of softer considerations:

  • state.mask instanceof ImageBitmap / state.rgb instanceof ImageBitmap assumes ImageBitmap exists at runtime; tests now provide a shim, but if this composable is ever used in SSR/Node without that shim, you may want a defensive typeof ImageBitmap !== 'undefined' check around the instanceofs.
  • clearRect currently uses the bitmap’s width/height; if canvases can be resized independently of stored states, you might consider clearing based on the current canvas size instead to avoid leaving stale pixels outside the bitmap area.
    Also, since histories now actively close() bitmaps, it’s worth documenting that ownership of passed-in ImageBitmaps is transferred to useCanvasHistory so callers don’t reuse a closed bitmap later.

Also applies to: 149-154

src/components/maskeditor/MaskEditorContent.vue (1)

158-168: Consider adding error handling for GPU initialization.

While the code checks for availability, GPU initialization can fail for various reasons (unsupported hardware, driver issues, etc.). Consider wrapping the GPU initialization in a try-catch block and gracefully degrading to CPU-only rendering if GPU setup fails.

     // Initialize GPU resources
     if (toolManager?.brushDrawing) {
-      await toolManager.brushDrawing.initGPUResources()
-      if (gpuCanvasRef.value && toolManager?.brushDrawing.initPreviewCanvas) {
-        // Match preview canvas resolution to mask canvas
-        gpuCanvasRef.value.width = maskCanvasRef.value.width
-        gpuCanvasRef.value.height = maskCanvasRef.value.height
-
-        toolManager.brushDrawing.initPreviewCanvas(gpuCanvasRef.value)
+      try {
+        await toolManager.brushDrawing.initGPUResources()
+        if (gpuCanvasRef.value && toolManager?.brushDrawing.initPreviewCanvas) {
+          // Match preview canvas resolution to mask canvas
+          gpuCanvasRef.value.width = maskCanvasRef.value.width
+          gpuCanvasRef.value.height = maskCanvasRef.value.height
+
+          toolManager.brushDrawing.initPreviewCanvas(gpuCanvasRef.value)
+        }
+      } catch (error) {
+        console.warn('[MaskEditorContent] GPU initialization failed, falling back to CPU rendering:', error)
       }
     }
src/composables/maskeditor/StrokeProcessor.test.ts (1)

63-73: Align debug logging threshold with toBeCloseTo tolerance

Right now you only log when |d - spacing| > 0.5, but toBeCloseTo(spacing, 1) will already fail for differences ≥ ~0.05. That means some failing cases won’t emit the helpful debug logs.

To keep logs aligned with the assertion, you could tighten the threshold, for example:

-      if (Math.abs(d - spacing) > 0.5) {
+      if (Math.abs(d - spacing) > 0.05) {

This way, any distance that can cause the expectation to fail will also log diagnostic information.

src/composables/maskeditor/brushUtils.ts (1)

1-34: Consider clamping hardness inputs/outputs to the documented [0,1] range

The math for getEffectiveBrushSize/getEffectiveHardness is clear and keeps the “hard core” radius stable as you grow the soft falloff. Right now, though, both functions assume hardness is already in [0, 1] (as per the JSDoc), and don’t guard against out‑of‑range values.

If upstream code ever passes hardness < 0 or > 1, you could end up with negative or >1 effective hardness, which would contradict the return contract and could produce odd gradients.

If you want these helpers to be self‑contained and defensive, consider clamping:

export function getEffectiveBrushSize(size: number, hardness: number): number {
-  // Scale factor for maximum softness
-  const MAX_SCALE = 1.5
-  const scale = 1.0 + (1.0 - hardness) * (MAX_SCALE - 1.0)
+  // Clamp hardness to [0, 1] to keep behavior predictable
+  const clampedHardness = Math.min(Math.max(hardness, 0), 1)
+  const MAX_SCALE = 1.5
+  const scale = 1.0 + (1.0 - clampedHardness) * (MAX_SCALE - 1.0)
   return size * scale
}

export function getEffectiveHardness(
  size: number,
  hardness: number,
  effectiveSize: number
): number {
   if (effectiveSize <= 0) return 0
-  // Adjust hardness to maintain the physical radius of the hard core
-  return (size * hardness) / effectiveSize
+  const clampedHardness = Math.min(Math.max(hardness, 0), 1)
+  const raw = (size * clampedHardness) / effectiveSize
+  // Clamp to [0, 1] to match the documented range
+  return Math.min(Math.max(raw, 0), 1)
}

Not mandatory if the store already guarantees valid ranges, but it will make these utilities safer to reuse in other contexts.

src/components/maskeditor/BrushSettingsPanel.vue (1)

55-62: Brush size/step size wiring looks good; consider localizing the “Stepsize” label

  • :max="500" on thickness matches the 1–500 clamp in setBrushSize, and the stepSize slider range 1–100 matches setBrushStepSize, so the store and UI stay in sync.
  • The new onStepSizeChange handler is correctly delegated to store.setBrushStepSize.
  • For consistency with the rest of the panel and i18n, consider replacing the hard-coded label="Stepsize" with a translation key once one exists (and maybe “Step size” spacing in the string).

Also applies to: 83-89, 122-124

src/composables/maskeditor/StrokeProcessor.ts (1)

1-110: StrokeProcessor spline + resampling logic looks solid; consider explicit reset semantics

The Catmull‑Rom windowing and resampleSegment integration handle both dragged strokes and single‑clicks correctly, and the sliding window in addPoint plus the flush loop in endStroke gives full coverage of all segments without duplication.

One thing to watch: internal state (controlPoints, remainder, isFirstPoint, hasProcessedSegment) is never cleared after endStroke(). If a single StrokeProcessor instance can be reused for multiple strokes, you may want either:

  • to document this as “one instance per stroke”, or
  • add a reset() that clears the buffers/flags (optionally called from endStroke) so reuse is safe.

You could also consider lifting const samples = 20 to a constructor parameter or class constant if you plan to tune smoothness later, but it’s fine as-is.

src/composables/maskeditor/splineUtils.ts (1)

1-125: Spline utilities are correct and robust for stroke processing

The centripetal Catmull‑Rom implementation and resampleSegment logic look mathematically sound, including safeguards for coincident control points and zero‑length segments. The remainder contract also matches how StrokeProcessor carries spacing across segments.

If you ever profile this hot path, you could hoist getT / interp or inline the add/mul math to reduce tiny allocation/closure overhead, but that’s a micro‑optimization and not required.

src/composables/maskeditor/gpu/brushShaders.ts (1)

1-171: Shader logic looks coherent; please verify brush size semantics and enum/dep wiring

Overall the WGSL templates and TypeGPU wiring look consistent:

  • Vertex shader expands a quad around the stroke point and converts to NDC.
  • Fragment shader correctly switches between square (Chebyshev) and circle (Euclidean) distance, applies hardness + fwidth‑based AA, and returns premultiplied color.
  • Blit/composite shaders are standard full‑screen triangle passes.
  • Readback computes un‑premultiplied RGBA and packs to u32 with sane indexing.

A few things worth double‑checking:

  • In vs, the comment says “Convert diameter to radius” but the code uses let radius = size * pressure; (no / 2.0). If the size vertex attribute is actually a diameter, this will double the brush radius; if it is already a radius, the comment should be updated to avoid confusion.
  • globals.brushShape == 1u for the square path ties the shader to the exact numeric value of your BrushShape enum on the TS side. Consider at least documenting that coupling (or mapping to a simpler u32/bool uniform) so future enum changes don’t silently break shape selection.
  • readbackShader assumes outputBuf.length >= width * height; ensure the compute dispatch and buffer allocation on the TS side always match textureDimensions(inputTex) to avoid out‑of‑bounds writes.
  • Lint hints show unresolved imports for 'typegpu' / 'typegpu/data' and ./gpuSchema in the analysis environment; please confirm those modules are in your dependencies and correctly resolved by your bundler/tsconfig.
src/stores/maskEditorStore.ts (1)

20-26: Brush size/step size and new GPU/clear state are consistent; minor default/value nits

  • setBrushSize now clamps to [1, 500], which correctly matches the updated thickness slider range.
  • setBrushStepSize clamps to [1, 100], aligned with the new stepSize slider; the various usages of stepSize in brushSettings, resetBrushToDefault, and resetState are consistent type‑wise.
  • The only behavioral nit is that the initial brushSettings uses stepSize: 10 while both reset paths use stepSize: 5. If you expect a single canonical default, you may want to align these values; if not, a brief comment would help explain the difference.
  • clearTrigger + triggerClear() and the exported tgpuRoot ref give the rest of the mask editor a clean way to react to clears and GPU root setup; once the GPU root type is stable, tightening tgpuRoot away from any would improve type safety.

Also applies to: 53-54, 72-75, 115-137, 175-177, 183-190, 211-273

src/composables/maskeditor/useBrushDrawing.ts (9)

20-36: Global GPU state is module‑scoped; consider instance‑scoping for multiple editors

device, renderer, textures, and readback buffers are all module‑level singletons. This works if you only ever mount one mask editor, but will couple all instances to the same GPU state and preview context (and any destroy/clear calls) if multiple editors are ever used concurrently or in quick succession.

If multi‑instance support is a goal, consider moving these into per‑useBrushDrawing instance state (or a dedicated GPU service with clear ownership and reference counting).


196-208: Brush settings cache: ensure new fields are persisted explicitly

You load stepSize from cached brush settings but rely on Brush’s structure staying in sync with the store state (store.brushSettings). If new fields are later added to Brush, they won’t be persisted unless explicitly handled.

Not urgent, but it might be safer to persist only the fields you care about (size, opacity, hardness, type, stepSize) and ignore/derive the rest, to decouple localStorage format from store internals.


210-216: Clear events should also clear preview and accumulator

The clearGPU watcher zeros maskTexture and rgbTexture, but it doesn’t reset the stroke accumulator or preview canvas. If the user clears the canvas mid‑stroke, the preview may still display stale stroke data until the next draw.

You could also reset stroke/preview state when clearing:

   watch(
     () => store.clearTrigger,
     () => {
       clearGPU()
+      resetDirtyRect()
+      if (renderer && previewContext) {
+        renderer.clearPreview(previewContext)
+      }
     }
   )

This keeps GPU and CPU views visually consistent after a clear.


697-721: drawLine spacing reuse via lineRemainder is good; consider guarding 0‑length input

The resampleSegment call plus lineRemainder reuse gives consistent spacing across chained Shift+click segments. One small edge case: if p1 and p2 are identical (degenerate line), you still go through GPU/CPU work even though there’s nothing to draw.

You could early‑return for zero‑length segments to skip unnecessary work:

-  async function drawLine(…
-  ): Promise<void> {
-    // Generate equidistant points using segment resampling
+  async function drawLine(…
+  ): Promise<void> {
+    if (p1.x === p2.x && p1.y === p2.y) {
+      return
+    }
+    // Generate equidistant points using segment resampling

Not critical, but a simple micro‑optimization.


727-787: startDrawing GPU/CPU flow is well structured; minor note on RAF branch

The overall start‑of‑stroke flow — reset dirty rect, prepare GPU accumulator, compute spacing from stepSize, initialize StrokeProcessor, and hide the main canvas while preview is active — is coherent and guarded with try/catch.

Note that the handleDrawing branch that calls gpuDrawPoint only executes when !isDrawing.value, and then immediately bails inside the RAF if !isDrawing.value is still true. That effectively makes gpuDrawPoint unreachable under normal drawing, so this function is now dead code.

If it’s no longer needed, consider removing gpuDrawPoint (and the special diff>20 path) to reduce complexity.


1011-1142: GPU readback + dirty‑rect cropping are correct; potential future perf win

copyGpuToCanvas correctly:

  • Lazily (re)allocates storage + staging buffers when the canvas size changes.
  • Uses the compute prepareReadback path to write into the storage buffer, then copies to map‑readable staging buffers.
  • Crops the putImageData to the current dirtyRect, falling back to full canvas when invalid.

Note that the compute shader always processes the full texture, even if the dirty rect is small, so large canvases will still pay O(W×H) at stroke end. If readback becomes a bottleneck, you might later consider tiling or a rect‑restricted readback, but the current implementation is correct and probably fine for now.


1147-1173: destroy cleans resources but leaves some globals non‑null

destroy releases renderer buffers, textures, readback buffers, and the TypeGPU root, and zeroes currentBufferSize, which is good. Two small follow‑ups:

  • Set renderer = null and previewContext = null so accidental use after destroy fails fast.
  • Optionally resetDirtyRect() to avoid stale state if the composable is reused without a full reload.

Example:

  function destroy(): void {
    renderer?.destroy()
+   renderer = null-    currentBufferSize = 0
+    currentBufferSize = 0
+    previewContext = null
+    resetDirtyRect()
  }

This makes reuse safer in long‑lived SPA sessions.


1247-1260: Preview canvas init assumes GPU is ready; call ordering matters

initPreviewCanvas returns early if device or getContext('webgpu') is unavailable, which is fine. Just be aware that callers must ensure initGPUResources (or at least initTypeGPU) has completed first, otherwise device will be null and preview will silently never initialize.

If this has bitten UX already, you could log a warning when device is missing so mis‑ordering is easier to debug.


1373-1394: clearGPU only clears textures; consider clearing preview and accumulator too

clearGPU writes zeros into both maskTexture and rgbTexture, which is correct for the backing textures. However, currentStrokeTexture (in GPUBrushRenderer) and the preview canvas can still contain the last in‑progress stroke.

You already clear the preview in drawEnd; doing something similar in clearGPU would keep everything in sync:

  function clearGPU() {
    if (!device || !maskTexture || !rgbTexture || !store.maskCanvas) return

    …
    device.queue.writeTexture(…rgbTexture…)
+
+    if (renderer && previewContext) {
+      renderer.clearPreview(previewContext)
+    }
  }

This avoids “ghost” strokes remaining in the preview after an external clear.

src/composables/maskeditor/gpu/GPUBrushRenderer.ts (2)

571-675: Blit + composite for preview are correct; note repeated uniform packing

blitToCanvas correctly:

  • Optionally draws the background texture first (for erasing over existing content), otherwise clears the target.
  • Then composites the accumulated stroke using either the erase or composite preview pipeline.
  • Repacks uniforms in the same layout as compositeStroke.

Functionally this is solid. If you later profile and find uniform packing/ArrayBuffer allocation to be hot, you could extract a small helper to reuse a single scratch buffer instead of allocating a new one on each call, but that’s an optimization only.


717-723: destroy releases GPU resources cleanly

Destroying all buffers and the accumulation texture in destroy is correct and important for long‑running sessions. Optionally, you might also set this.currentStrokeTexture = null to make accidental use after destroy more obvious, but functionally this is fine as‑is.

Comment on lines +505 to +569
private renderStrokeInternal(
targetView: GPUTextureView,
pipeline: GPURenderPipeline,
points: { x: number; y: number; pressure: number }[],
settings: {
size: number
opacity: number
hardness: number
color: [number, number, number]
width: number
height: number
brushShape: number
}
) {
if (points.length === 0) return

// 1. Update Uniforms
const buffer = new ArrayBuffer(UNIFORM_SIZE)
const f32 = new Float32Array(buffer)
const u32 = new Uint32Array(buffer)

f32[0] = settings.color[0]
f32[1] = settings.color[1]
f32[2] = settings.color[2]
f32[3] = settings.opacity
f32[4] = settings.hardness
f32[5] = 0 // Padding
f32[6] = settings.width
f32[7] = settings.height
u32[8] = settings.brushShape
this.device.queue.writeBuffer(this.uniformBuffer, 0, buffer)

// 2. Batch Instance Data
const batchSize = Math.min(points.length, MAX_STROKES)
const iData = new Float32Array(batchSize * 4)
for (let i = 0; i < batchSize; i++) {
iData[i * 4 + 0] = points[i].x
iData[i * 4 + 1] = points[i].y
iData[i * 4 + 2] = settings.size
iData[i * 4 + 3] = points[i].pressure
}
this.device.queue.writeBuffer(this.instanceBuffer, 0, iData)

// 3. Render Pass
const encoder = this.device.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: targetView,
loadOp: 'load',
storeOp: 'store'
}
]
})

pass.setPipeline(pipeline)
pass.setBindGroup(0, this.uniformBindGroup)
pass.setVertexBuffer(0, this.quadVertexBuffer)
pass.setVertexBuffer(1, this.instanceBuffer)
pass.setIndexBuffer(this.indexBuffer, 'uint16')
pass.drawIndexed(6, batchSize)
pass.end()

this.device.queue.submit([encoder.finish()])
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

MAX_STROKES silently truncates long strokes; batch large point sets instead

In renderStrokeInternal you clamp to:

const batchSize = Math.min(points.length, MAX_STROKES)

pass.drawIndexed(6, batchSize)

Any points beyond MAX_STROKES are never rendered, so very long strokes will be visibly truncated on the GPU path.

A straightforward fix is to batch in chunks of MAX_STROKES within a single render pass:

-    const batchSize = Math.min(points.length, MAX_STROKES)
-    const iData = new Float32Array(batchSize * 4)
-    for (let i = 0; i < batchSize; i++) {
-      iData[i * 4 + 0] = points[i].x
-      iData[i * 4 + 1] = points[i].y
-      iData[i * 4 + 2] = settings.size
-      iData[i * 4 + 3] = points[i].pressure
-    }
-    this.device.queue.writeBuffer(this.instanceBuffer, 0, iData)
-
-    // 3. Render Pass
-    const encoder = this.device.createCommandEncoder()
-    const pass = encoder.beginRenderPass({ … })
-
-    pass.drawIndexed(6, batchSize)
-    pass.end()
-    this.device.queue.submit([encoder.finish()])
+    const encoder = this.device.createCommandEncoder()
+    const pass = encoder.beginRenderPass({
+      colorAttachments: [
+        {
+          view: targetView,
+          loadOp: 'load',
+          storeOp: 'store'
+        }
+      ]
+    })
+
+    pass.setPipeline(pipeline)
+    pass.setBindGroup(0, this.uniformBindGroup)
+    pass.setVertexBuffer(0, this.quadVertexBuffer)
+    pass.setIndexBuffer(this.indexBuffer, 'uint16')
+
+    for (let offset = 0; offset < points.length; offset += MAX_STROKES) {
+      const batchSize = Math.min(MAX_STROKES, points.length - offset)
+      const iData = new Float32Array(batchSize * 4)
+      for (let i = 0; i < batchSize; i++) {
+        const p = points[offset + i]
+        iData[i * 4 + 0] = p.x
+        iData[i * 4 + 1] = p.y
+        iData[i * 4 + 2] = settings.size
+        iData[i * 4 + 3] = p.pressure
+      }
+      this.device.queue.writeBuffer(this.instanceBuffer, 0, iData)
+      pass.setVertexBuffer(1, this.instanceBuffer)
+      pass.drawIndexed(6, batchSize)
+    }
+
+    pass.end()
+    this.device.queue.submit([encoder.finish()])

This preserves the existing MAX_STROKES buffer size while correctly rendering arbitrarily long strokes.


🏁 Script executed:

#!/bin/bash
# Find MAX_STROKES definition
rg -n 'MAX_STROKES' src/composables/maskeditor/gpu/GPUBrushRenderer.ts

Length of output: 135


🏁 Script executed:

#!/bin/bash
# Examine the full renderStrokeInternal method and surrounding context
cat src/composables/maskeditor/gpu/GPUBrushRenderer.ts | head -600 | tail -150

Length of output: 4274


🏁 Script executed:

#!/bin/bash
# Check if there are any other batching or stroke rendering methods
rg -n 'renderStroke|batch|MAX_STROKES' src/composables/maskeditor/gpu/GPUBrushRenderer.ts -A2 -B2

Length of output: 1566


Batch long strokes in chunks instead of silently truncating at MAX_STROKES

The current implementation silently drops all points beyond MAX_STROKES (10,000):

const batchSize = Math.min(points.length, MAX_STROKES)  // Clamps to 10,000
const iData = new Float32Array(batchSize * 4)
for (let i = 0; i < batchSize; i++) {  // Never reaches points beyond index 10,000
  // ...
}

Points beyond the limit are never copied to the instance buffer or rendered. Implement the proposed batching approach to process all points while respecting the buffer size:

-    const batchSize = Math.min(points.length, MAX_STROKES)
-    const iData = new Float32Array(batchSize * 4)
-    for (let i = 0; i < batchSize; i++) {
-      iData[i * 4 + 0] = points[i].x
-      iData[i * 4 + 1] = points[i].y
-      iData[i * 4 + 2] = settings.size
-      iData[i * 4 + 3] = points[i].pressure
-    }
-    this.device.queue.writeBuffer(this.instanceBuffer, 0, iData)
-
-    // 3. Render Pass
-    const encoder = this.device.createCommandEncoder()
-    const pass = encoder.beginRenderPass({ … })
-
-    pass.drawIndexed(6, batchSize)
-    pass.end()
-    this.device.queue.submit([encoder.finish()])
+    const encoder = this.device.createCommandEncoder()
+    const pass = encoder.beginRenderPass({
+      colorAttachments: [
+        {
+          view: targetView,
+          loadOp: 'load',
+          storeOp: 'store'
+        }
+      ]
+    })
+
+    pass.setPipeline(pipeline)
+    pass.setBindGroup(0, this.uniformBindGroup)
+    pass.setVertexBuffer(0, this.quadVertexBuffer)
+    pass.setIndexBuffer(this.indexBuffer, 'uint16')
+
+    for (let offset = 0; offset < points.length; offset += MAX_STROKES) {
+      const batchSize = Math.min(MAX_STROKES, points.length - offset)
+      const iData = new Float32Array(batchSize * 4)
+      for (let i = 0; i < batchSize; i++) {
+        const p = points[offset + i]
+        iData[i * 4 + 0] = p.x
+        iData[i * 4 + 1] = p.y
+        iData[i * 4 + 2] = settings.size
+        iData[i * 4 + 3] = p.pressure
+      }
+      this.device.queue.writeBuffer(this.instanceBuffer, 0, iData)
+      pass.setVertexBuffer(1, this.instanceBuffer)
+      pass.drawIndexed(6, batchSize)
+    }
+
+    pass.end()
+    this.device.queue.submit([encoder.finish()])
🤖 Prompt for AI Agents
In src/composables/maskeditor/gpu/GPUBrushRenderer.ts around lines 505-569, the
current code truncates points to MAX_STROKES and drops the rest; change this to
process points in chunks of up to MAX_STROKES so all points are rendered.
Implement a loop over offsets (for offset = 0; offset < points.length; offset +=
MAX_STROKES) that computes batchSize = Math.min(points.length - offset,
MAX_STROKES), builds an iData Float32Array for points[offset ..
offset+batchSize-1] (or fills it with a small inner loop), calls
device.queue.writeBuffer(this.instanceBuffer, 0, iData), then creates an
encoder, begins a render pass, binds pipeline/buffers, issues
pass.drawIndexed(6, batchSize), ends the pass and submits the encoder; repeat
for each chunk until all points are processed.

@jtydhr88 jtydhr88 closed this Nov 22, 2025
@jtydhr88 jtydhr88 deleted the webGPU-maskeditor-rendering branch November 22, 2025 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants