Skip to content

feat(video): VIDEO_EDIT rich widget with trim and crop editors - #14118

Closed
jtydhr88 wants to merge 1 commit into
mainfrom
feat/trim-video-rich-widget
Closed

feat(video): VIDEO_EDIT rich widget with trim and crop editors#14118
jtydhr88 wants to merge 1 commit into
mainfrom
feat/trim-video-rich-widget

Conversation

@jtydhr88

@jtydhr88 jtydhr88 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

For testing and feedback. open sepearte PRs to break it into small parts:
Separate PRs of wave1:

  1. feat(range): content inset support and active handle in useRangeEditor #14120
  2. feat(video): video preview composables and metadata utils #14121
  3. feat(video): crop box editor composables and overlay component #14122
    Separate PRs of wave2:
    TODO

Summary

  • videoedit widget type wired through nodeDef schema (VIDEO_EDIT + features), widget constructor, litegraph widget map, and widget registry
  • WidgetVideoEdit/VideoEditPanel render feature-gated sections: filmstrip trim timeline with playhead scrubbing, crop overlay with drag/resize handles and aspect-ratio presets/lock, WidgetBoundingBox numeric row, reset-frame shortcuts, and trim/crop enable toggles (UI-only; zero values mean no-op)
  • Logic lives in composables: useVideoEditModel (seconds/pixels to display domain), useTrimPlayback, useTimelineScrub, useCropBoxEditor, useCropRatioLock, useVideoEditFormats, useVideoSourceUrl (upstream link or own file widget, reactive to execution outputs, rand param stripped to avoid redundant reloads)
  • Exact video metadata (fps/frame count/duration/size) from the new backend /video_metadata endpoint with client-side estimation fallback, replacing client-side MP4 box probing
  • Node-level video preview suppressed when a videoedit widget exists (Vue mode); the litegraph renderer keeps its regular preview and the widget draws nothing there
  • Unit tests for the composables, utils, and widget components

BE is Comfy-Org/ComfyUI#15090

Screenshots (if applicable)

image

@jtydhr88
jtydhr88 requested a review from a team July 26, 2026 19:52
@dosubot dosubot Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Jul 26, 2026
@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

🌐 Website E2E

Tip

All tests passed.

Status ✅ Passed
Report View Report

🔗 Website Preview

Website Preview: https://comfy-website-preview-pr-14118.vercel.app

This commit: https://website-frontend-7g873mebd-comfyui.vercel.app

Last updated: 2026-07-28T02:55:07Z for ba1ad30

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown

🎭 Playwright: ✅ 1738 passed, 0 failed · 1 flaky

📊 Browser Reports
  • chromium: View Report (✅ 1717 / ❌ 0 / ⚠️ 1 / ⏭️ 5)
  • chromium-2x: View Report (✅ 2 / ❌ 0 / ⚠️ 0 / ⏭️ 0)
  • chromium-0.5x: View Report (✅ 1 / ❌ 0 / ⚠️ 0 / ⏭️ 0)
  • mobile-chrome: View Report (✅ 18 / ❌ 0 / ⚠️ 0 / ⏭️ 0)

🎨 Storybook: ✅ Built — View Storybook

Details

⏰ Completed at: 07/28/2026, 02:55:20 AM UTC

Links

📦 Bundle: 8.14 MB gzip 🔴 +17.2 kB

Details

Summary

  • Raw size: 34.2 MB baseline 34.1 MB — 🔴 +67.7 kB
  • Gzip: 8.14 MB baseline 8.12 MB — 🔴 +17.2 kB
  • Brotli: 5.63 MB baseline 5.61 MB — 🔴 +15.1 kB
  • Bundles: 425 current • 420 baseline • 160 added / 155 removed

Category Glance
Other 🔴 +41 kB (12.5 MB) · Utilities & Hooks 🔴 +15.1 kB (372 kB) · UI Components 🔴 +5.89 kB (70.5 kB) · Editors & Dialogs 🔴 +2.99 kB (124 kB) · Data & Services 🔴 +2.19 kB (3.38 MB) · Views & Navigation 🔴 +419 B (112 kB) · + 5 more

App Entry Points — 3.64 kB (baseline 3.64 kB) • ⚪ 0 B

Main entry bundles and manifests

File Before After Δ Raw Δ Gzip Δ Brotli
assets/index-CeD0yFxt.js (removed) 3.64 kB 🟢 -3.64 kB 🟢 -1.82 kB 🟢 -1.59 kB
assets/index-Dihe1gjy.js (new) 3.64 kB 🔴 +3.64 kB 🔴 +1.83 kB 🔴 +1.58 kB

Status: 1 added / 1 removed

Graph Workspace — 1.28 MB (baseline 1.28 MB) • 🔴 +211 B

Graph editor runtime, canvas, workflow orchestration

File Before After Δ Raw Δ Gzip Δ Brotli
assets/GraphView-DPff4fxm.js (new) 1.28 MB 🔴 +1.28 MB 🔴 +275 kB 🔴 +207 kB
assets/GraphView-CxelUJW6.js (removed) 1.28 MB 🟢 -1.28 MB 🟢 -275 kB 🟢 -207 kB

Status: 1 added / 1 removed / 1 unchanged

Views & Navigation — 112 kB (baseline 112 kB) • 🔴 +419 B

Top-level views, pages, and routed surfaces

File Before After Δ Raw Δ Gzip Δ Brotli
assets/CloudSurveyView-Brq5yW8g.js (new) 25 kB 🔴 +25 kB 🔴 +6.26 kB 🔴 +5.54 kB
assets/CloudSurveyView-DwvGxkEp.js (removed) 25 kB 🟢 -25 kB 🟢 -6.25 kB 🟢 -5.53 kB
assets/CloudLoginView-D3A-_vAS.js (removed) 11.6 kB 🟢 -11.6 kB 🟢 -3.14 kB 🟢 -2.75 kB
assets/CloudLoginView-DCOegr-X.js (new) 11.6 kB 🔴 +11.6 kB 🔴 +3.14 kB 🔴 +2.75 kB
assets/CloudSignupView-C9vqpw4Y.js (new) 9.96 kB 🔴 +9.96 kB 🔴 +2.81 kB 🔴 +2.46 kB
assets/CloudSignupView-shZGxjzW.js (removed) 9.96 kB 🟢 -9.96 kB 🟢 -2.81 kB 🟢 -2.47 kB
assets/CloudLayoutView-DCo15Ync.js (removed) 9.54 kB 🟢 -9.54 kB 🟢 -2.35 kB 🟢 -2.02 kB
assets/CloudLayoutView-DYlAQ1c1.js (new) 9.54 kB 🔴 +9.54 kB 🔴 +2.35 kB 🔴 +2.02 kB
assets/UserCheckView-B3MkgeSU.js (new) 8.8 kB 🔴 +8.8 kB 🔴 +2.22 kB 🔴 +1.92 kB
assets/UserCheckView-CaRqrpC1.js (removed) 8.8 kB 🟢 -8.8 kB 🟢 -2.22 kB 🟢 -1.93 kB
assets/WidgetTextPreview-DkzP854d.js (new) 6.13 kB 🔴 +6.13 kB 🔴 +2.17 kB 🔴 +1.93 kB
assets/WidgetTextPreview-m834-W97.js (removed) 6.13 kB 🟢 -6.13 kB 🟢 -2.16 kB 🟢 -1.92 kB
assets/CloudSubscriptionRedirectView-ByBNuYmd.js (new) 6.06 kB 🔴 +6.06 kB 🔴 +2.26 kB 🔴 +1.97 kB
assets/CloudSubscriptionRedirectView-DAvFQyw8.js (removed) 6.06 kB 🟢 -6.06 kB 🟢 -2.26 kB 🟢 -1.97 kB
assets/UserSelectView-BRNDU1nk.js (removed) 5.54 kB 🟢 -5.54 kB 🟢 -1.98 kB 🟢 -1.74 kB
assets/UserSelectView-D-qi5bFx.js (new) 5.54 kB 🔴 +5.54 kB 🔴 +1.98 kB 🔴 +1.74 kB
assets/CloudForgotPasswordView-B98an57R.js (new) 5.15 kB 🔴 +5.15 kB 🔴 +1.76 kB 🔴 +1.54 kB
assets/CloudForgotPasswordView-CKlNfrO6.js (removed) 5.15 kB 🟢 -5.15 kB 🟢 -1.76 kB 🟢 -1.54 kB
assets/CloudAuthTimeoutView-CibSpduu.js (removed) 4.49 kB 🟢 -4.49 kB 🟢 -1.57 kB 🟢 -1.37 kB
assets/CloudAuthTimeoutView-COARgpxZ.js (new) 4.49 kB 🔴 +4.49 kB 🔴 +1.57 kB 🔴 +1.37 kB
assets/OAuthLayoutView-BIiYE6YV.js (removed) 1.36 kB 🟢 -1.36 kB 🟢 -733 B 🟢 -621 B
assets/OAuthLayoutView-BZDe-cGv.js (new) 1.36 kB 🔴 +1.36 kB 🔴 +734 B 🔴 +620 B
assets/layout-CNyfx3JD.js (new) 866 B 🔴 +866 B 🔴 +401 B 🔴 +338 B
assets/layout-CFmaHb5G.js (removed) 447 B 🟢 -447 B 🟢 -286 B 🟢 -230 B
assets/WidgetTextPreview-BmXtGgMl.js (removed) 131 B 🟢 -131 B 🟢 -100 B 🟢 -89 B
assets/WidgetTextPreview-CckeyLi4.js (new) 131 B 🔴 +131 B 🔴 +100 B 🔴 +88 B

Status: 13 added / 13 removed / 3 unchanged

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

Configuration panels, inspectors, and settings screens

File Before After Δ Raw Δ Gzip Δ Brotli
assets/KeybindingPanel-4Go5RcwE.js (new) 49.5 kB 🔴 +49.5 kB 🔴 +9.97 kB 🔴 +8.83 kB
assets/KeybindingPanel-e1nyPymN.js (removed) 49.5 kB 🟢 -49.5 kB 🟢 -9.96 kB 🟢 -8.83 kB
assets/SecretsPanel-B71lalZY.js (new) 31 kB 🔴 +31 kB 🔴 +7.51 kB 🔴 +6.58 kB
assets/SecretsPanel-BDiRks0f.js (removed) 31 kB 🟢 -31 kB 🟢 -7.51 kB 🟢 -6.57 kB
assets/CreditsPanel-C_roOXJ0.js (new) 16 kB 🔴 +16 kB 🔴 +4.66 kB 🔴 +4.1 kB
assets/CreditsPanel-DqSs1gZs.js (removed) 16 kB 🟢 -16 kB 🟢 -4.66 kB 🟢 -4.09 kB
assets/SubscriptionPanel-AWEa9b8P.js (removed) 11.5 kB 🟢 -11.5 kB 🟢 -3.65 kB 🟢 -3.21 kB
assets/SubscriptionPanel-DH5s9Hc7.js (new) 11.5 kB 🔴 +11.5 kB 🔴 +3.66 kB 🔴 +3.21 kB
assets/AboutPanel-aIJyy2wL.js (removed) 11.2 kB 🟢 -11.2 kB 🟢 -3.06 kB 🟢 -2.74 kB
assets/AboutPanel-BECYEQtP.js (new) 11.2 kB 🔴 +11.2 kB 🔴 +3.06 kB 🔴 +2.74 kB
assets/ExtensionPanel-DBSjVCjd.js (new) 9.24 kB 🔴 +9.24 kB 🔴 +2.54 kB 🔴 +2.25 kB
assets/ExtensionPanel-qBTB2V7Y.js (removed) 9.24 kB 🟢 -9.24 kB 🟢 -2.54 kB 🟢 -2.25 kB
assets/ServerConfigPanel-BhZVHulH.js (new) 6.15 kB 🔴 +6.15 kB 🔴 +1.97 kB 🔴 +1.75 kB
assets/ServerConfigPanel-rm0Pu-zD.js (removed) 6.15 kB 🟢 -6.15 kB 🟢 -1.97 kB 🟢 -1.75 kB
assets/UserPanel-CBREFIMs.js (new) 5.78 kB 🔴 +5.78 kB 🔴 +1.81 kB 🔴 +1.57 kB
assets/UserPanel-Dy_fX3Qn.js (removed) 5.78 kB 🟢 -5.78 kB 🟢 -1.81 kB 🟢 -1.57 kB
assets/refreshRemoteConfig-BlQ8uqQ0.js (removed) 2.97 kB 🟢 -2.97 kB 🟢 -1.27 kB 🟢 -1.11 kB
assets/refreshRemoteConfig-DwmGRn3b.js (new) 2.97 kB 🔴 +2.97 kB 🔴 +1.27 kB 🔴 +1.11 kB
assets/cloudRemoteConfig-BvzQ-ShD.js (new) 988 B 🔴 +988 B 🔴 +537 B 🔴 +459 B
assets/cloudRemoteConfig-Ck8Kj1B7.js (removed) 988 B 🟢 -988 B 🟢 -539 B 🟢 -444 B
assets/refreshRemoteConfig-Bf_x2bhD.js (new) 110 B 🔴 +110 B 🔴 +89 B 🔴 +91 B
assets/refreshRemoteConfig-BRaBK9aQ.js (removed) 110 B 🟢 -110 B 🟢 -89 B 🟢 -90 B

Status: 11 added / 11 removed / 15 unchanged

User & Accounts — 29.1 kB (baseline 29.1 kB) • ⚪ 0 B

Authentication, profile, and account management bundles

File Before After Δ Raw Δ Gzip Δ Brotli
assets/SignUpForm-_ITWIurE.js (new) 12.3 kB 🔴 +12.3 kB 🔴 +4.18 kB 🔴 +3.65 kB
assets/SignUpForm-CKX7uCgf.js (removed) 12.3 kB 🟢 -12.3 kB 🟢 -4.18 kB 🟢 -3.65 kB
assets/auth-C0U23bER.js (new) 3.69 kB 🔴 +3.69 kB 🔴 +1.3 kB 🔴 +1.13 kB
assets/auth-I9zwWo5b.js (removed) 3.69 kB 🟢 -3.69 kB 🟢 -1.3 kB 🟢 -1.12 kB
assets/usePostAuthRedirect-BX5MzEUy.js (new) 3.33 kB 🔴 +3.33 kB 🔴 +1.28 kB 🔴 +1.11 kB
assets/usePostAuthRedirect-JZS2RhpE.js (removed) 3.33 kB 🟢 -3.33 kB 🟢 -1.27 kB 🟢 -1.11 kB
assets/UpdatePasswordContent-c3KCXB0Q.js (removed) 1.91 kB 🟢 -1.91 kB 🟢 -874 B 🟢 -760 B
assets/UpdatePasswordContent-C5wrfOtL.js (new) 1.91 kB 🔴 +1.91 kB 🔴 +871 B 🔴 +759 B
assets/authStore-CcmjROLT.js (removed) 128 B 🟢 -128 B 🟢 -104 B 🟢 -112 B
assets/authStore-CjwCN_Cm.js (new) 128 B 🔴 +128 B 🔴 +104 B 🔴 +104 B
assets/workspaceAuthStore-Bocdjg_u.js (removed) 108 B 🟢 -108 B 🟢 -99 B 🟢 -109 B
assets/workspaceAuthStore-u8i2wNJq.js (new) 108 B 🔴 +108 B 🔴 +99 B 🔴 +105 B
assets/auth-MGC5q-xu.js (removed) 105 B 🟢 -105 B 🟢 -96 B 🟢 -85 B
assets/auth-VA2l-0Ir.js (new) 105 B 🔴 +105 B 🔴 +96 B 🔴 +89 B

Status: 7 added / 7 removed / 3 unchanged

Editors & Dialogs — 124 kB (baseline 121 kB) • 🔴 +2.99 kB

Modals, dialogs, drawers, and in-app editors

File Before After Δ Raw Δ Gzip Δ Brotli
assets/ComfyHubPublishDialog-3VzAKyzf.js (new) 89.9 kB 🔴 +89.9 kB 🔴 +19.2 kB 🔴 +16.4 kB
assets/ComfyHubPublishDialog-B4tuR0MO.js (removed) 89.9 kB 🟢 -89.9 kB 🟢 -19.2 kB 🟢 -16.5 kB
assets/useShareDialog-DmkaYH22.js (new) 23.7 kB 🔴 +23.7 kB 🔴 +5.59 kB 🔴 +4.96 kB
assets/useShareDialog-rxn1tnxP.js (removed) 23.7 kB 🟢 -23.7 kB 🟢 -5.58 kB 🟢 -4.95 kB
assets/feedbackDialog-Cr-K7Yxz.js (new) 4.5 kB 🔴 +4.5 kB 🔴 +1.9 kB 🔴 +1.62 kB
assets/feedbackDialog-DwkQAx_H.js (removed) 4.5 kB 🟢 -4.5 kB 🟢 -1.9 kB 🟢 -1.61 kB
assets/useRangeEditor-VPZo_VzX.js (new) 2.99 kB 🔴 +2.99 kB 🔴 +1.09 kB 🔴 +982 B
assets/ComfyHubPublishDialog-CqwEoylG.js (new) 143 B 🔴 +143 B 🔴 +105 B 🔴 +89 B
assets/ComfyHubPublishDialog-HQHkOGGQ.js (removed) 143 B 🟢 -143 B 🟢 -105 B 🟢 -89 B
assets/useSubscriptionDialog-Dv50A7EJ.js (new) 108 B 🔴 +108 B 🔴 +102 B 🔴 +95 B
assets/useSubscriptionDialog-Dwj4kIN7.js (removed) 108 B 🟢 -108 B 🟢 -102 B 🟢 -97 B

Status: 6 added / 5 removed / 1 unchanged

UI Components — 70.5 kB (baseline 64.6 kB) • 🔴 +5.89 kB

Reusable component library chunks

File Before After Δ Raw Δ Gzip Δ Brotli
assets/ComfyQueueButton-BsW68LHB.js (new) 13.5 kB 🔴 +13.5 kB 🔴 +3.77 kB 🔴 +3.35 kB
assets/ComfyQueueButton-CDgSESQp.js (removed) 13.5 kB 🟢 -13.5 kB 🟢 -3.77 kB 🟢 -3.34 kB
assets/useTerminalTabs-BGyJVv9m.js (new) 11.8 kB 🔴 +11.8 kB 🔴 +3.72 kB 🔴 +3.29 kB
assets/useTerminalTabs-CtRIh2-g.js (removed) 11.8 kB 🟢 -11.8 kB 🟢 -3.72 kB 🟢 -3.29 kB
assets/InviteMembersForm-7LOzCx5E.js (new) 7.85 kB 🔴 +7.85 kB 🔴 +2.65 kB 🔴 +2.37 kB
assets/InviteMembersForm-C6QNb65V.js (removed) 7.85 kB 🟢 -7.85 kB 🟢 -2.65 kB 🟢 -2.37 kB
assets/WidgetInputNumberInput-CFChu0xM.js (new) 5.89 kB 🔴 +5.89 kB 🔴 +2.11 kB 🔴 +1.88 kB
assets/SubscribeButton-D4Nr6fb1.js (removed) 2.18 kB 🟢 -2.18 kB 🟢 -989 B 🟢 -863 B
assets/SubscribeButton-DulZuZw0.js (new) 2.18 kB 🔴 +2.18 kB 🔴 +986 B 🔴 +866 B
assets/cloudFeedbackTopbarButton-cv7ekqnf.js (removed) 768 B 🟢 -768 B 🟢 -453 B 🟢 -418 B
assets/cloudFeedbackTopbarButton-kSrAPL29.js (new) 768 B 🔴 +768 B 🔴 +454 B 🔴 +406 B
assets/ComfyQueueButton-DXGHNC4L.js (removed) 128 B 🟢 -128 B 🟢 -99 B 🟢 -91 B
assets/ComfyQueueButton-jX25DnlJ.js (new) 128 B 🔴 +128 B 🔴 +99 B 🔴 +98 B

Status: 7 added / 6 removed / 8 unchanged

Data & Services — 3.38 MB (baseline 3.37 MB) • 🔴 +2.19 kB

Stores, services, APIs, and repositories

File Before After Δ Raw Δ Gzip Δ Brotli
assets/settingStore-DRdnAsRZ.js (new) 3.1 MB 🔴 +3.1 MB 🔴 +717 kB 🔴 +541 kB
assets/settingStore-FKyISF61.js (removed) 3.1 MB 🟢 -3.1 MB 🟢 -717 kB 🟢 -540 kB
assets/load3dService-BxqiMtGa.js (new) 132 kB 🔴 +132 kB 🔴 +29.3 kB 🔴 +24.6 kB
assets/load3dService-CsDH301h.js (removed) 132 kB 🟢 -132 kB 🟢 -29.2 kB 🟢 -24.6 kB
assets/api-BhkV575P.js (removed) 91.4 kB 🟢 -91.4 kB 🟢 -25.3 kB 🟢 -21.7 kB
assets/api-DJbwQpW2.js (new) 91.4 kB 🔴 +91.4 kB 🔴 +25.3 kB 🔴 +21.8 kB
assets/workflowShareService-Cb16gmqB.js (new) 16.5 kB 🔴 +16.5 kB 🔴 +4.92 kB 🔴 +4.36 kB
assets/workflowShareService-DVjtTR8U.js (removed) 16.5 kB 🟢 -16.5 kB 🟢 -4.92 kB 🟢 -4.36 kB
assets/keybindingService-DcxB6-OV.js (removed) 7.28 kB 🟢 -7.28 kB 🟢 -1.9 kB 🟢 -1.62 kB
assets/keybindingService-DqEWE5vh.js (new) 7.28 kB 🔴 +7.28 kB 🔴 +1.9 kB 🔴 +1.62 kB
assets/releaseStore-Be-Q-Eqr.js (removed) 6.77 kB 🟢 -6.77 kB 🟢 -2.06 kB 🟢 -1.8 kB
assets/releaseStore-DqE1eSD1.js (new) 6.77 kB 🔴 +6.77 kB 🔴 +2.06 kB 🔴 +1.8 kB
assets/systemStatsStore-Ck3vJWkR.js (new) 4.99 kB 🔴 +4.99 kB 🔴 +1.78 kB 🔴 +1.5 kB
assets/systemStatsStore-ClAoudUB.js (removed) 4.99 kB 🟢 -4.99 kB 🟢 -1.77 kB 🟢 -1.5 kB
assets/userStore-CSBSanTB.js (removed) 2.42 kB 🟢 -2.42 kB 🟢 -931 B 🟢 -819 B
assets/userStore-DFpKrLis.js (new) 2.42 kB 🔴 +2.42 kB 🔴 +930 B 🔴 +818 B
assets/audioService-CqW_gyvp.js (new) 1.76 kB 🔴 +1.76 kB 🔴 +861 B 🔴 +746 B
assets/audioService-D-P3Qahh.js (removed) 1.76 kB 🟢 -1.76 kB 🟢 -863 B 🟢 -749 B
assets/dialogService-iXfIOXDr.js (new) 98 B 🔴 +98 B 🔴 +97 B 🔴 +92 B
assets/dialogService-YrC6PrMP.js (removed) 98 B 🟢 -98 B 🟢 -97 B 🟢 -86 B
assets/releaseStore-BIk-fFxi.js (new) 95 B 🔴 +95 B 🔴 +86 B 🔴 +85 B
assets/releaseStore-DWxRgm1l.js (removed) 95 B 🟢 -95 B 🟢 -86 B 🟢 -82 B
assets/settingStore-5Qo4sogX.js (new) 95 B 🔴 +95 B 🔴 +86 B 🔴 +86 B
assets/settingStore-Cy-P6QBC.js (removed) 95 B 🟢 -95 B 🟢 -86 B 🟢 -89 B
assets/assetsStore-Ble6srbT.js (new) 94 B 🔴 +94 B 🔴 +92 B 🔴 +86 B
assets/assetsStore-Cve5-Ccz.js (removed) 94 B 🟢 -94 B 🟢 -92 B 🟢 -86 B
assets/api-Btpz7VVo.js (removed) 62 B 🟢 -62 B 🟢 -74 B 🟢 -66 B
assets/api-o0_g_FIE.js (new) 62 B 🔴 +62 B 🔴 +74 B 🔴 +66 B

Status: 14 added / 14 removed / 3 unchanged

Utilities & Hooks — 372 kB (baseline 357 kB) • 🔴 +15.1 kB

Helpers, composables, and utility bundles

File Before After Δ Raw Δ Gzip Δ Brotli
assets/useConflictDetection-C1ADG1rj.js (removed) 235 kB 🟢 -235 kB 🟢 -52.5 kB 🟢 -42.8 kB
assets/useConflictDetection-CftudyF2.js (new) 235 kB 🔴 +235 kB 🔴 +52.5 kB 🔴 +42.7 kB
assets/useLoad3d-BC75sccv.js (new) 25.8 kB 🔴 +25.8 kB 🔴 +5.82 kB 🔴 +5.15 kB
assets/useLoad3d-MF5UPblM.js (removed) 25.8 kB 🟢 -25.8 kB 🟢 -5.82 kB 🟢 -5.16 kB
assets/useLoad3dViewer-C0CEq5Kp.js (new) 21.3 kB 🔴 +21.3 kB 🔴 +5.01 kB 🔴 +4.38 kB
assets/useLoad3dViewer-CL6Lby8U.js (removed) 21.3 kB 🟢 -21.3 kB 🟢 -5.01 kB 🟢 -4.38 kB
assets/useImageCrop-Dpv5OaCl.js (new) 15.1 kB 🔴 +15.1 kB 🔴 +3.45 kB 🔴 +3.01 kB
assets/useFeatureFlags-9dLcpcgb.js (removed) 7.08 kB 🟢 -7.08 kB 🟢 -2.13 kB 🟢 -1.8 kB
assets/useFeatureFlags-DuuRReQ7.js (new) 7.08 kB 🔴 +7.08 kB 🔴 +2.13 kB 🔴 +1.79 kB
assets/useDowngradeToPersonal-CwfPOZXC.js (removed) 3.51 kB 🟢 -3.51 kB 🟢 -1.27 kB 🟢 -1.1 kB
assets/useDowngradeToPersonal-DO0o2pYl.js (new) 3.51 kB 🔴 +3.51 kB 🔴 +1.27 kB 🔴 +1.1 kB
assets/useExternalLink-BcBTzKOe.js (removed) 2.87 kB 🟢 -2.87 kB 🟢 -1.09 kB 🟢 -947 B
assets/useExternalLink-Sg8SFqaz.js (new) 2.87 kB 🔴 +2.87 kB 🔴 +1.09 kB 🔴 +963 B
assets/downloadUtil-CEf5hJP7.js (removed) 2.45 kB 🟢 -2.45 kB 🟢 -1.11 kB 🟢 -898 B
assets/downloadUtil-zNAVyqDX.js (new) 2.45 kB 🔴 +2.45 kB 🔴 +1.12 kB 🔴 +902 B
assets/assetPreviewUtil-DdUnfi8P.js (new) 2.41 kB 🔴 +2.41 kB 🔴 +1 kB 🔴 +871 B
assets/assetPreviewUtil-DzO8Au5E.js (removed) 2.41 kB 🟢 -2.41 kB 🟢 -1 kB 🟢 -872 B
assets/useUpstreamValue-1qm_kJoe.js (removed) 2.04 kB 🟢 -2.04 kB 🟢 -790 B 🟢 -702 B
assets/useUpstreamValue-Cv6bk3Ap.js (new) 2.04 kB 🔴 +2.04 kB 🔴 +788 B 🔴 +701 B
assets/useWorkspaceTierLabel-BeHCk5FW.js (removed) 1.99 kB 🟢 -1.99 kB 🟢 -847 B 🟢 -726 B
assets/useWorkspaceTierLabel-BlrP6glf.js (new) 1.99 kB 🔴 +1.99 kB 🔴 +844 B 🔴 +728 B
assets/useErrorHandling-ByggYHt6.js (removed) 1.54 kB 🟢 -1.54 kB 🟢 -646 B 🟢 -547 B
assets/useErrorHandling-Cc3KdsJe.js (new) 1.54 kB 🔴 +1.54 kB 🔴 +647 B 🔴 +548 B
assets/subscriptionCheckoutUtil-DeyMah5Z.js (removed) 903 B 🟢 -903 B 🟢 -534 B 🟢 -450 B
assets/subscriptionCheckoutUtil-MlIhKdCP.js (new) 903 B 🔴 +903 B 🔴 +534 B 🔴 +451 B
assets/useSessionCookie-BPMqgssA.js (removed) 746 B 🟢 -746 B 🟢 -389 B 🟢 -340 B
assets/useSessionCookie-xZOM9VZK.js (new) 746 B 🔴 +746 B 🔴 +391 B 🔴 +334 B
assets/useLoad3d-DEQJZlSU.js (new) 311 B 🔴 +311 B 🔴 +164 B 🔴 +148 B
assets/useLoad3d-DNlonn2E.js (removed) 311 B 🟢 -311 B 🟢 -164 B 🟢 -148 B
assets/useSessionCookie-Byh3NssH.js (new) 101 B 🔴 +101 B 🔴 +86 B 🔴 +78 B
assets/useSessionCookie-DUV0jqK1.js (removed) 101 B 🟢 -101 B 🟢 -86 B 🟢 -79 B
assets/useFeatureFlags-BmOoPphR.js (removed) 98 B 🟢 -98 B 🟢 -85 B 🟢 -79 B
assets/useFeatureFlags-CJuBQgGr.js (new) 98 B 🔴 +98 B 🔴 +85 B 🔴 +79 B
assets/useLoad3dViewer-B4VQukIm.js (new) 98 B 🔴 +98 B 🔴 +85 B 🔴 +82 B
assets/useLoad3dViewer-D30dqdTc.js (removed) 98 B 🟢 -98 B 🟢 -85 B 🟢 -85 B
assets/useCurrentUser-C6dYg1yC.js (removed) 94 B 🟢 -94 B 🟢 -95 B 🟢 -86 B
assets/useCurrentUser-Cn0fnNs0.js (new) 94 B 🔴 +94 B 🔴 +95 B 🔴 +93 B

Status: 19 added / 18 removed / 17 unchanged

Vendor & Third-Party — 15.7 MB (baseline 15.7 MB) • ⚪ 0 B

External libraries and shared vendor chunks

Status: 16 unchanged

Other — 12.5 MB (baseline 12.5 MB) • 🔴 +41 kB

Bundles that do not match a named category

File Before After Δ Raw Δ Gzip Δ Brotli
assets/main-CoMdSavj.js (new) 193 kB 🔴 +193 kB 🔴 +56.6 kB 🔴 +47.6 kB
assets/main-CuQ7rSG1.js (removed) 192 kB 🟢 -192 kB 🟢 -56.4 kB 🟢 -47.3 kB
assets/core-BVXpC7Ql.js (removed) 111 kB 🟢 -111 kB 🟢 -28.7 kB 🟢 -24.3 kB
assets/core-DjtP_z0B.js (new) 111 kB 🔴 +111 kB 🔴 +28.7 kB 🔴 +24.3 kB
assets/WidgetSelect-B9EG5lZo.js (removed) 88 kB 🟢 -88 kB 🟢 -20 kB 🟢 -17.1 kB
assets/WidgetSelect-LOb-QJU9.js (new) 88 kB 🔴 +88 kB 🔴 +20 kB 🔴 +17.1 kB
assets/Load3D-B6xv5RLu.js (removed) 71.3 kB 🟢 -71.3 kB 🟢 -11.7 kB 🟢 -10 kB
assets/Load3D-DT6UGrPI.js (new) 71.3 kB 🔴 +71.3 kB 🔴 +11.7 kB 🔴 +10 kB
assets/WidgetVideoEdit-Xs5NDyAi.js (new) 62.5 kB 🔴 +62.5 kB 🔴 +14.4 kB 🔴 +12.7 kB
assets/Preview3d-BbPDxCn0.js (new) 50.9 kB 🔴 +50.9 kB 🔴 +8.34 kB 🔴 +7.28 kB
assets/Preview3d-CgsUwQ3-.js (removed) 50.9 kB 🟢 -50.9 kB 🟢 -8.34 kB 🟢 -7.28 kB
assets/main-ai1npUI2.js (removed) 46.6 kB 🟢 -46.6 kB 🟢 -13.8 kB 🟢 -11.9 kB
assets/main-GPHVK3Ha.js (new) 46.6 kB 🔴 +46.6 kB 🔴 +13.8 kB 🔴 +11.9 kB
assets/SubscriptionTransitionPreviewWorkspace-2jceGmcr.js (new) 43.5 kB 🔴 +43.5 kB 🔴 +9.36 kB 🔴 +8.21 kB
assets/SubscriptionTransitionPreviewWorkspace-Bk991J5L.js (removed) 43.5 kB 🟢 -43.5 kB 🟢 -9.36 kB 🟢 -8.23 kB
assets/SubscriptionRequiredDialogContentUnified-DvwPP5gz.js (new) 42.1 kB 🔴 +42.1 kB 🔴 +9.37 kB 🔴 +8.16 kB
assets/SubscriptionRequiredDialogContentUnified-WMTCk9QV.js (removed) 42.1 kB 🟢 -42.1 kB 🟢 -9.37 kB 🟢 -8.15 kB
assets/MembersPanelContent-5qyn1H38.js (new) 41.9 kB 🔴 +41.9 kB 🔴 +8.8 kB 🔴 +7.85 kB
assets/MembersPanelContent-5uZmrqwX.js (removed) 41.9 kB 🟢 -41.9 kB 🟢 -8.8 kB 🟢 -7.84 kB
assets/WorkspaceSettingsPanelContent-BilonnYA.js (removed) 34.6 kB 🟢 -34.6 kB 🟢 -8 kB 🟢 -7.02 kB
assets/WorkspaceSettingsPanelContent-BmGPJDpH.js (new) 34.6 kB 🔴 +34.6 kB 🔴 +8 kB 🔴 +7.01 kB
assets/WidgetBoundingBoxes-BSCHedQr.js (removed) 33.8 kB 🟢 -33.8 kB 🟢 -9.19 kB 🟢 -8.16 kB
assets/WidgetBoundingBoxes-DaeKKrKt.js (new) 33.8 kB 🔴 +33.8 kB 🔴 +9.19 kB 🔴 +8.16 kB
assets/WidgetPainter-LoFXGzDV.js (new) 32.7 kB 🔴 +32.7 kB 🔴 +7.9 kB 🔴 +6.98 kB
assets/WidgetPainter-nVvIkqTo.js (removed) 32.7 kB 🟢 -32.7 kB 🟢 -7.9 kB 🟢 -6.98 kB
assets/Load3dViewerContent-U5rKNOR_.js (removed) 30.8 kB 🟢 -30.8 kB 🟢 -6.3 kB 🟢 -5.47 kB
assets/Load3dViewerContent-uvxsIvd_.js (new) 30.8 kB 🔴 +30.8 kB 🔴 +6.3 kB 🔴 +5.46 kB
assets/HdrViewerContent-B0ZfUZ1b.js (new) 29.8 kB 🔴 +29.8 kB 🔴 +8.28 kB 🔴 +7.3 kB
assets/HdrViewerContent-EIgySGCL.js (removed) 29.8 kB 🟢 -29.8 kB 🟢 -8.28 kB 🟢 -7.3 kB
assets/SubscriptionPanelContentWorkspace-DXkKgziS.js (new) 25.9 kB 🔴 +25.9 kB 🔴 +6.04 kB 🔴 +5.32 kB
assets/SubscriptionPanelContentWorkspace-Zw3go-qp.js (removed) 25.9 kB 🟢 -25.9 kB 🟢 -6.04 kB 🟢 -5.32 kB
assets/SubscriptionRequiredDialogContentWorkspace-D6Muj0_S.js (new) 24.9 kB 🔴 +24.9 kB 🔴 +5.76 kB 🔴 +5.06 kB
assets/SubscriptionRequiredDialogContentWorkspace-DJYK2w4t.js (removed) 24.9 kB 🟢 -24.9 kB 🟢 -5.77 kB 🟢 -5.06 kB
assets/SubscriptionRequiredDialogContent-CEkyK--r.js (new) 24.4 kB 🔴 +24.4 kB 🔴 +6 kB 🔴 +5.3 kB
assets/SubscriptionRequiredDialogContent-DNB671fU.js (removed) 24.4 kB 🟢 -24.4 kB 🟢 -6 kB 🟢 -5.28 kB
assets/WidgetImageCrop-BY_EtODt.js (removed) 23.3 kB 🟢 -23.3 kB 🟢 -5.75 kB 🟢 -5.04 kB
assets/load3d-BbjAeAg9.js (removed) 22.2 kB 🟢 -22.2 kB 🟢 -5.44 kB 🟢 -4.69 kB
assets/load3d-xu6GVl5K.js (new) 22.2 kB 🔴 +22.2 kB 🔴 +5.44 kB 🔴 +4.69 kB
assets/CurrentUserPopoverWorkspace-DW2D3G2x.js (new) 20.5 kB 🔴 +20.5 kB 🔴 +4.71 kB 🔴 +4.19 kB
assets/CurrentUserPopoverWorkspace-DyLGzkje.js (removed) 20.5 kB 🟢 -20.5 kB 🟢 -4.71 kB 🟢 -4.19 kB
assets/SignInContent-C218WeLO.js (new) 20.3 kB 🔴 +20.3 kB 🔴 +5.13 kB 🔴 +4.48 kB
assets/SignInContent-DabF6MHA.js (removed) 20.3 kB 🟢 -20.3 kB 🟢 -5.13 kB 🟢 -4.49 kB
assets/WidgetInputNumber-SvNmJxBp.js (removed) 19.1 kB 🟢 -19.1 kB 🟢 -4.83 kB 🟢 -4.29 kB
assets/CreditsTile-BfUQhfIL.js (removed) 17.2 kB 🟢 -17.2 kB 🟢 -4.57 kB 🟢 -4.03 kB
assets/CreditsTile-CtSFBeUY.js (new) 17.2 kB 🔴 +17.2 kB 🔴 +4.57 kB 🔴 +4.02 kB
assets/WidgetRecordAudio-CTQdvN_3.js (removed) 16.6 kB 🟢 -16.6 kB 🟢 -4.63 kB 🟢 -4.14 kB
assets/WidgetRecordAudio-flt0ukH-.js (new) 16.6 kB 🔴 +16.6 kB 🔴 +4.63 kB 🔴 +4.12 kB
assets/WidgetRange-DzsrT07g.js (removed) 16.4 kB 🟢 -16.4 kB 🟢 -4.24 kB 🟢 -3.79 kB
assets/WidgetInputNumber-h9wLxrp6.js (new) 14 kB 🔴 +14 kB 🔴 +3.67 kB 🔴 +3.24 kB
assets/WidgetRange-SMmMcjBr.js (new) 13.8 kB 🔴 +13.8 kB 🔴 +3.57 kB 🔴 +3.15 kB
assets/WaveAudioPlayer-DFG8cbFv.js (removed) 12.8 kB 🟢 -12.8 kB 🟢 -3.5 kB 🟢 -3.07 kB
assets/WaveAudioPlayer-DieohLYt.js (new) 12.8 kB 🔴 +12.8 kB 🔴 +3.5 kB 🔴 +3.06 kB
assets/i18n-CTKHy5UV.js (new) 12.6 kB 🔴 +12.6 kB 🔴 +3.34 kB 🔴 +2.82 kB
assets/i18n-Daul2BbM.js (removed) 12.6 kB 🟢 -12.6 kB 🟢 -3.33 kB 🟢 -2.82 kB
assets/WidgetCurve-BDNsXMrw.js (new) 11.3 kB 🔴 +11.3 kB 🔴 +3.5 kB 🔴 +3.16 kB
assets/WidgetCurve-DlCdf0EX.js (removed) 11.3 kB 🟢 -11.3 kB 🟢 -3.51 kB 🟢 -3.16 kB
assets/AudioPreviewPlayer-CeaFSCsL.js (removed) 10.6 kB 🟢 -10.6 kB 🟢 -3.08 kB 🟢 -2.74 kB
assets/AudioPreviewPlayer-DlaHJHCk.js (new) 10.6 kB 🔴 +10.6 kB 🔴 +3.08 kB 🔴 +2.74 kB
assets/TeamWorkspacesDialogContent-DniYSwhu.js (removed) 10.3 kB 🟢 -10.3 kB 🟢 -3 kB 🟢 -2.66 kB
assets/TeamWorkspacesDialogContent-YjYl0wHs.js (new) 10.3 kB 🔴 +10.3 kB 🔴 +3 kB 🔴 +2.66 kB
assets/onboardingCloudRoutes-gYz_G6a2.js (new) 9.03 kB 🔴 +9.03 kB 🔴 +2.77 kB 🔴 +2.37 kB
assets/onboardingCloudRoutes-zB2ZzyQc.js (removed) 9.03 kB 🟢 -9.03 kB 🟢 -2.77 kB 🟢 -2.37 kB
assets/Load3DConfiguration-7SFFGu7O.js (new) 8.97 kB 🔴 +8.97 kB 🔴 +2.64 kB 🔴 +2.32 kB
assets/Load3DConfiguration-DASfE02F.js (removed) 8.97 kB 🟢 -8.97 kB 🟢 -2.64 kB 🟢 -2.32 kB
assets/WidgetImageCrop-Y_hJKs2W.js (new) 8.55 kB 🔴 +8.55 kB 🔴 +2.68 kB 🔴 +2.37 kB
assets/SetMemberCreditLimitDialogContent-D-b_sZt9.js (new) 8.54 kB 🔴 +8.54 kB 🔴 +2.37 kB 🔴 +2.07 kB
assets/SetMemberCreditLimitDialogContent-FMla4XeE.js (removed) 8.54 kB 🟢 -8.54 kB 🟢 -2.37 kB 🟢 -2.08 kB
assets/nodeTemplates-CmYlbeOZ.js (new) 8.33 kB 🔴 +8.33 kB 🔴 +2.87 kB 🔴 +2.52 kB
assets/nodeTemplates-e0JQWxkM.js (removed) 8.33 kB 🟢 -8.33 kB 🟢 -2.87 kB 🟢 -2.53 kB
assets/NightlySurveyController-B7aPZL-D.js (new) 7.56 kB 🔴 +7.56 kB 🔴 +2.59 kB 🔴 +2.29 kB
assets/NightlySurveyController-rSFjPLfn.js (removed) 7.56 kB 🟢 -7.56 kB 🟢 -2.59 kB 🟢 -2.28 kB
assets/WidgetWithControl-B4_v2J7K.js (removed) 6.38 kB 🟢 -6.38 kB 🟢 -2.59 kB 🟢 -2.32 kB
assets/WidgetWithControl-Dym86LKZ.js (new) 6.38 kB 🔴 +6.38 kB 🔴 +2.6 kB 🔴 +2.32 kB
assets/tierBenefits-Cx51JwCt.js (removed) 6.26 kB 🟢 -6.26 kB 🟢 -2 kB 🟢 -1.75 kB
assets/tierBenefits-MNg40-VB.js (new) 6.26 kB 🔴 +6.26 kB 🔴 +2 kB 🔴 +1.75 kB
assets/CancelSubscriptionDialogContent-Dqls8M9_.js (removed) 6.01 kB 🟢 -6.01 kB 🟢 -2.05 kB 🟢 -1.8 kB
assets/CancelSubscriptionDialogContent-DRwFLX7W.js (new) 6.01 kB 🔴 +6.01 kB 🔴 +2.05 kB 🔴 +1.8 kB
assets/load3dPreviewExtensions-03pZr6hQ.js (new) 5.94 kB 🔴 +5.94 kB 🔴 +1.85 kB 🔴 +1.62 kB
assets/load3dPreviewExtensions-C3aks7Rc.js (removed) 5.94 kB 🟢 -5.94 kB 🟢 -1.85 kB 🟢 -1.62 kB
assets/main-BcuFENkt.js (removed) 5.91 kB 🟢 -5.91 kB 🟢 -1.9 kB 🟢 -1.61 kB
assets/main-D-2OAZPF.js (new) 5.91 kB 🔴 +5.91 kB 🔴 +1.89 kB 🔴 +1.6 kB
assets/FreeTierDialogContent-Dna5M6_a.js (removed) 5.47 kB 🟢 -5.47 kB 🟢 -1.83 kB 🟢 -1.61 kB
assets/FreeTierDialogContent-MWRGcQ5u.js (new) 5.47 kB 🔴 +5.47 kB 🔴 +1.83 kB 🔴 +1.61 kB
assets/CreateWorkspaceDialogContent-CDHZRm5T.js (removed) 5.19 kB 🟢 -5.19 kB 🟢 -1.83 kB 🟢 -1.58 kB
assets/CreateWorkspaceDialogContent-DNGMLpOf.js (new) 5.19 kB 🔴 +5.19 kB 🔴 +1.83 kB 🔴 +1.58 kB
assets/ChangeMemberRoleDialogContent-CLBlBGIn.js (removed) 5.04 kB 🟢 -5.04 kB 🟢 -1.67 kB 🟢 -1.46 kB
assets/ChangeMemberRoleDialogContent-DsArB1bu.js (new) 5.04 kB 🔴 +5.04 kB 🔴 +1.67 kB 🔴 +1.46 kB
assets/missingModelDownload-B2bnTJPp.js (removed) 5.03 kB 🟢 -5.03 kB 🟢 -1.95 kB 🟢 -1.7 kB
assets/missingModelDownload-Cc2nKKTH.js (new) 5.03 kB 🔴 +5.03 kB 🔴 +1.95 kB 🔴 +1.7 kB
assets/EditWorkspaceDialogContent-Bv7-AhLz.js (new) 5 kB 🔴 +5 kB 🔴 +1.79 kB 🔴 +1.55 kB
assets/EditWorkspaceDialogContent-DKQDT2Py.js (removed) 5 kB 🟢 -5 kB 🟢 -1.79 kB 🟢 -1.55 kB
assets/WidgetTextarea-Bfh4uyDM.js (removed) 4.86 kB 🟢 -4.86 kB 🟢 -1.9 kB 🟢 -1.66 kB
assets/WidgetTextarea-CPC0UBEz.js (new) 4.86 kB 🔴 +4.86 kB 🔴 +1.9 kB 🔴 +1.66 kB
assets/saveMesh-BMXL8UbJ.js (removed) 4.81 kB 🟢 -4.81 kB 🟢 -1.56 kB 🟢 -1.38 kB
assets/saveMesh-oLA2dPlx.js (new) 4.81 kB 🔴 +4.81 kB 🔴 +1.55 kB 🔴 +1.37 kB
assets/WorkspacePanelContent-CvCJXvzv.js (removed) 4.78 kB 🟢 -4.78 kB 🟢 -1.63 kB 🟢 -1.43 kB
assets/WorkspacePanelContent-DMUNwecW.js (new) 4.78 kB 🔴 +4.78 kB 🔴 +1.63 kB 🔴 +1.43 kB
assets/InviteMemberDialogContent-BFo9K08w.js (removed) 4.75 kB 🟢 -4.75 kB 🟢 -1.62 kB 🟢 -1.4 kB
assets/InviteMemberDialogContent-DHE-3d6J.js (new) 4.75 kB 🔴 +4.75 kB 🔴 +1.61 kB 🔴 +1.4 kB
assets/ValueControlPopover-D_qBTerf.js (new) 4.54 kB 🔴 +4.54 kB 🔴 +1.58 kB 🔴 +1.41 kB
assets/ValueControlPopover-D3ScKzC_.js (removed) 4.54 kB 🟢 -4.54 kB 🟢 -1.58 kB 🟢 -1.41 kB
assets/ApiNodesSignInContent-DCmfuwvk.js (removed) 4.14 kB 🟢 -4.14 kB 🟢 -1.34 kB 🟢 -1.18 kB
assets/ApiNodesSignInContent-gnv9qlPI.js (new) 4.14 kB 🔴 +4.14 kB 🔴 +1.34 kB 🔴 +1.17 kB
assets/DeleteWorkspaceDialogContent-BrWuGi-K.js (removed) 3.91 kB 🟢 -3.91 kB 🟢 -1.47 kB 🟢 -1.26 kB
assets/DeleteWorkspaceDialogContent-D1EqSq11.js (new) 3.91 kB 🔴 +3.91 kB 🔴 +1.47 kB 🔴 +1.26 kB
assets/LeaveWorkspaceDialogContent-DC_vqYaT.js (removed) 3.73 kB 🟢 -3.73 kB 🟢 -1.42 kB 🟢 -1.21 kB
assets/LeaveWorkspaceDialogContent-DxUANU4d.js (new) 3.73 kB 🔴 +3.73 kB 🔴 +1.41 kB 🔴 +1.21 kB
assets/RemoveMemberDialogContent-CS2z20SO.js (removed) 3.71 kB 🟢 -3.71 kB 🟢 -1.37 kB 🟢 -1.18 kB
assets/RemoveMemberDialogContent-DFrD6caU.js (new) 3.71 kB 🔴 +3.71 kB 🔴 +1.37 kB 🔴 +1.18 kB
assets/RevokeInviteDialogContent-BjZoqh8z.js (new) 3.62 kB 🔴 +3.62 kB 🔴 +1.38 kB 🔴 +1.2 kB
assets/RevokeInviteDialogContent-BrDUK2Ho.js (removed) 3.62 kB 🟢 -3.62 kB 🟢 -1.38 kB 🟢 -1.2 kB
assets/WidgetToggleSwitch-EmmLgHJL.js (removed) 3.58 kB 🟢 -3.58 kB 🟢 -1.32 kB 🟢 -1.16 kB
assets/WidgetToggleSwitch-DSqRBP00.js (new) 3.57 kB 🔴 +3.57 kB 🔴 +1.32 kB 🔴 +1.16 kB
assets/InviteMemberUpsellDialogContent-BkGwq3GB.js (removed) 3.51 kB 🟢 -3.51 kB 🟢 -1.26 kB 🟢 -1.11 kB
assets/InviteMemberUpsellDialogContent-CVUsL8ws.js (new) 3.51 kB 🔴 +3.51 kB 🔴 +1.26 kB 🔴 +1.1 kB
assets/workspaceCheckoutTelemetry-5HEdnzhf.js (removed) 3.46 kB 🟢 -3.46 kB 🟢 -1.56 kB 🟢 -1.35 kB
assets/workspaceCheckoutTelemetry-Ba7AduBz.js (new) 3.46 kB 🔴 +3.46 kB 🔴 +1.55 kB 🔴 +1.35 kB
assets/Media3DTop-BhClwBPJ.js (new) 3.26 kB 🔴 +3.26 kB 🔴 +1.29 kB 🔴 +1.13 kB
assets/Media3DTop-BrN-4Vpr.js (removed) 3.26 kB 🟢 -3.26 kB 🟢 -1.29 kB 🟢 -1.13 kB
assets/WidgetInputText-DJFg0K_i.js (new) 3.06 kB 🔴 +3.06 kB 🔴 +1.3 kB 🔴 +1.17 kB
assets/WidgetInputText-P-5PYTMo.js (removed) 3.06 kB 🟢 -3.06 kB 🟢 -1.3 kB 🟢 -1.17 kB
assets/GlobalToast-C__YmVAO.js (removed) 3.05 kB 🟢 -3.05 kB 🟢 -1.25 kB 🟢 -1.07 kB
assets/GlobalToast-DTk2j0bd.js (new) 3.05 kB 🔴 +3.05 kB 🔴 +1.25 kB 🔴 +1.11 kB
assets/load3dAdvanced-Dm5ES_Ao.js (new) 2.87 kB 🔴 +2.87 kB 🔴 +1.13 kB 🔴 +977 B
assets/load3dAdvanced-DX6sFTPc.js (removed) 2.87 kB 🟢 -2.87 kB 🟢 -1.13 kB 🟢 -978 B
assets/SubscribeToRun-B-ELrNEW.js (new) 2.45 kB 🔴 +2.45 kB 🔴 +1.06 kB 🔴 +924 B
assets/SubscribeToRun-CYNZXdBi.js (removed) 2.45 kB 🟢 -2.45 kB 🟢 -1.07 kB 🟢 -928 B
assets/MediaAudioTop-C7Mwrs-4.js (removed) 1.67 kB 🟢 -1.67 kB 🟢 -838 B 🟢 -696 B
assets/MediaAudioTop-C9_cjfPh.js (new) 1.67 kB 🔴 +1.67 kB 🔴 +835 B 🔴 +695 B
assets/signInSchema-DkRrP4e5.js (removed) 1.6 kB 🟢 -1.6 kB 🟢 -584 B 🟢 -508 B
assets/signInSchema-kKoB3Ku9.js (new) 1.6 kB 🔴 +1.6 kB 🔴 +585 B 🔴 +513 B
assets/CloudRunButtonWrapper-C0vFSM60.js (new) 1.11 kB 🔴 +1.11 kB 🔴 +545 B 🔴 +481 B
assets/CloudRunButtonWrapper-J_ZNoLuP.js (removed) 1.11 kB 🟢 -1.11 kB 🟢 -548 B 🟢 -480 B
assets/cloudSessionCookie-DTkCUWUP.js (removed) 989 B 🟢 -989 B 🟢 -465 B 🟢 -411 B
assets/cloudSessionCookie-DwwsCvNG.js (new) 989 B 🔴 +989 B 🔴 +463 B 🔴 +424 B
assets/cloudBadges-CybdWhyw.js (new) 971 B 🔴 +971 B 🔴 +548 B 🔴 +461 B
assets/cloudBadges-Dicn-krn.js (removed) 971 B 🟢 -971 B 🟢 -546 B 🟢 -462 B
assets/Load3DAdvanced-CFTqmi5w.js (removed) 813 B 🟢 -813 B 🟢 -453 B 🟢 -382 B
assets/Load3DAdvanced-CZ2epF3D.js (new) 813 B 🔴 +813 B 🔴 +454 B 🔴 +393 B
assets/nightlyBadges-bp876baT.js (new) 462 B 🔴 +462 B 🔴 +306 B 🔴 +248 B
assets/nightlyBadges-j_xVNNG3.js (removed) 462 B 🟢 -462 B 🟢 -305 B 🟢 -258 B
assets/missingModelDownload-B1zU_rAh.js (new) 228 B 🔴 +228 B 🔴 +150 B 🔴 +130 B
assets/missingModelDownload-EpownxI1.js (removed) 228 B 🟢 -228 B 🟢 -150 B 🟢 -129 B
assets/SubscriptionPanelContentWorkspace-D5x1yAE4.js (new) 179 B 🔴 +179 B 🔴 +117 B 🔴 +92 B
assets/SubscriptionPanelContentWorkspace-sLAy0eM2.js (removed) 179 B 🟢 -179 B 🟢 -117 B 🟢 -91 B
assets/Load3dViewerContent-JLUqzyqW.js (new) 137 B 🔴 +137 B 🔴 +103 B 🔴 +91 B
assets/Load3dViewerContent-qZGQI_bu.js (removed) 137 B 🟢 -137 B 🟢 -103 B 🟢 -108 B
assets/WidgetToggleSwitch-BKzxIHiu.js (new) 134 B 🔴 +134 B 🔴 +101 B 🔴 +98 B
assets/Load3DAdvanced-CR1xwgDZ.js (new) 122 B 🔴 +122 B 🔴 +97 B 🔴 +88 B
assets/Load3DAdvanced-CsJbuUEG.js (removed) 122 B 🟢 -122 B 🟢 -97 B 🟢 -88 B
assets/WidgetLegacy-C2zTGeiR.js (new) 117 B 🔴 +117 B 🔴 +106 B 🔴 +103 B
assets/WidgetLegacy-CNwXobfG.js (removed) 117 B 🟢 -117 B 🟢 -106 B 🟢 -102 B
assets/workflowDraftStoreV2-_-joa7Ld.js (removed) 112 B 🟢 -112 B 🟢 -101 B 🟢 -111 B
assets/workflowDraftStoreV2-MiJQaH9o.js (new) 112 B 🔴 +112 B 🔴 +101 B 🔴 +110 B
assets/Load3D-DoWnOrhx.js (removed) 98 B 🟢 -98 B 🟢 -89 B 🟢 -79 B
assets/Load3D-DTmHoQ0g.js (new) 98 B 🔴 +98 B 🔴 +89 B 🔴 +80 B
assets/i18n-BcsNmBIb.js (new) 97 B 🔴 +97 B 🔴 +92 B 🔴 +101 B
assets/i18n-Bg7Q4r1z.js (removed) 97 B 🟢 -97 B 🟢 -92 B 🟢 -87 B
assets/changeTracker-a6wUfE0f.js (removed) 91 B 🟢 -91 B 🟢 -93 B 🟢 -90 B
assets/changeTracker-B57lAKg3.js (new) 91 B 🔴 +91 B 🔴 +93 B 🔴 +85 B

Status: 81 added / 79 removed / 198 unchanged

⚡ Performance Report

canvas-idle: · 60.0 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 0ms TBT · 68.2 MB heap
canvas-mouse-sweep: · 60.0 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 0ms TBT · 56.0 MB heap
canvas-zoom-sweep: · 60.0 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 0ms TBT · 71.5 MB heap
dom-widget-clipping: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 53.3 MB heap
large-graph-idle: · 60.0 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 0ms TBT · 62.6 MB heap
large-graph-pan: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 61.8 MB heap
large-graph-zoom: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 62.7 MB heap
minimap-idle: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 64.0 MB heap
subgraph-dom-widget-clipping: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 53.5 MB heap
subgraph-idle: · 60.0 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 0ms TBT · 56.8 MB heap
subgraph-mouse-sweep: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 59.4 MB heap
subgraph-transition-enter: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 146ms TBT · 94.7 MB heap
viewport-pan-sweep: · 60.0 avg FPS · 59.9 P5 FPS ✅ (target: ≥52) · 0ms TBT · 64.5 MB heap
vue-large-graph-idle: · 56.3 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 161.6 MB heap
vue-large-graph-pan: · 56.3 avg FPS · 59.5 P5 FPS ✅ (target: ≥52) · 285ms TBT · 164.7 MB heap
workflow-execution: · 60.0 avg FPS · 59.7 P5 FPS ✅ (target: ≥52) · 0ms TBT · 48.5 MB heap

⚠️ 10 regressions detected

Show regressions
Metric Baseline PR (median) Δ Sig
canvas-idle: task duration 483ms 540ms +12% ⚠️ z=4.7
canvas-zoom-sweep: task duration 391ms 436ms +12% ⚠️ z=4.7
dom-widget-clipping: task duration 381ms 399ms +5% ⚠️ z=2.1
large-graph-idle: task duration 722ms 769ms +7% ⚠️ z=4.2
large-graph-pan: task duration 1299ms 1361ms +5% ⚠️ z=6.5
large-graph-pan: script duration 418ms 455ms +9% ⚠️ z=2.4
minimap-idle: task duration 721ms 798ms +11% ⚠️ z=5.7
minimap-idle: script duration 113ms 125ms +11% ⚠️ z=2.7
subgraph-dom-widget-clipping: task duration 391ms 431ms +10% ⚠️ z=2.9
subgraph-idle: task duration 519ms 522ms +1% ⚠️ z=4.9
All metrics
Metric Baseline PR (median) Δ Sig
canvas-idle: avg frame time 17ms 17ms -0% z=-0.5
canvas-idle: p95 frame time 17ms 17ms +0%
canvas-idle: layout duration 0ms 0ms +0%
canvas-idle: style recalc duration 9ms 8ms -2% z=-2.7
canvas-idle: layout count 0 0 +0%
canvas-idle: style recalc count 10 9 -15% z=-4.7
canvas-idle: task duration 483ms 540ms +12% ⚠️ z=4.7
canvas-idle: script duration 19ms 21ms +13% z=-1.9
canvas-idle: TBT 0ms 0ms +0%
canvas-idle: heap used 68.0 MB 68.2 MB +0%
canvas-idle: DOM nodes 20 17 -15% z=-4.4
canvas-idle: event listeners 4 4 +0% z=-1.6
canvas-mouse-sweep: avg frame time 17ms 17ms -0% z=-0.4
canvas-mouse-sweep: p95 frame time 17ms 17ms -0%
canvas-mouse-sweep: layout duration 4ms 3ms -9% z=-0.8
canvas-mouse-sweep: style recalc duration 42ms 40ms -7% z=-0.9
canvas-mouse-sweep: layout count 12 12 +0%
canvas-mouse-sweep: style recalc count 77 76 -1% z=-1.1
canvas-mouse-sweep: task duration 955ms 914ms -4% z=0.8
canvas-mouse-sweep: script duration 125ms 132ms +6% z=-0.5
canvas-mouse-sweep: TBT 0ms 0ms +0%
canvas-mouse-sweep: heap used 49.8 MB 56.0 MB +12%
canvas-mouse-sweep: DOM nodes -275 -108 -61% z=-65.8
canvas-mouse-sweep: event listeners -148 -71 -52% z=-19.1
canvas-zoom-sweep: avg frame time 17ms 17ms -0% z=-0.3
canvas-zoom-sweep: p95 frame time 17ms 17ms +1%
canvas-zoom-sweep: layout duration 1ms 1ms -17% z=-0.8
canvas-zoom-sweep: style recalc duration 17ms 19ms +9% z=-0.2
canvas-zoom-sweep: layout count 6 6 +0%
canvas-zoom-sweep: style recalc count 31 31 +0% z=-0.6
canvas-zoom-sweep: task duration 391ms 436ms +12% ⚠️ z=4.7
canvas-zoom-sweep: script duration 21ms 26ms +24% z=-0.5
canvas-zoom-sweep: TBT 0ms 0ms +0%
canvas-zoom-sweep: heap used 71.6 MB 71.5 MB -0%
canvas-zoom-sweep: DOM nodes 78 77 -2% z=-3.5
canvas-zoom-sweep: event listeners 19 19 +0% z=-0.9
dom-widget-clipping: avg frame time 17ms 17ms +0% z=0.1
dom-widget-clipping: p95 frame time 17ms 17ms +0%
dom-widget-clipping: layout duration 0ms 0ms +0%
dom-widget-clipping: style recalc duration 7ms 9ms +22% z=-1.2
dom-widget-clipping: layout count 0 0 +0%
dom-widget-clipping: style recalc count 11 13 +14% z=-1.2
dom-widget-clipping: task duration 381ms 399ms +5% ⚠️ z=2.1
dom-widget-clipping: script duration 57ms 62ms +8% z=-1.8
dom-widget-clipping: TBT 0ms 0ms +0%
dom-widget-clipping: heap used 53.0 MB 53.3 MB +1%
dom-widget-clipping: DOM nodes 18 21 +17% z=-0.8
dom-widget-clipping: event listeners 2 2 +0% variance too high
large-graph-idle: avg frame time 17ms 17ms +0% z=-0.2
large-graph-idle: p95 frame time 17ms 17ms +1%
large-graph-idle: layout duration 0ms 0ms +0%
large-graph-idle: style recalc duration 8ms 9ms +18% z=-2.7
large-graph-idle: layout count 0 0 +0%
large-graph-idle: style recalc count 8 9 +6% z=-9.9
large-graph-idle: task duration 722ms 769ms +7% ⚠️ z=4.2
large-graph-idle: script duration 111ms 121ms +9% z=1.8
large-graph-idle: TBT 0ms 0ms +0%
large-graph-idle: heap used 62.2 MB 62.6 MB +1%
large-graph-idle: DOM nodes -275 -276 +0% z=-332.1
large-graph-idle: event listeners -144 -142 -1% z=-27.6
large-graph-pan: avg frame time 17ms 17ms -0% z=0.3
large-graph-pan: p95 frame time 17ms 17ms -0%
large-graph-pan: layout duration 0ms 0ms +0%
large-graph-pan: style recalc duration 15ms 14ms -9% z=-4.6
large-graph-pan: layout count 0 0 +0%
large-graph-pan: style recalc count 68 68 +0% z=-2.4
large-graph-pan: task duration 1299ms 1361ms +5% ⚠️ z=6.5
large-graph-pan: script duration 418ms 455ms +9% ⚠️ z=2.4
large-graph-pan: TBT 0ms 0ms +0%
large-graph-pan: heap used 63.3 MB 61.8 MB -2%
large-graph-pan: DOM nodes -275 -277 +1% z=-179.1
large-graph-pan: event listeners -144 -143 -1% z=-178.6
large-graph-zoom: avg frame time 17ms 17ms -0%
large-graph-zoom: p95 frame time 17ms 17ms +0%
large-graph-zoom: layout duration 8ms 8ms -10%
large-graph-zoom: style recalc duration 16ms 16ms +2%
large-graph-zoom: layout count 60 60 +0%
large-graph-zoom: style recalc count 65 66 +1%
large-graph-zoom: task duration 1551ms 1625ms +5%
large-graph-zoom: script duration 537ms 564ms +5%
large-graph-zoom: TBT 0ms 0ms +0%
large-graph-zoom: heap used 58.3 MB 62.7 MB +8%
large-graph-zoom: DOM nodes -281 -282 +0%
large-graph-zoom: event listeners -148 -148 +0%
minimap-idle: avg frame time 17ms 17ms +0% z=0.1
minimap-idle: p95 frame time 17ms 17ms +0%
minimap-idle: layout duration 0ms 0ms +0%
minimap-idle: style recalc duration 6ms 7ms +10% z=-3.1
minimap-idle: layout count 0 0 +0%
minimap-idle: style recalc count 8 7 -13% z=-3.8
minimap-idle: task duration 721ms 798ms +11% ⚠️ z=5.7
minimap-idle: script duration 113ms 125ms +11% ⚠️ z=2.7
minimap-idle: TBT 0ms 0ms +0%
minimap-idle: heap used 63.8 MB 64.0 MB +0%
minimap-idle: DOM nodes -277 -279 +1% z=-218.0
minimap-idle: event listeners -144 -144 +0% z=-225.0
subgraph-dom-widget-clipping: avg frame time 17ms 17ms +0% z=0.1
subgraph-dom-widget-clipping: p95 frame time 17ms 17ms +0%
subgraph-dom-widget-clipping: layout duration 0ms 0ms +0%
subgraph-dom-widget-clipping: style recalc duration 11ms 13ms +20% z=0.0
subgraph-dom-widget-clipping: layout count 0 0 +0%
subgraph-dom-widget-clipping: style recalc count 47 48 +1% z=-0.8
subgraph-dom-widget-clipping: task duration 391ms 431ms +10% ⚠️ z=2.9
subgraph-dom-widget-clipping: script duration 126ms 126ms -0% z=-0.4
subgraph-dom-widget-clipping: TBT 0ms 0ms +0%
subgraph-dom-widget-clipping: heap used 53.6 MB 53.5 MB -0%
subgraph-dom-widget-clipping: DOM nodes 20 21 +5% z=-1.1
subgraph-dom-widget-clipping: event listeners 6 8 +33% z=-1.4
subgraph-idle: avg frame time 17ms 17ms +0% z=0.4
subgraph-idle: p95 frame time 17ms 17ms +0%
subgraph-idle: layout duration 0ms 0ms +0%
subgraph-idle: style recalc duration 9ms 9ms +2% z=-1.6
subgraph-idle: layout count 0 0 +0%
subgraph-idle: style recalc count 9 10 +6% z=-2.1
subgraph-idle: task duration 519ms 522ms +1% ⚠️ z=4.9
subgraph-idle: script duration 16ms 18ms +13% z=-0.9
subgraph-idle: TBT 0ms 0ms +0%
subgraph-idle: heap used 45.2 MB 56.8 MB +26%
subgraph-idle: DOM nodes -272 -128 -53% z=-99.9
subgraph-idle: event listeners -148 -72 -51% variance too high
subgraph-mouse-sweep: avg frame time 17ms 17ms -0% z=-0.1
subgraph-mouse-sweep: p95 frame time 17ms 17ms -0%
subgraph-mouse-sweep: layout duration 5ms 4ms -3% z=-0.7
subgraph-mouse-sweep: style recalc duration 40ms 37ms -7% z=-1.7
subgraph-mouse-sweep: layout count 16 16 +0%
subgraph-mouse-sweep: style recalc count 75 76 +1% z=-2.4
subgraph-mouse-sweep: task duration 804ms 821ms +2% z=0.8
subgraph-mouse-sweep: script duration 93ms 99ms +6% z=-0.3
subgraph-mouse-sweep: TBT 0ms 0ms +0%
subgraph-mouse-sweep: heap used 46.6 MB 59.4 MB +27%
subgraph-mouse-sweep: DOM nodes -273 62 -123% z=-2.2
subgraph-mouse-sweep: event listeners -148 4 -103% variance too high
subgraph-transition-enter: avg frame time 17ms 17ms +0%
subgraph-transition-enter: p95 frame time 17ms 17ms +0%
subgraph-transition-enter: layout duration 12ms 13ms +13%
subgraph-transition-enter: style recalc duration 30ms 31ms +3%
subgraph-transition-enter: layout count 13 13 +0%
subgraph-transition-enter: style recalc count 17 17 +0%
subgraph-transition-enter: task duration 1006ms 992ms -1%
subgraph-transition-enter: script duration 37ms 38ms +2%
subgraph-transition-enter: TBT 116ms 146ms +26%
subgraph-transition-enter: heap used 95.0 MB 94.7 MB -0%
subgraph-transition-enter: DOM nodes 13673 13673 +0%
subgraph-transition-enter: event listeners 2371 2371 +0%
viewport-pan-sweep: avg frame time 17ms 17ms -0%
viewport-pan-sweep: p95 frame time 17ms 17ms +0%
viewport-pan-sweep: layout duration 0ms 0ms +0%
viewport-pan-sweep: style recalc duration 42ms 40ms -5%
viewport-pan-sweep: layout count 0 0 +0%
viewport-pan-sweep: style recalc count 249 250 +0%
viewport-pan-sweep: task duration 4741ms 4870ms +3%
viewport-pan-sweep: script duration 1448ms 1479ms +2%
viewport-pan-sweep: TBT 0ms 0ms +0%
viewport-pan-sweep: heap used 68.0 MB 64.5 MB -5%
viewport-pan-sweep: DOM nodes -276 -277 +0%
viewport-pan-sweep: event listeners -128 -127 -1%
vue-large-graph-idle: avg frame time 18ms 18ms -0%
vue-large-graph-idle: p95 frame time 17ms 17ms -0%
vue-large-graph-idle: layout duration 0ms 0ms +0%
vue-large-graph-idle: style recalc duration 0ms 0ms +0%
vue-large-graph-idle: layout count 0 0 +0%
vue-large-graph-idle: style recalc count 0 0 +0%
vue-large-graph-idle: task duration 17603ms 17225ms -2%
vue-large-graph-idle: script duration 539ms 601ms +12%
vue-large-graph-idle: TBT 0ms 0ms +0%
vue-large-graph-idle: heap used 159.3 MB 161.6 MB +1%
vue-large-graph-idle: DOM nodes -8312 -8312 +0%
vue-large-graph-idle: event listeners -16387 -16388 +0%
vue-large-graph-pan: avg frame time 18ms 18ms -0%
vue-large-graph-pan: p95 frame time 17ms 17ms +0%
vue-large-graph-pan: layout duration 0ms 0ms +0%
vue-large-graph-pan: style recalc duration 23ms 19ms -18%
vue-large-graph-pan: layout count 0 0 +0%
vue-large-graph-pan: style recalc count 162 147 -10%
vue-large-graph-pan: task duration 22120ms 21075ms -5%
vue-large-graph-pan: script duration 908ms 924ms +2%
vue-large-graph-pan: TBT 678ms 285ms -58%
vue-large-graph-pan: heap used 149.0 MB 164.7 MB +11%
vue-large-graph-pan: DOM nodes -8312 -8312 +0%
vue-large-graph-pan: event listeners -16381 -16384 +0%
workflow-execution: avg frame time 17ms 17ms -0% z=0.1
workflow-execution: p95 frame time 17ms 17ms +0%
workflow-execution: layout duration 1ms 1ms +69% z=-2.6
workflow-execution: style recalc duration 20ms 24ms +24% z=0.1
workflow-execution: layout count 3 3 +0% z=-3.6
workflow-execution: style recalc count 13 17 +27% z=-0.6
workflow-execution: task duration 114ms 127ms +11% z=0.4
workflow-execution: script duration 10ms 11ms +14% z=-6.1
workflow-execution: TBT 0ms 0ms +0%
workflow-execution: heap used 48.5 MB 48.5 MB +0%
workflow-execution: DOM nodes 119 138 +16% z=-3.3
workflow-execution: event listeners 65 66 +2% z=3.2
Historical variance (last 15 runs)
Metric μ σ CV
canvas-idle: avg frame time 17ms 0ms 0.0%
canvas-idle: layout duration 0ms 0ms 0.0%
canvas-idle: style recalc duration 11ms 1ms 8.2%
canvas-idle: layout count 0 0 0.0%
canvas-idle: style recalc count 11 1 5.0%
canvas-idle: task duration 395ms 31ms 7.9%
canvas-idle: script duration 25ms 2ms 8.8%
canvas-idle: TBT 0ms 0ms 0.0%
canvas-idle: DOM nodes 23 1 5.6%
canvas-idle: event listeners 12 5 40.9%
canvas-mouse-sweep: avg frame time 17ms 0ms 0.0%
canvas-mouse-sweep: layout duration 4ms 0ms 5.4%
canvas-mouse-sweep: style recalc duration 43ms 3ms 7.4%
canvas-mouse-sweep: layout count 12 0 0.0%
canvas-mouse-sweep: style recalc count 79 2 3.0%
canvas-mouse-sweep: task duration 865ms 58ms 6.7%
canvas-mouse-sweep: script duration 136ms 6ms 4.8%
canvas-mouse-sweep: TBT 0ms 0ms 0.0%
canvas-mouse-sweep: DOM nodes 62 3 4.2%
canvas-mouse-sweep: event listeners 8 4 49.4%
canvas-zoom-sweep: avg frame time 17ms 0ms 0.0%
canvas-zoom-sweep: layout duration 1ms 0ms 7.0%
canvas-zoom-sweep: style recalc duration 19ms 2ms 8.0%
canvas-zoom-sweep: layout count 6 0 0.0%
canvas-zoom-sweep: style recalc count 31 0 1.5%
canvas-zoom-sweep: task duration 327ms 23ms 7.1%
canvas-zoom-sweep: script duration 27ms 3ms 11.1%
canvas-zoom-sweep: TBT 0ms 0ms 0.0%
canvas-zoom-sweep: DOM nodes 79 1 1.0%
canvas-zoom-sweep: event listeners 24 5 21.8%
dom-widget-clipping: avg frame time 17ms 0ms 0.0%
dom-widget-clipping: layout duration 0ms 0ms 0.0%
dom-widget-clipping: style recalc duration 10ms 1ms 8.0%
dom-widget-clipping: layout count 0 0 0.0%
dom-widget-clipping: style recalc count 13 0 3.8%
dom-widget-clipping: task duration 365ms 16ms 4.5%
dom-widget-clipping: script duration 68ms 3ms 4.8%
dom-widget-clipping: TBT 0ms 0ms 0.0%
dom-widget-clipping: DOM nodes 22 1 6.4%
dom-widget-clipping: event listeners 8 6 81.2%
large-graph-idle: avg frame time 17ms 0ms 0.0%
large-graph-idle: layout duration 0ms 0ms 0.0%
large-graph-idle: style recalc duration 12ms 1ms 8.6%
large-graph-idle: layout count 0 0 0.0%
large-graph-idle: style recalc count 12 0 2.7%
large-graph-idle: task duration 542ms 54ms 10.0%
large-graph-idle: script duration 102ms 11ms 10.3%
large-graph-idle: TBT 0ms 0ms 0.0%
large-graph-idle: DOM nodes 25 1 3.7%
large-graph-idle: event listeners 26 6 23.2%
large-graph-pan: avg frame time 17ms 0ms 0.0%
large-graph-pan: layout duration 0ms 0ms 0.0%
large-graph-pan: style recalc duration 17ms 1ms 4.6%
large-graph-pan: layout count 0 0 0.0%
large-graph-pan: style recalc count 70 1 0.9%
large-graph-pan: task duration 1082ms 43ms 4.0%
large-graph-pan: script duration 408ms 20ms 4.8%
large-graph-pan: TBT 0ms 0ms 0.0%
large-graph-pan: DOM nodes 19 2 8.7%
large-graph-pan: event listeners 5 1 16.8%
minimap-idle: avg frame time 17ms 0ms 0.0%
minimap-idle: layout duration 0ms 0ms 0.0%
minimap-idle: style recalc duration 10ms 1ms 8.6%
minimap-idle: layout count 0 0 0.0%
minimap-idle: style recalc count 10 1 7.1%
minimap-idle: task duration 527ms 47ms 9.0%
minimap-idle: script duration 98ms 10ms 10.1%
minimap-idle: TBT 0ms 0ms 0.0%
minimap-idle: DOM nodes 19 1 7.1%
minimap-idle: event listeners 5 1 14.4%
subgraph-dom-widget-clipping: avg frame time 17ms 0ms 0.0%
subgraph-dom-widget-clipping: layout duration 0ms 0ms 0.0%
subgraph-dom-widget-clipping: style recalc duration 13ms 1ms 7.4%
subgraph-dom-widget-clipping: layout count 0 0 0.0%
subgraph-dom-widget-clipping: style recalc count 48 1 1.2%
subgraph-dom-widget-clipping: task duration 378ms 18ms 4.9%
subgraph-dom-widget-clipping: script duration 128ms 6ms 4.9%
subgraph-dom-widget-clipping: TBT 0ms 0ms 0.0%
subgraph-dom-widget-clipping: DOM nodes 22 1 5.0%
subgraph-dom-widget-clipping: event listeners 16 6 36.0%
subgraph-idle: avg frame time 17ms 0ms 0.0%
subgraph-idle: layout duration 0ms 0ms 0.0%
subgraph-idle: style recalc duration 10ms 1ms 7.5%
subgraph-idle: layout count 0 0 0.0%
subgraph-idle: style recalc count 11 1 6.0%
subgraph-idle: task duration 370ms 31ms 8.5%
subgraph-idle: script duration 20ms 3ms 13.2%
subgraph-idle: TBT 0ms 0ms 0.0%
subgraph-idle: DOM nodes 22 1 6.9%
subgraph-idle: event listeners 10 7 64.5%
subgraph-mouse-sweep: avg frame time 17ms 0ms 0.0%
subgraph-mouse-sweep: layout duration 5ms 0ms 6.8%
subgraph-mouse-sweep: style recalc duration 42ms 3ms 7.8%
subgraph-mouse-sweep: layout count 16 0 0.0%
subgraph-mouse-sweep: style recalc count 80 2 2.4%
subgraph-mouse-sweep: task duration 766ms 69ms 9.0%
subgraph-mouse-sweep: script duration 101ms 7ms 6.5%
subgraph-mouse-sweep: TBT 0ms 0ms 0.0%
subgraph-mouse-sweep: DOM nodes 67 2 3.3%
subgraph-mouse-sweep: event listeners 8 4 52.6%
workflow-execution: avg frame time 17ms 0ms 0.0%
workflow-execution: layout duration 2ms 0ms 9.4%
workflow-execution: style recalc duration 24ms 2ms 9.1%
workflow-execution: layout count 5 1 11.0%
workflow-execution: style recalc count 18 2 11.5%
workflow-execution: task duration 123ms 11ms 8.8%
workflow-execution: script duration 29ms 3ms 10.2%
workflow-execution: TBT 0ms 0ms 0.0%
workflow-execution: DOM nodes 161 7 4.4%
workflow-execution: event listeners 52 4 8.4%
Trend (last 15 commits on main)
Metric Trend Dir Latest
canvas-idle: avg frame time ▆▃▆▁▆▃▆█▆▆▄▃▃▄▃ ➡️ 17ms
canvas-idle: p95 frame time ➡️ NaNms
canvas-idle: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
canvas-idle: style recalc duration ▇▇▆▆▃█▄▃▄▃▇▄▁▆▇ ➡️ 11ms
canvas-idle: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
canvas-idle: style recalc count █▃▅▂▅▆▃▁▂▁▂▅▆▅▆ ➡️ 12
canvas-idle: task duration ▃▃▃▆▂▃▃▅▆▂█▃▁▃▃ ➡️ 391ms
canvas-idle: script duration ▄▃▅▇▂▅▃▆▇▅█▄▁▅▆ ➡️ 27ms
canvas-idle: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
canvas-idle: heap used ➡️ NaN MB
canvas-idle: DOM nodes █▇▆▅▃▇▃▁▂▂▅▆▆▆▇ ➡️ 24
canvas-idle: event listeners ▅█▅▄▁▅▁▁▁▄▅▅▁▅▄ 📉 11
canvas-mouse-sweep: avg frame time ▆█▆▃▁▃▁▆▆▁▃▆▆▃▃ ➡️ 17ms
canvas-mouse-sweep: p95 frame time ➡️ NaNms
canvas-mouse-sweep: layout duration ▁▃▂▄▁▂▁▃▆▂█▇▆▄▃ ➡️ 4ms
canvas-mouse-sweep: style recalc duration ▄▄▂▄▁▂▃▃▅▄█▆▂▄▄ ➡️ 43ms
canvas-mouse-sweep: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 12
canvas-mouse-sweep: style recalc count █▅▄▃▂▂▁▄▄▅▆▅▂▇▄ ➡️ 79
canvas-mouse-sweep: task duration █▆▄▂▂▃▂▄▄▅█▆▁▆▄ ➡️ 868ms
canvas-mouse-sweep: script duration ▄▅▄▆▄▆▆▆▅▅█▆▁▅▆ ➡️ 139ms
canvas-mouse-sweep: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
canvas-mouse-sweep: heap used ➡️ NaN MB
canvas-mouse-sweep: DOM nodes █▅▃▃▁▂▂▃▂▄▆▅▃▅▅ ➡️ 64
canvas-mouse-sweep: event listeners █▁▁▁▁▁▇▁▁▁██▇▁█ 📈 13
canvas-zoom-sweep: avg frame time ▅▅█▄▅▁▁▁▅▁▁▅▄▅▁ ➡️ 17ms
canvas-zoom-sweep: p95 frame time ➡️ NaNms
canvas-zoom-sweep: layout duration ▆▅▅▄▁▁█▅▃▅▇▆▁▂▆ ➡️ 1ms
canvas-zoom-sweep: style recalc duration ▆▅▄▆▅▃█▆▇▅▇▄▁▃▅ ➡️ 20ms
canvas-zoom-sweep: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 6
canvas-zoom-sweep: style recalc count ▁▁▃▄▆▃▆█▄▄▆▁▆▁▆ ➡️ 32
canvas-zoom-sweep: task duration ▄▂▁▇▂▂▄▅▆▃█▄▁▁▅ ➡️ 338ms
canvas-zoom-sweep: script duration ▃▃▂▇▂▂▅▇▆▅█▄▁▂▆ ➡️ 30ms
canvas-zoom-sweep: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
canvas-zoom-sweep: heap used ➡️ NaN MB
canvas-zoom-sweep: DOM nodes ▄▃▁▅█▁▃▆▄▅▅▃▃▄▃ ➡️ 79
canvas-zoom-sweep: event listeners ▁▁▂▅█▂▁▅▁▅▅▄▁▅▁ ➡️ 19
dom-widget-clipping: avg frame time ▂▄▅▅▂▄█▇▅▇▇▅▅▁▇ ➡️ 17ms
dom-widget-clipping: p95 frame time ➡️ NaNms
dom-widget-clipping: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
dom-widget-clipping: style recalc duration ▆▆▂▆▄▃██▄▁▆▇▆▃▅ ➡️ 10ms
dom-widget-clipping: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
dom-widget-clipping: style recalc count ▇█▅█▅▄█▇▇▁▇▄▇▂▅ ➡️ 13
dom-widget-clipping: task duration ▃▃▁▅▄▃▅▆▅▂▇█▁▅▅ ➡️ 371ms
dom-widget-clipping: script duration ▅▄▄▆▆▅▇▇▆▃█▇▁▇▇ ➡️ 71ms
dom-widget-clipping: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
dom-widget-clipping: heap used ➡️ NaN MB
dom-widget-clipping: DOM nodes ▇▇▄▇▅▄█▇▅▁▅▄▇▃▄ ➡️ 21
dom-widget-clipping: event listeners ▅▅▅▅▁▅██▁▁▁▁█▁▁ 📉 2
large-graph-idle: avg frame time ▅▅▅▅▅▂▁▂▄▅▄▂▂▅█ ➡️ 17ms
large-graph-idle: p95 frame time ➡️ NaNms
large-graph-idle: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
large-graph-idle: style recalc duration ▅▅▅▆▄▅▃▄▅▅▆█▁▄▆ ➡️ 13ms
large-graph-idle: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
large-graph-idle: style recalc count █▆█▃▃▁▃▆▃▆▆▃▆██ ➡️ 12
large-graph-idle: task duration ▂▃▂▆▂▃▃▇▅▃██▁▂▅ ➡️ 569ms
large-graph-idle: script duration ▄▅▄▆▄▅▅▇▆▅█▆▁▃▆ ➡️ 110ms
large-graph-idle: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
large-graph-idle: heap used ➡️ NaN MB
large-graph-idle: DOM nodes ▆█▅▂▅▃▁▂▃▅▅▆▂▆▅ ➡️ 25
large-graph-idle: event listeners ███▇██▄▁▄▇▇█▂█▇ ➡️ 29
large-graph-pan: avg frame time ▆▃▃▆█▃▁█▆▆▆▆█▁▆ ➡️ 17ms
large-graph-pan: p95 frame time ➡️ NaNms
large-graph-pan: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
large-graph-pan: style recalc duration ▃▂▄▄▁▅▂▂▁▄▄█▃▁▂ ➡️ 17ms
large-graph-pan: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
large-graph-pan: style recalc count ▆▃█▂▃▂▂▂▁▇▅▃█▆▃ ➡️ 69
large-graph-pan: task duration ▄▃▄▆▄▄▄▆▄▄█▆▁▂▅ ➡️ 1100ms
large-graph-pan: script duration ▅▄▅▆▆▅▄▆▄▅█▄▁▄▅ ➡️ 413ms
large-graph-pan: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
large-graph-pan: heap used ➡️ NaN MB
large-graph-pan: DOM nodes ▅▃▆▂▄▁▃▁▁▅▁▂█▅▂ ➡️ 18
large-graph-pan: event listeners █▆█▁▁▆▁▁▃▆▁▃██▃ ➡️ 5
minimap-idle: avg frame time ▃▆▆▃█▁█▆▆▃▃▆█▆█ ➡️ 17ms
minimap-idle: p95 frame time ➡️ NaNms
minimap-idle: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
minimap-idle: style recalc duration ▄█▁█▅▅█▅▅▃▅▁▁▄▆ ➡️ 10ms
minimap-idle: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
minimap-idle: style recalc count ▃▅▂▄█▃▆▁▂▅▂▁▅▆▃ ➡️ 9
minimap-idle: task duration ▃▄▁▅▁▃▄▅▇▃█▅▁▁▅ ➡️ 547ms
minimap-idle: script duration ▄▆▃▇▃▅▆▆▇▅█▅▁▃▆ ➡️ 106ms
minimap-idle: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
minimap-idle: heap used ➡️ NaN MB
minimap-idle: DOM nodes ▃▅▂▄█▃▆▁▂▅▂▁▅▆▃ ➡️ 19
minimap-idle: event listeners ▃▃▆▁▁▁▃▁▁▆▁▃█▆▁ ➡️ 4
subgraph-dom-widget-clipping: avg frame time ▅▄▄▄▄▄█▄▄▄▃▁▆▃▃ ➡️ 17ms
subgraph-dom-widget-clipping: p95 frame time ➡️ NaNms
subgraph-dom-widget-clipping: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
subgraph-dom-widget-clipping: style recalc duration ▂▄▃▅▅▃▂▅▇▃▄█▁▄▆ ➡️ 14ms
subgraph-dom-widget-clipping: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
subgraph-dom-widget-clipping: style recalc count ▇█▆▃▆▃▁▆█▇▃▆▇█▅ ➡️ 48
subgraph-dom-widget-clipping: task duration ▂▃▃▆▅▅▂▅█▂▆█▁▂▇ ➡️ 398ms
subgraph-dom-widget-clipping: script duration ▃▃▃▄▅▅▂▄█▂▅▇▁▂▅ ➡️ 131ms
subgraph-dom-widget-clipping: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
subgraph-dom-widget-clipping: heap used ➡️ NaN MB
subgraph-dom-widget-clipping: DOM nodes ▅▇▅▂▅▂▁▅▅▅▁▇▅█▄ ➡️ 22
subgraph-dom-widget-clipping: event listeners ▅▅▅▂▅▁▅██▁▁█▅█▅ 📈 16
subgraph-idle: avg frame time ▆▆█▁▆▃▆▆▆▃▆▁▃▆█ ➡️ 17ms
subgraph-idle: p95 frame time ➡️ NaNms
subgraph-idle: layout duration ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
subgraph-idle: style recalc duration ▁▇▃▆▂▄▂▃▃▆▆▄▃▇█ ➡️ 12ms
subgraph-idle: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0
subgraph-idle: style recalc count ▃▆▃▃▂▅▁▂▁▆▃▃██▇ ➡️ 12
subgraph-idle: task duration ▁▃▁▇▁▁▃▆▅▂█▅▁▁▄ ➡️ 378ms
subgraph-idle: script duration ▁▃▂▇▁▂▃▇▆▂█▅▂▁▅ ➡️ 22ms
subgraph-idle: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
subgraph-idle: heap used ➡️ NaN MB
subgraph-idle: DOM nodes ▃▅▃▂▁▄▁▂▁▅▃▂▇█▇ ➡️ 24
subgraph-idle: event listeners ▁▅▁▁▁▁▁▁▁▅▄▁███ 📈 21
subgraph-mouse-sweep: avg frame time ▅▄▁▃▃▄▆▄▆▃▃█▁▃▃ ➡️ 17ms
subgraph-mouse-sweep: p95 frame time ➡️ NaNms
subgraph-mouse-sweep: layout duration ▁▄▄▄▃▃▅▅▅▂█▇▂▃▆ ➡️ 5ms
subgraph-mouse-sweep: style recalc duration ▃▂▄▅▂▃▄▅█▃█▆▁▂▅ ➡️ 43ms
subgraph-mouse-sweep: layout count ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 16
subgraph-mouse-sweep: style recalc count ▅▂▅▅▁▄▃▅█▅▆▄▂▄▅ ➡️ 81
subgraph-mouse-sweep: task duration ▃▂▄▅▂▄▄▅▇▄█▆▁▃▅ ➡️ 785ms
subgraph-mouse-sweep: script duration ▄▅▄▇▅▅▆▇▆▅██▁▄▆ ➡️ 105ms
subgraph-mouse-sweep: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
subgraph-mouse-sweep: heap used ➡️ NaN MB
subgraph-mouse-sweep: DOM nodes ▅▁▄▅▁▄▃▃█▅▅▄▂▅▃ ➡️ 66
subgraph-mouse-sweep: event listeners ▇▁▂▇▁▂▂▂█▇▂▂▇▇▂ 📈 5
workflow-execution: avg frame time ▆▆▆▄▆▆▃▄▁▄█▆▅▄▆ ➡️ 17ms
workflow-execution: p95 frame time ➡️ NaNms
workflow-execution: layout duration ▁▆▁▃▂▄▃▂▃▃▅█▄▂▅ ➡️ 2ms
workflow-execution: style recalc duration ▃▇▅▇▁▅▆▇█▁██▂▄▆ ➡️ 25ms
workflow-execution: layout count ▁█▂▃▂▃▃▁▃▃▄▃▂▃▂ ➡️ 5
workflow-execution: style recalc count ▃█▅▇▁▄▅▆▅▅▅▅▄▄▂ ➡️ 15
workflow-execution: task duration ▂▅▄▅▁▄▆▆▆▁▇█▁▃▃ ➡️ 120ms
workflow-execution: script duration ▄▃▄▄▃▅▄▅▆▂▇█▁▃▄ ➡️ 29ms
workflow-execution: TBT ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ➡️ 0ms
workflow-execution: heap used ➡️ NaN MB
workflow-execution: DOM nodes ▂█▃▆▁▄▃▅▃█▃▃▄▃▁ ➡️ 152
workflow-execution: event listeners ▅███▁▅███▁██▅█▅ ➡️ 49
Raw data
{
  "timestamp": "2026-07-28T03:06:03.947Z",
  "gitSha": "130865bf372429805ddfdaecbd195e5acf42c36f",
  "branch": "feat/trim-video-rich-widget",
  "measurements": [
    {
      "name": "canvas-idle",
      "durationMs": 2059.8190000000045,
      "styleRecalcs": 8,
      "styleRecalcDurationMs": 8.038,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 573.898,
      "heapDeltaBytes": 3788956,
      "heapUsedBytes": 71526884,
      "domNodes": 16,
      "jsHeapTotalBytes": 20582400,
      "scriptDurationMs": 21.410999999999994,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333332,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "canvas-idle",
      "durationMs": 2032.9910000000382,
      "styleRecalcs": 9,
      "styleRecalcDurationMs": 8.733,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 505.48499999999996,
      "heapDeltaBytes": 3605592,
      "heapUsedBytes": 71401152,
      "domNodes": 18,
      "jsHeapTotalBytes": 20320256,
      "scriptDurationMs": 20.866999999999997,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "canvas-mouse-sweep",
      "durationMs": 1869.512000000043,
      "styleRecalcs": 75,
      "styleRecalcDurationMs": 39.148,
      "layouts": 12,
      "layoutDurationMs": 3.636,
      "taskDurationMs": 949.609,
      "heapDeltaBytes": -16499400,
      "heapUsedBytes": 51470844,
      "domNodes": -278,
      "jsHeapTotalBytes": 20971520,
      "scriptDurationMs": 132.47299999999998,
      "eventListeners": -146,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66999999999998,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "canvas-mouse-sweep",
      "durationMs": 1843.6480000000302,
      "styleRecalcs": 77,
      "styleRecalcDurationMs": 40.213,
      "layouts": 12,
      "layoutDurationMs": 3.2700000000000005,
      "taskDurationMs": 877.7159999999999,
      "heapDeltaBytes": -1678612,
      "heapUsedBytes": 66006228,
      "domNodes": 62,
      "jsHeapTotalBytes": 21368832,
      "scriptDurationMs": 132.006,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "canvas-zoom-sweep",
      "durationMs": 1753.9479999999799,
      "styleRecalcs": 31,
      "styleRecalcDurationMs": 19.97,
      "layouts": 6,
      "layoutDurationMs": 0.6399999999999999,
      "taskDurationMs": 436.541,
      "heapDeltaBytes": 7339384,
      "heapUsedBytes": 75160900,
      "domNodes": 77,
      "jsHeapTotalBytes": 20058112,
      "scriptDurationMs": 25.110000000000003,
      "eventListeners": 19,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "canvas-zoom-sweep",
      "durationMs": 1718.4090000000651,
      "styleRecalcs": 31,
      "styleRecalcDurationMs": 17.551000000000005,
      "layouts": 6,
      "layoutDurationMs": 0.5689999999999998,
      "taskDurationMs": 436.424,
      "heapDeltaBytes": 7024992,
      "heapUsedBytes": 74854416,
      "domNodes": 76,
      "jsHeapTotalBytes": 20320256,
      "scriptDurationMs": 26.307000000000002,
      "eventListeners": 19,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "dom-widget-clipping",
      "durationMs": 615.1049999999714,
      "styleRecalcs": 12,
      "styleRecalcDurationMs": 8.030000000000001,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 397.45,
      "heapDeltaBytes": -12142888,
      "heapUsedBytes": 55901136,
      "domNodes": 20,
      "jsHeapTotalBytes": 21368832,
      "scriptDurationMs": 61.770999999999994,
      "eventListeners": 2,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "dom-widget-clipping",
      "durationMs": 610.904000000005,
      "styleRecalcs": 13,
      "styleRecalcDurationMs": 9.799,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 401.112,
      "heapDeltaBytes": -12184668,
      "heapUsedBytes": 55873652,
      "domNodes": 22,
      "jsHeapTotalBytes": 21106688,
      "scriptDurationMs": 61.940999999999995,
      "eventListeners": 2,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "large-graph-idle",
      "durationMs": 2029.8980000000029,
      "styleRecalcs": 8,
      "styleRecalcDurationMs": 9.368000000000002,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 740.566,
      "heapDeltaBytes": 6773356,
      "heapUsedBytes": 66008600,
      "domNodes": -276,
      "jsHeapTotalBytes": 4526080,
      "scriptDurationMs": 117.542,
      "eventListeners": -142,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "large-graph-idle",
      "durationMs": 2030.6779999999662,
      "styleRecalcs": 9,
      "styleRecalcDurationMs": 9.371,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 796.8449999999999,
      "heapDeltaBytes": 6488852,
      "heapUsedBytes": 65273308,
      "domNodes": -275,
      "jsHeapTotalBytes": 5050368,
      "scriptDurationMs": 125.109,
      "eventListeners": -142,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "large-graph-pan",
      "durationMs": 2183.5230000000365,
      "styleRecalcs": 68,
      "styleRecalcDurationMs": 13.345000000000002,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 1326.617,
      "heapDeltaBytes": 4355820,
      "heapUsedBytes": 64043412,
      "domNodes": -276,
      "jsHeapTotalBytes": 4206592,
      "scriptDurationMs": 440.47,
      "eventListeners": -142,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333332,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "large-graph-pan",
      "durationMs": 2255.1599999999326,
      "styleRecalcs": 68,
      "styleRecalcDurationMs": 13.929,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 1396.006,
      "heapDeltaBytes": 5823020,
      "heapUsedBytes": 65558932,
      "domNodes": -277,
      "jsHeapTotalBytes": 4993024,
      "scriptDurationMs": 470.411,
      "eventListeners": -144,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.670000000000012,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "large-graph-zoom",
      "durationMs": 3246.1000000000126,
      "styleRecalcs": 66,
      "styleRecalcDurationMs": 16.783999999999995,
      "layouts": 60,
      "layoutDurationMs": 7.356,
      "taskDurationMs": 1597.5469999999998,
      "heapDeltaBytes": 1788444,
      "heapUsedBytes": 63214804,
      "domNodes": -280,
      "jsHeapTotalBytes": 7409664,
      "scriptDurationMs": 548.454,
      "eventListeners": -148,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "large-graph-zoom",
      "durationMs": 3365.2640000000247,
      "styleRecalcs": 65,
      "styleRecalcDurationMs": 15.418000000000001,
      "layouts": 60,
      "layoutDurationMs": 7.771,
      "taskDurationMs": 1652.978,
      "heapDeltaBytes": 6286224,
      "heapUsedBytes": 68347896,
      "domNodes": -283,
      "jsHeapTotalBytes": 6885376,
      "scriptDurationMs": 579.056,
      "eventListeners": -148,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "minimap-idle",
      "durationMs": 2038.8740000000212,
      "styleRecalcs": 6,
      "styleRecalcDurationMs": 6.304999999999998,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 753.8460000000001,
      "heapDeltaBytes": 6845208,
      "heapUsedBytes": 67784376,
      "domNodes": -280,
      "jsHeapTotalBytes": 4526080,
      "scriptDurationMs": 121.726,
      "eventListeners": -146,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "minimap-idle",
      "durationMs": 2013.716000000045,
      "styleRecalcs": 8,
      "styleRecalcDurationMs": 7.776999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 842.778,
      "heapDeltaBytes": 6119560,
      "heapUsedBytes": 66518052,
      "domNodes": -278,
      "jsHeapTotalBytes": 5050368,
      "scriptDurationMs": 128.864,
      "eventListeners": -142,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "subgraph-dom-widget-clipping",
      "durationMs": 605.1879999999983,
      "styleRecalcs": 47,
      "styleRecalcDurationMs": 12.101,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 422.1550000000001,
      "heapDeltaBytes": -11623056,
      "heapUsedBytes": 56345524,
      "domNodes": 20,
      "jsHeapTotalBytes": 21106688,
      "scriptDurationMs": 123.36899999999999,
      "eventListeners": 8,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "subgraph-dom-widget-clipping",
      "durationMs": 657.7079999999569,
      "styleRecalcs": 48,
      "styleRecalcDurationMs": 13.328999999999999,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 440.507,
      "heapDeltaBytes": -11933324,
      "heapUsedBytes": 55878976,
      "domNodes": 22,
      "jsHeapTotalBytes": 22417408,
      "scriptDurationMs": 127.96200000000002,
      "eventListeners": 8,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "subgraph-idle",
      "durationMs": 2005.5720000000292,
      "styleRecalcs": 9,
      "styleRecalcDurationMs": 8.729000000000001,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 480.60499999999996,
      "heapDeltaBytes": 3709968,
      "heapUsedBytes": 71631424,
      "domNodes": 18,
      "jsHeapTotalBytes": 20320256,
      "scriptDurationMs": 16.631,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "subgraph-idle",
      "durationMs": 2021.2430000000268,
      "styleRecalcs": 10,
      "styleRecalcDurationMs": 9.659,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 564.0459999999999,
      "heapDeltaBytes": -20373940,
      "heapUsedBytes": 47555864,
      "domNodes": -273,
      "jsHeapTotalBytes": 20971520,
      "scriptDurationMs": 18.991,
      "eventListeners": -148,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    },
    {
      "name": "subgraph-mouse-sweep",
      "durationMs": 1711.7470000000026,
      "styleRecalcs": 76,
      "styleRecalcDurationMs": 37.80799999999999,
      "layouts": 16,
      "layoutDurationMs": 4.936999999999999,
      "taskDurationMs": 825.063,
      "heapDeltaBytes": -5646188,
      "heapUsedBytes": 62327692,
      "domNodes": 62,
      "jsHeapTotalBytes": 21630976,
      "scriptDurationMs": 98.03300000000002,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "subgraph-mouse-sweep",
      "durationMs": 1699.1100000000188,
      "styleRecalcs": 75,
      "styleRecalcDurationMs": 35.938,
      "layouts": 16,
      "layoutDurationMs": 4.0280000000000005,
      "taskDurationMs": 817.7919999999999,
      "heapDeltaBytes": -5609064,
      "heapUsedBytes": 62276036,
      "domNodes": 62,
      "jsHeapTotalBytes": 21368832,
      "scriptDurationMs": 99.37599999999999,
      "eventListeners": 4,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.699999999999818
    },
    {
      "name": "subgraph-transition-enter",
      "durationMs": 1401.544000000058,
      "styleRecalcs": 17,
      "styleRecalcDurationMs": 30.709,
      "layouts": 13,
      "layoutDurationMs": 13.122000000000002,
      "taskDurationMs": 991.784,
      "heapDeltaBytes": 29135328,
      "heapUsedBytes": 99280312,
      "domNodes": 13673,
      "jsHeapTotalBytes": 15990784,
      "scriptDurationMs": 37.61200000000001,
      "eventListeners": 2371,
      "totalBlockingTimeMs": 146,
      "frameDurationMs": 16.66333333333335,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "viewport-pan-sweep",
      "durationMs": 8487.586000000021,
      "styleRecalcs": 250,
      "styleRecalcDurationMs": 41.321999999999996,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 4766.001,
      "heapDeltaBytes": 6973540,
      "heapUsedBytes": 65885020,
      "domNodes": -277,
      "jsHeapTotalBytes": 6275072,
      "scriptDurationMs": 1440.113,
      "eventListeners": -128,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "viewport-pan-sweep",
      "durationMs": 8837.23299999997,
      "styleRecalcs": 249,
      "styleRecalcDurationMs": 38.647,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 4973.14,
      "heapDeltaBytes": 10081032,
      "heapUsedBytes": 69330044,
      "domNodes": -276,
      "jsHeapTotalBytes": 7323648,
      "scriptDurationMs": 1517.888,
      "eventListeners": -126,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "vue-large-graph-idle",
      "durationMs": 17045.241999999973,
      "styleRecalcs": 0,
      "styleRecalcDurationMs": 0,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 17022.66,
      "heapDeltaBytes": -42124132,
      "heapUsedBytes": 167091796,
      "domNodes": -8312,
      "jsHeapTotalBytes": -14094336,
      "scriptDurationMs": 587.08,
      "eventListeners": -16387,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 17.776666666666642,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "vue-large-graph-idle",
      "durationMs": 17460.83199999998,
      "styleRecalcs": 0,
      "styleRecalcDurationMs": 0,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 17426.896000000004,
      "heapDeltaBytes": -37068788,
      "heapUsedBytes": 171724616,
      "domNodes": -8312,
      "jsHeapTotalBytes": -8327168,
      "scriptDurationMs": 615.124,
      "eventListeners": -16389,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 17.776666666666642,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "vue-large-graph-pan",
      "durationMs": 20888.660999999956,
      "styleRecalcs": 145,
      "styleRecalcDurationMs": 18.645000000000024,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 20854.641,
      "heapDeltaBytes": -52990884,
      "heapUsedBytes": 166307780,
      "domNodes": -8312,
      "jsHeapTotalBytes": -15142912,
      "scriptDurationMs": 884.8179999999999,
      "eventListeners": -16385,
      "totalBlockingTimeMs": 221,
      "frameDurationMs": 17.776666666666763,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "vue-large-graph-pan",
      "durationMs": 21344.16799999997,
      "styleRecalcs": 148,
      "styleRecalcDurationMs": 19.710000000000004,
      "layouts": 0,
      "layoutDurationMs": 0,
      "taskDurationMs": 21294.55,
      "heapDeltaBytes": -29362640,
      "heapUsedBytes": 179019964,
      "domNodes": -8312,
      "jsHeapTotalBytes": -10510336,
      "scriptDurationMs": 962.496,
      "eventListeners": -16383,
      "totalBlockingTimeMs": 349,
      "frameDurationMs": 17.776666666666763,
      "p95FrameDurationMs": 16.799999999999272
    },
    {
      "name": "workflow-execution",
      "durationMs": 459.8109999999451,
      "styleRecalcs": 14,
      "styleRecalcDurationMs": 23.066000000000003,
      "layouts": 3,
      "layoutDurationMs": 1.0849999999999997,
      "taskDurationMs": 125.483,
      "heapDeltaBytes": -15952132,
      "heapUsedBytes": 50972184,
      "domNodes": 130,
      "jsHeapTotalBytes": 7999488,
      "scriptDurationMs": 11.468,
      "eventListeners": 67,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.66333333333332,
      "p95FrameDurationMs": 16.700000000000728
    },
    {
      "name": "workflow-execution",
      "durationMs": 488.99599999992915,
      "styleRecalcs": 19,
      "styleRecalcDurationMs": 25.719999999999995,
      "layouts": 3,
      "layoutDurationMs": 1.2609999999999997,
      "taskDurationMs": 129.2,
      "heapDeltaBytes": -16045316,
      "heapUsedBytes": 50742064,
      "domNodes": 145,
      "jsHeapTotalBytes": 7737344,
      "scriptDurationMs": 10.714000000000002,
      "eventListeners": 65,
      "totalBlockingTimeMs": 0,
      "frameDurationMs": 16.666666666666668,
      "p95FrameDurationMs": 16.800000000000182
    }
  ]
}

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a complete VIDEO_EDIT widget with typed registration, video source and filmstrip loading, trim playback, crop editing, ratio locking, localization, design tokens, and Vue panel integration. Comprehensive tests cover conversion, metadata, media loading, model synchronization, pointer interactions, and widget wiring.

Changes

Video editing widget

Layer / File(s) Summary
Widget contracts and registration
src/schemas/nodeDef/..., src/lib/litegraph/..., src/renderer/extensions/vueNodes/widgets/..., src/scripts/widgets.ts, src/locales/en/main.json, packages/design-system/src/css/style.css
Registers the VIDEO_EDIT schema, LiteGraph widget types, Vue widget loader, widget constructor, localization, expansion behavior, media suppression, and design tokens.
Video source and filmstrip pipeline
src/composables/video/useVideoSourceUrl.ts, src/composables/video/useVideoFilmstrip.ts, src/utils/videoMetadataUtil.ts
Resolves source URLs, validates metadata responses, samples thumbnails, and exposes reactive loading and media metadata.
Frame, trim, and scrub state
src/composables/video/useVideoEditModel.ts, src/composables/video/useTrimPlayback.ts, src/composables/video/useTimelineScrub.ts, src/utils/videoFrameUtil.ts
Maps frame state to widget values, synchronizes preview playback, clamps trim boundaries, and handles pointer scrubbing.
Crop geometry and ratio locking
src/composables/video/useCropBoxEditor.ts, src/composables/video/useCropRatioLock.ts
Adds draggable and resizable crop bounds with source clamping, minimum dimensions, and locked aspect-ratio handling.
Trim, crop, and panel UI
src/components/videoEdit/*, src/composables/video/useVideoEditFormats.ts
Adds the filmstrip, crop overlay, editing panel, metadata formatting, feature toggles, and widget-to-panel model bindings. Tests cover the added UI and composables.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WidgetVideoEdit
  participant VideoEditPanel
  participant useVideoFilmstrip
  participant useTrimPlayback
  participant HTMLVideoElement
  WidgetVideoEdit->>useVideoFilmstrip: load video URL and metadata
  useVideoFilmstrip-->>VideoEditPanel: provide thumbnails and frame metadata
  VideoEditPanel->>useTrimPlayback: handle scrub or playback change
  useTrimPlayback->>HTMLVideoElement: seek or play preview
  HTMLVideoElement-->>VideoEditPanel: emit metadata and time updates
  VideoEditPanel-->>WidgetVideoEdit: update trim and crop model values
Loading

Suggested reviewers: dante01yoon

🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 inconclusive)

Check name Status Explanation Resolution
End-To-End Regression Coverage For Fixes ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
Adr Compliance For Entity/Litegraph Changes ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: adding a VIDEO_EDIT rich widget with trim and crop editors.
Description check ✅ Passed The description covers the main changes and includes screenshots, though it does not fully follow the requested template sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/trim-video-rich-widget

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.

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.63283% with 48 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/components/videoEdit/VideoEditPanel.vue 79.68% 26 Missing ⚠️
src/components/videoEdit/VideoFilmstripTrim.vue 84.21% 14 Missing and 1 partial ⚠️
src/composables/video/useTrimPlayback.ts 97.26% 2 Missing ⚠️
src/composables/video/useVideoEditModel.ts 97.36% 2 Missing ⚠️
...erer/extensions/vueNodes/components/LGraphNode.vue 66.66% 1 Missing ⚠️
...vueNodes/widgets/composables/useVideoEditWidget.ts 92.30% 1 Missing ⚠️
...nsions/vueNodes/widgets/registry/widgetRegistry.ts 50.00% 1 Missing ⚠️
@@            Coverage Diff             @@
##             main   #14118      +/-   ##
==========================================
- Coverage   79.24%   79.13%   -0.12%     
==========================================
  Files        1730     1750      +20     
  Lines       96155    97671    +1516     
  Branches    30905    31339     +434     
==========================================
+ Hits        76197    77289    +1092     
- Misses      19578    19991     +413     
- Partials      380      391      +11     
Flag Coverage Δ
unit 68.69% <89.63%> (+0.28%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/components/videoEdit/WidgetVideoEdit.vue 100.00% <100.00%> (ø)
src/composables/video/useTimelineScrub.ts 100.00% <100.00%> (ø)
src/composables/video/useVideoEditFormats.ts 100.00% <100.00%> (ø)
src/lib/litegraph/src/types/widgets.ts 83.33% <ø> (ø)
src/lib/litegraph/src/widgets/widgetMap.ts 54.28% <ø> (ø)
...nsions/vueNodes/widgets/components/layout/index.ts 100.00% <100.00%> (ø)
src/schemas/nodeDef/nodeDefSchemaV2.ts 90.90% <100.00%> (+0.43%) ⬆️
src/scripts/widgets.ts 71.42% <ø> (ø)
...erer/extensions/vueNodes/components/LGraphNode.vue 79.36% <66.66%> (-0.12%) ⬇️
...vueNodes/widgets/composables/useVideoEditWidget.ts 92.30% <92.30%> (ø)
... and 5 more

... and 45 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/videoEdit/VideoEditPanel.vue`:
- Around line 269-279: Update the useTrimPlayback call in VideoEditPanel to pass
the reactive trimEnabled toggle to its trimEnabled option instead of the static
hasTrim feature flag, so playback clamping follows the user’s current Trim Video
setting.

In `@src/components/videoEdit/VideoFilmstripTrim.vue`:
- Around line 198-204: Replace the hardcoded 1rem and 2rem offsets in
timelineInsetLeftStyle, leftDimStyle, rightDimStyle, and selectionStyle with
values derived from HANDLE_WIDTH_PX, preserving the existing normalized
positioning and dimming behavior while keeping all overlay calculations
consistent with the px-based pointer math.
- Around line 28-43: Add a keyboard-accessible scrubbing path to the track
element handled by startScrubDrag and the corresponding playhead handle around
the referenced template section. Make the interactive control focusable with an
appropriate tabindex, expose its current position and bounds accessibly, and
handle keyboard input to move the playhead using established frame-update logic
while preserving pointer scrubbing and disabled behavior.

In `@src/composables/video/useVideoEditModel.ts`:
- Around line 44-74: Update the startFrame and endFrame setters in the trim
computed values to clamp each dragged handle against the other handle’s current
position, preventing non-positive durations when handles cross. Preserve
duration 0 only for the intentional “trim to video end” case, so endFrame does
not jump to frameMax after crossing; add a regression test in
useVideoEditModel.test.ts covering this behavior.

In `@src/composables/video/useVideoFilmstrip.ts`:
- Around line 132-142: Update the sampling flow in useVideoFilmstrip around
sampleFilmstripFrames so it accepts and invokes an isStale callback after each
sampled frame, using the existing loadId/url staleness state. Stop the sampling
loop immediately when the callback reports stale, while preserving the final
isLoadStale guard before assigning thumbnails.value.
- Around line 14-31: Update waitForEvent to reject after a bounded timeout in
addition to the existing event and error handlers, ensuring cleanup clears the
timer and removes listeners on every completion path. Keep successful event
resolution unchanged so sampleFilmstripFrames cannot remain loading indefinitely
when seeked never arrives.

In `@src/composables/video/useVideoSourceUrl.test.ts`:
- Around line 64-124: Add coverage in the useVideoSourceUrl test suite for the
subgraph-resolution path: configure getInputNode to return a node recognized by
isSubgraphNode(), mock resolveSubgraphOutputLink() with a representative output,
and assert mountSource resolves that output as videoUrl. Also cover the relevant
null/empty fallback behavior for the resolver while preserving existing direct
upstream and unlinked-input cases.

In `@src/composables/video/useVideoSourceUrl.ts`:
- Around line 91-102: The updateVideoUrl watchers in the video source composable
currently deep-watch graph-wide nodeOutputs and nodePreviewImages for every
widget; replace them with per-source-node reactive lookups keyed to the resolved
source node so unrelated graph mutations do not retrigger this widget. Also
ensure the reactive dependencies include video-input rewiring, so
resolveSourceNode changes cause videoUrl to refresh before the new source
executes, while preserving updates for the source file widget value and relevant
output/preview changes.

In `@src/lib/litegraph/src/types/widgets.ts`:
- Around line 391-394: Extract and export a named VideoEditTrim type from the
widgets types module, containing start_time and duration, then replace the
inline trim shape in VideoEditValue and the duplicated trim types used by
setTrim and the default-value construction in useVideoEditWidget.ts with
VideoEditTrim. Update imports as needed while preserving the existing optional
trim behavior.

In
`@src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts`:
- Around line 38-41: Update the options assertion in the relevant video edit
widget test to also verify the rendering contract: canvasOnly must be false and
hideInPanel must be true, while retaining the existing feature and serialization
assertions.

In `@src/utils/videoMetadataUtil.ts`:
- Around line 5-12: Update the zVideoMetadata schema to reject invalid values
before timeline calculations: require width and height to be positive integers,
size and duration to be nonnegative numbers, fps to be positive when present,
and frame_count to be a nonnegative integer when present. Preserve the existing
nullable behavior for fps, duration, and frame_count.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0822b032-03ec-4ea8-86d6-f3fe97102b91

📥 Commits

Reviewing files that changed from the base of the PR and between a98cfa3 and ec1328d.

📒 Files selected for processing (41)
  • packages/design-system/src/css/style.css
  • src/components/videoEdit/VideoCropOverlay.test.ts
  • src/components/videoEdit/VideoCropOverlay.vue
  • src/components/videoEdit/VideoEditPanel.test.ts
  • src/components/videoEdit/VideoEditPanel.vue
  • src/components/videoEdit/VideoFilmstripTrim.test.ts
  • src/components/videoEdit/VideoFilmstripTrim.vue
  • src/components/videoEdit/WidgetVideoEdit.test.ts
  • src/components/videoEdit/WidgetVideoEdit.vue
  • src/composables/useRangeEditor.test.ts
  • src/composables/useRangeEditor.ts
  • src/composables/video/useCropBoxEditor.test.ts
  • src/composables/video/useCropBoxEditor.ts
  • src/composables/video/useCropRatioLock.test.ts
  • src/composables/video/useCropRatioLock.ts
  • src/composables/video/useTimelineScrub.ts
  • src/composables/video/useTrimPlayback.test.ts
  • src/composables/video/useTrimPlayback.ts
  • src/composables/video/useVideoEditFormats.test.ts
  • src/composables/video/useVideoEditFormats.ts
  • src/composables/video/useVideoEditModel.test.ts
  • src/composables/video/useVideoEditModel.ts
  • src/composables/video/useVideoFilmstrip.test.ts
  • src/composables/video/useVideoFilmstrip.ts
  • src/composables/video/useVideoSourceUrl.test.ts
  • src/composables/video/useVideoSourceUrl.ts
  • src/lib/litegraph/src/types/widgets.ts
  • src/lib/litegraph/src/widgets/VideoEditWidget.ts
  • src/lib/litegraph/src/widgets/widgetMap.ts
  • src/locales/en/main.json
  • src/renderer/extensions/vueNodes/components/LGraphNode.vue
  • src/renderer/extensions/vueNodes/widgets/components/layout/index.ts
  • src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts
  • src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.ts
  • src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts
  • src/schemas/nodeDef/nodeDefSchemaV2.ts
  • src/scripts/widgets.ts
  • src/utils/videoFrameUtil.test.ts
  • src/utils/videoFrameUtil.ts
  • src/utils/videoMetadataUtil.test.ts
  • src/utils/videoMetadataUtil.ts

Comment thread src/components/videoEdit/VideoEditPanel.vue
Comment thread src/components/videoEdit/VideoFilmstripTrim.vue
Comment thread src/components/videoEdit/VideoFilmstripTrim.vue
Comment thread src/composables/video/useVideoEditModel.ts
Comment thread src/composables/video/useVideoFilmstrip.ts Outdated
Comment thread src/composables/video/useVideoSourceUrl.test.ts
Comment thread src/composables/video/useVideoSourceUrl.ts
Comment thread src/lib/litegraph/src/types/widgets.ts
Comment thread src/utils/videoMetadataUtil.ts
@jtydhr88
jtydhr88 force-pushed the feat/trim-video-rich-widget branch from ec1328d to b244d6f Compare July 26, 2026 20:39
@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
pillar5-agent Ready Ready Preview, Comment Jul 26, 2026 10:46pm

Request Review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 8

♻️ Duplicate comments (1)
src/components/videoEdit/VideoEditPanel.vue (1)

269-279: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Trim "enabled" toggle vs. trim "feature exists" flag inconsistently applied.

useTrimPlayback is wired with hasTrimTimeline: hasTrim (269-279) — the static "trim feature exists" flag — while trimEnabled (the user's toggle, defineModel at line 249) is never passed to the composable at all. Similarly, metadataRows (375-393) branches on hasTrim.value alone to decide whether to show "selected of total" duration/frame text, rather than hasTrim.value && trimEnabled.value as is correctly done elsewhere in this same file (lines 80, 87, 94).

Net effect: after a user trims a clip and then turns the "Trim Video" toggle off (retaining stale startFrame/endFrame), the preview playback clamping and the metadata panel may still behave/display as if trimming were active, since neither path checks the reactive trimEnabled state.

A previous review round flagged this exact confusion for the (differently-named) useTrimPlayback parameter and was marked addressed, but the current code still funnels the static hasTrim flag in under the new hasTrimTimeline key — worth confirming whether the composable now expects a separate trimEnabled input that simply isn't being passed.

🐛 Proposed fix for `metadataRows`
   {
     label: t('videoEdit.duration'),
-    value: hasTrim.value
+    value: hasTrim.value && trimEnabled.value
       ? t('videoEdit.selectedOfTotal', {
           selected: formatDuration(selectedDurationSeconds.value),
           total: formatDuration(duration)
         })
       : formatDuration(duration)
   },
   {
     label: t('videoEdit.frames'),
-    value: hasTrim.value
+    value: hasTrim.value && trimEnabled.value
       ? t('videoEdit.selectedOfTotal', {
           selected: selectedFrameCount.value,
           total: effectiveTotalFrames.value
         })
       : String(effectiveTotalFrames.value)
   },

Please verify useTrimPlayback's contract for hasTrimTimeline to determine if trimEnabled also needs to be passed for correct clamping behavior:

#!/bin/bash
fd -a 'useTrimPlayback.ts' src/composables
cat -n src/composables/video/useTrimPlayback.ts
rg -n 'hasTrimTimeline|trimEnabled' src/composables/video/useTrimPlayback.ts src/composables/video/useTrimPlayback.test.ts

Also applies to: 375-393

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/VideoEditPanel.vue` around lines 269 - 279, Update
the VideoEditPanel.vue integration so trim behavior depends on the reactive
trimEnabled state: verify the useTrimPlayback contract and pass trimEnabled
separately if required instead of relying only on hasTrimTimeline, while
preserving the static feature-availability flag. In metadataRows, require both
hasTrim.value and trimEnabled.value before displaying selected-versus-total trim
duration or frame text.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/composables/video/useTimelineScrub.ts`:
- Around line 1-97: Add a colocated useTimelineScrub.test.ts covering
pointer-to-frame boundary clamping, scrubToFrame enforcement of scrubMin and
scrubMax, and startScrubDrag lifecycle cleanup including pointer release and
scope disposal. Follow the existing useRangeEditor.test.ts testing pattern and
include edge cases where pointer positions fall outside the track bounds.
- Around line 35-46: Extract the shared pointer-to-normalized-position
calculation from pointerToFrame and use it in both pointerToFrame and
useRangeEditor’s pointerToValue. The helper should accept clientX, the element’s
bounding rect, and the horizontal inset, preserve clamping to [0,1] and the
minimum content width, and leave each caller’s frame/value denormalization
unchanged.

In `@src/composables/video/useTrimPlayback.ts`:
- Around line 40-50: Update waitForVideoSeek to include the same bounded timeout
behavior as useVideoFilmstrip.ts’s waitForEvent, using the established
SEEK_EVENT_TIMEOUT_MS symbol if available. Ensure the timeout settles the
promise and removes both seeked/error listeners, while preserving immediate
resolution when either event fires so isSeeking cannot remain stuck.

In `@src/composables/video/useVideoEditModel.ts`:
- Around line 87-113: Clamp cropBounds setter values in cropBounds within
useVideoEditModel.ts to the source dimensions before storing them, constraining
x/y and the crop’s right/bottom edges to the valid [0, width.value] and [0,
height.value] ranges while preserving the full-frame reset behavior; reuse the
existing clamp approach used by startFrame/endFrame. In
src/composables/video/useVideoEditModel.test.ts lines 108-147, add a regression
test for an out-of-bounds cropBounds write, such as negative x or oversized
width, and assert the stored bounds are clamped.

In `@src/composables/video/useVideoFilmstrip.test.ts`:
- Around line 99-198: Add an error-path test for useVideoFilmstrip that makes
the mocked video element emit error instead of loadedmetadata, then await
loading to finish and assert error.value is set while thumbnails, totalFrames,
and other derived state are reset appropriately. Reuse the existing
runWithScope, video mock setup, and cleanup patterns, and target the loadVideo
failure branch.

In `@src/composables/video/useVideoFilmstrip.ts`:
- Around line 145-178: Separate the metadata-loading flow from thumbnail
sampling so errors from sampleFilmstripFrames or its per-frame callback are
handled without entering the catch that resets duration, totalFrames, width,
height, fps, and fileSize. Preserve the successfully resolved metadata and only
clear thumbnails or record the thumbnail-specific failure, while keeping
stale-load checks and existing metadata error handling intact.

In
`@src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts`:
- Around line 46-58: Add a test alongside the existing trim-only case in
useVideoEditWidget that invokes the widget with features set to only crop and
verifies the created value contains only the crop defaults, with options
preserving features: ['crop']. This should specifically exercise the crop
feature gate independently of trim.

In `@src/utils/videoMetadataUtil.test.ts`:
- Around line 61-67: The malformed-response coverage in the fetchVideoMetadata
tests does not exercise the numeric schema guards. Add a test using the expected
metadata shape but an invalid numeric value, such as a negative width, and
assert that fetchVideoMetadata returns undefined; include the relevant positive,
nonnegative, or integer boundary cases as appropriate while preserving the
existing malformed-shape test.

---

Duplicate comments:
In `@src/components/videoEdit/VideoEditPanel.vue`:
- Around line 269-279: Update the VideoEditPanel.vue integration so trim
behavior depends on the reactive trimEnabled state: verify the useTrimPlayback
contract and pass trimEnabled separately if required instead of relying only on
hasTrimTimeline, while preserving the static feature-availability flag. In
metadataRows, require both hasTrim.value and trimEnabled.value before displaying
selected-versus-total trim duration or frame text.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4fdfad45-0c40-4e46-b9e1-95c88f0c43e8

📥 Commits

Reviewing files that changed from the base of the PR and between ec1328d and b244d6f.

📒 Files selected for processing (41)
  • packages/design-system/src/css/style.css
  • src/components/videoEdit/VideoCropOverlay.test.ts
  • src/components/videoEdit/VideoCropOverlay.vue
  • src/components/videoEdit/VideoEditPanel.test.ts
  • src/components/videoEdit/VideoEditPanel.vue
  • src/components/videoEdit/VideoFilmstripTrim.test.ts
  • src/components/videoEdit/VideoFilmstripTrim.vue
  • src/components/videoEdit/WidgetVideoEdit.test.ts
  • src/components/videoEdit/WidgetVideoEdit.vue
  • src/composables/useRangeEditor.test.ts
  • src/composables/useRangeEditor.ts
  • src/composables/video/useCropBoxEditor.test.ts
  • src/composables/video/useCropBoxEditor.ts
  • src/composables/video/useCropRatioLock.test.ts
  • src/composables/video/useCropRatioLock.ts
  • src/composables/video/useTimelineScrub.ts
  • src/composables/video/useTrimPlayback.test.ts
  • src/composables/video/useTrimPlayback.ts
  • src/composables/video/useVideoEditFormats.test.ts
  • src/composables/video/useVideoEditFormats.ts
  • src/composables/video/useVideoEditModel.test.ts
  • src/composables/video/useVideoEditModel.ts
  • src/composables/video/useVideoFilmstrip.test.ts
  • src/composables/video/useVideoFilmstrip.ts
  • src/composables/video/useVideoSourceUrl.test.ts
  • src/composables/video/useVideoSourceUrl.ts
  • src/lib/litegraph/src/types/widgets.ts
  • src/lib/litegraph/src/widgets/VideoEditWidget.ts
  • src/lib/litegraph/src/widgets/widgetMap.ts
  • src/locales/en/main.json
  • src/renderer/extensions/vueNodes/components/LGraphNode.vue
  • src/renderer/extensions/vueNodes/widgets/components/layout/index.ts
  • src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts
  • src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.ts
  • src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts
  • src/schemas/nodeDef/nodeDefSchemaV2.ts
  • src/scripts/widgets.ts
  • src/utils/videoFrameUtil.test.ts
  • src/utils/videoFrameUtil.ts
  • src/utils/videoMetadataUtil.test.ts
  • src/utils/videoMetadataUtil.ts

Comment on lines +1 to +97
import { onScopeDispose, ref } from 'vue'
import type { Ref } from 'vue'

import { clamp } from 'es-toolkit'

import { denormalize } from '@/utils/mathUtil'

interface UseTimelineScrubOptions {
trackRef: Ref<HTMLElement | null>
frameMax: Ref<number>
scrubMin: Ref<number>
scrubMax: Ref<number>
contentInsetX: number
isDisabled: () => boolean
onScrub: (frame: number) => void
}

export function useTimelineScrub(
playheadFrame: Ref<number>,
options: UseTimelineScrubOptions
) {
const {
trackRef,
frameMax,
scrubMin,
scrubMax,
contentInsetX,
isDisabled,
onScrub
} = options

const isScrubDragging = ref(false)
let cleanupScrubDrag: (() => void) | null = null

function pointerToFrame(event: PointerEvent) {
const el = trackRef.value
if (!el) return playheadFrame.value
const rect = el.getBoundingClientRect()
const contentWidth = Math.max(rect.width - 2 * contentInsetX, 1)
const normalized = clamp(
(event.clientX - rect.left - contentInsetX) / contentWidth,
0,
1
)
return Math.round(denormalize(normalized, 0, frameMax.value))
}

function scrubToFrame(frame: number) {
const clamped = clamp(frame, scrubMin.value, scrubMax.value)
playheadFrame.value = clamped
onScrub(clamped)
}

function updateScrubFromPointer(event: PointerEvent) {
const frame = pointerToFrame(event)
if (frame === playheadFrame.value) return
scrubToFrame(frame)
}

function startScrubDrag(event: PointerEvent) {
if (isDisabled() || event.button !== 0) return

const el = trackRef.value
if (!el) return

cleanupScrubDrag?.()

isScrubDragging.value = true
scrubToFrame(pointerToFrame(event))
el.setPointerCapture(event.pointerId)

const onMove = (moveEvent: PointerEvent) => {
updateScrubFromPointer(moveEvent)
}

const endDrag = () => {
isScrubDragging.value = false
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', endDrag)
el.removeEventListener('lostpointercapture', endDrag)
cleanupScrubDrag = null
}

cleanupScrubDrag = endDrag

el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', endDrag)
el.addEventListener('lostpointercapture', endDrag)
}

onScopeDispose(() => {
isScrubDragging.value = false
cleanupScrubDrag?.()
})

return { isScrubDragging, startScrubDrag }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing unit tests for useTimelineScrub.

This composable performs non-trivial pointer-to-frame math and drag-state clamping, similar in complexity to useRangeEditor.ts and useCropBoxEditor.ts, both of which have colocated test files. No useTimelineScrub.test.ts is present here. Consider adding tests for pointerToFrame clamping, scrubToFrame bounds (scrubMin/scrubMax), and drag lifecycle cleanup, following the existing pattern in useRangeEditor.test.ts.

As per path instructions, .agents/checks/test-quality.md requires "cover key edge cases beyond happy paths ... boundary collisions/clamping" for new behaviors such as this scrub-clamping logic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useTimelineScrub.ts` around lines 1 - 97, Add a
colocated useTimelineScrub.test.ts covering pointer-to-frame boundary clamping,
scrubToFrame enforcement of scrubMin and scrubMax, and startScrubDrag lifecycle
cleanup including pointer release and scope disposal. Follow the existing
useRangeEditor.test.ts testing pattern and include edge cases where pointer
positions fall outside the track bounds.

Source: Path instructions

Comment on lines +35 to +46
function pointerToFrame(event: PointerEvent) {
const el = trackRef.value
if (!el) return playheadFrame.value
const rect = el.getBoundingClientRect()
const contentWidth = Math.max(rect.width - 2 * contentInsetX, 1)
const normalized = clamp(
(event.clientX - rect.left - contentInsetX) / contentWidth,
0,
1
)
return Math.round(denormalize(normalized, 0, frameMax.value))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate pointer-to-normalized-position math vs. useRangeEditor.ts.

This inset-aware normalization (clientX - rect.left - inset, rect.width - 2*inset, clamp to [0,1]) is nearly identical to pointerToValue in src/composables/useRangeEditor.ts (lines 35-41). Extracting a shared helper (e.g. pointerToNormalized(clientX, rect, inset)) would prevent the two implementations from drifting apart as inset handling evolves.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useTimelineScrub.ts` around lines 35 - 46, Extract the
shared pointer-to-normalized-position calculation from pointerToFrame and use it
in both pointerToFrame and useRangeEditor’s pointerToValue. The helper should
accept clientX, the element’s bounding rect, and the horizontal inset, preserve
clamping to [0,1] and the minimum content width, and leave each caller’s
frame/value denormalization unchanged.

Comment thread src/composables/video/useTrimPlayback.ts
Comment on lines +87 to +113
const cropBounds = computed<Bounds>({
get: () => {
const crop = modelValue.value.crop
if (!crop || crop.width <= 0 || crop.height <= 0) {
return { x: 0, y: 0, width: width.value, height: height.value }
}
return { ...crop }
},
set: (next) => {
const coversFullFrame =
next.x <= 0 &&
next.y <= 0 &&
next.width >= width.value &&
next.height >= height.value
modelValue.value = {
...modelValue.value,
crop: coversFullFrame
? { x: 0, y: 0, width: 0, height: 0 }
: {
x: Math.round(next.x),
y: Math.round(next.y),
width: Math.round(next.width),
height: Math.round(next.height)
}
}
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Crop bounds aren't clamped to source dimensions, and no test covers it. The cropBounds setter in useVideoEditModel.ts only special-cases the "covers full frame" reset; any other out-of-range value (negative x/y, or x+width/y+height beyond the source size) is stored verbatim, unlike the meticulously-clamped trim setters in the same file.

  • src/composables/video/useVideoEditModel.ts#L87-L113: clamp next.x/y/width/height against [0, width.value]/[0, height.value] before storing, mirroring the clamp() usage already used for startFrame/endFrame.
  • src/composables/video/useVideoEditModel.test.ts#L108-L147: add a regression test asserting that an out-of-bounds cropBounds write (e.g. negative x or oversized width) gets clamped, following the same pattern used for the trim-handle-crossover regression tests at lines 84-105.
📍 Affects 2 files
  • src/composables/video/useVideoEditModel.ts#L87-L113 (this comment)
  • src/composables/video/useVideoEditModel.test.ts#L108-L147
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useVideoEditModel.ts` around lines 87 - 113, Clamp
cropBounds setter values in cropBounds within useVideoEditModel.ts to the source
dimensions before storing them, constraining x/y and the crop’s right/bottom
edges to the valid [0, width.value] and [0, height.value] ranges while
preserving the full-frame reset behavior; reuse the existing clamp approach used
by startFrame/endFrame. In src/composables/video/useVideoEditModel.test.ts lines
108-147, add a regression test for an out-of-bounds cropBounds write, such as
negative x or oversized width, and assert the stored bounds are clamped.

Comment thread src/composables/video/useVideoFilmstrip.test.ts
Comment on lines +145 to +178
const metadata = await fetchVideoMetadata(url)

if (isLoadStale(loadId, url)) return

fps.value = metadata?.fps ?? options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = metadata?.size
totalFrames.value =
metadata?.frame_count ??
Math.max(Math.round(videoDuration * fps.value), 1)

const sampledThumbnails = await sampleFilmstripFrames(
video,
canvas,
context,
videoDuration,
sampleCount,
() => isLoadStale(loadId, url)
)

if (isLoadStale(loadId, url)) return

thumbnails.value = sampledThumbnails
} catch (loadError) {
if (isLoadStale(loadId, url)) return
error.value =
loadError instanceof Error ? loadError.message : 'Failed to load video'
duration.value = 0
totalFrames.value = 0
width.value = 0
height.value = 0
fps.value = options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = undefined
thumbnails.value = []
} finally {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Isolate thumbnail-sampling failures from already-fetched metadata.

The single try/catch around lines 134-184 means any exception thrown while sampling filmstrip frames (e.g. captureFrame's canvas.toDataURL throwing a SecurityError on a tainted canvas, or any other per-frame draw failure) falls into the same catch block that resets duration, totalFrames, width, height, fps, and fileSize — even though those were already successfully resolved from loadedmetadata and fetchVideoMetadata before sampling began. A thumbnail-only failure shouldn't wipe out valid, already-fetched metadata.

♻️ Proposed fix
-      const sampledThumbnails = await sampleFilmstripFrames(
-        video,
-        canvas,
-        context,
-        videoDuration,
-        sampleCount,
-        () => isLoadStale(loadId, url)
-      )
-
-      if (isLoadStale(loadId, url)) return
-
-      thumbnails.value = sampledThumbnails
+      let sampledThumbnails: string[] = []
+      try {
+        sampledThumbnails = await sampleFilmstripFrames(
+          video,
+          canvas,
+          context,
+          videoDuration,
+          sampleCount,
+          () => isLoadStale(loadId, url)
+        )
+      } catch {
+        // Preserve already-fetched duration/fps/size even if capture fails
+      }
+
+      if (isLoadStale(loadId, url)) return
+
+      thumbnails.value = sampledThumbnails
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const metadata = await fetchVideoMetadata(url)
if (isLoadStale(loadId, url)) return
fps.value = metadata?.fps ?? options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = metadata?.size
totalFrames.value =
metadata?.frame_count ??
Math.max(Math.round(videoDuration * fps.value), 1)
const sampledThumbnails = await sampleFilmstripFrames(
video,
canvas,
context,
videoDuration,
sampleCount,
() => isLoadStale(loadId, url)
)
if (isLoadStale(loadId, url)) return
thumbnails.value = sampledThumbnails
} catch (loadError) {
if (isLoadStale(loadId, url)) return
error.value =
loadError instanceof Error ? loadError.message : 'Failed to load video'
duration.value = 0
totalFrames.value = 0
width.value = 0
height.value = 0
fps.value = options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = undefined
thumbnails.value = []
} finally {
const metadata = await fetchVideoMetadata(url)
if (isLoadStale(loadId, url)) return
fps.value = metadata?.fps ?? options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = metadata?.size
totalFrames.value =
metadata?.frame_count ??
Math.max(Math.round(videoDuration * fps.value), 1)
let sampledThumbnails: string[] = []
try {
sampledThumbnails = await sampleFilmstripFrames(
video,
canvas,
context,
videoDuration,
sampleCount,
() => isLoadStale(loadId, url)
)
} catch {
// Preserve already-fetched duration/fps/size even if capture fails
}
if (isLoadStale(loadId, url)) return
thumbnails.value = sampledThumbnails
} catch (loadError) {
if (isLoadStale(loadId, url)) return
error.value =
loadError instanceof Error ? loadError.message : 'Failed to load video'
duration.value = 0
totalFrames.value = 0
width.value = 0
height.value = 0
fps.value = options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = undefined
thumbnails.value = []
} finally {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useVideoFilmstrip.ts` around lines 145 - 178, Separate
the metadata-loading flow from thumbnail sampling so errors from
sampleFilmstripFrames or its per-frame callback are handled without entering the
catch that resets duration, totalFrames, width, height, fps, and fileSize.
Preserve the successfully resolved metadata and only clear thumbnails or record
the thumbnail-specific failure, while keeping stale-load checks and existing
metadata error handling intact.

Comment on lines +46 to +58
it('only creates the sections listed in features', () => {
const { node, addWidget } = createNode()

useVideoEditWidget()(node, {
type: 'VIDEO_EDIT',
name: 'trim',
features: ['trim']
})

const [, , value, , options] = addWidget.mock.calls[0]
expect(value).toEqual({ trim: { start_time: 0, duration: 0 } })
expect(options).toMatchObject({ features: ['trim'] })
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Cover the crop-only feature gate.

The suite verifies trim-only and all-features defaults, but not features: ['crop']. A crop branch accidentally gated on trim would still pass current tests.

Proposed test
+  it('only creates crop when crop is the enabled feature', () => {
+    const { node, addWidget } = createNode()
+
+    useVideoEditWidget()(node, {
+      type: 'VIDEO_EDIT',
+      name: 'crop',
+      features: ['crop']
+    })
+
+    const [, , value, , options] = addWidget.mock.calls[0]
+    expect(value).toEqual({ crop: { x: 0, y: 0, width: 0, height: 0 } })
+    expect(options).toMatchObject({ features: ['crop'] })
+  })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('only creates the sections listed in features', () => {
const { node, addWidget } = createNode()
useVideoEditWidget()(node, {
type: 'VIDEO_EDIT',
name: 'trim',
features: ['trim']
})
const [, , value, , options] = addWidget.mock.calls[0]
expect(value).toEqual({ trim: { start_time: 0, duration: 0 } })
expect(options).toMatchObject({ features: ['trim'] })
})
it('only creates the sections listed in features', () => {
const { node, addWidget } = createNode()
useVideoEditWidget()(node, {
type: 'VIDEO_EDIT',
name: 'trim',
features: ['trim']
})
const [, , value, , options] = addWidget.mock.calls[0]
expect(value).toEqual({ trim: { start_time: 0, duration: 0 } })
expect(options).toMatchObject({ features: ['trim'] })
})
it('only creates crop when crop is the enabled feature', () => {
const { node, addWidget } = createNode()
useVideoEditWidget()(node, {
type: 'VIDEO_EDIT',
name: 'crop',
features: ['crop']
})
const [, , value, , options] = addWidget.mock.calls[0]
expect(value).toEqual({ crop: { x: 0, y: 0, width: 0, height: 0 } })
expect(options).toMatchObject({ features: ['crop'] })
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts`
around lines 46 - 58, Add a test alongside the existing trim-only case in
useVideoEditWidget that invokes the widget with features set to only crop and
verifies the created value contains only the crop defaults, with options
preserving features: ['crop']. This should specifically exercise the crop
feature gate independently of trim.

Source: Path instructions

Comment on lines +61 to +67
it('returns undefined for malformed responses', async () => {
mockResponse(true, { unexpected: true })

const result = await fetchVideoMetadata('/api/view?filename=a.mp4')

expect(result).toBeUndefined()
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider testing the new numeric guards directly.

The malformed-response test only checks a wholly different shape ({ unexpected: true }). Since the previous review's schema hardening (.positive(), .nonnegative(), .int()) was the main fix in this file, add a case with valid-shaped-but-invalid values (e.g., negative width) to confirm those guards actually reject bad metadata.

♻️ Suggested addition
+  it('rejects metadata with invalid numeric values', async () => {
+    mockResponse(true, { ...metadata, width: -10 })
+
+    const result = await fetchVideoMetadata('/api/view?filename=a.mp4')
+
+    expect(result).toBeUndefined()
+  })

As per path instructions ("Treat .agents/checks/test-quality.md ... as required review context"), which asks to "cover key edge cases beyond happy paths (empty/undefined inputs, error/exception flows, disabled interactions, boundary collisions/clamping)".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('returns undefined for malformed responses', async () => {
mockResponse(true, { unexpected: true })
const result = await fetchVideoMetadata('/api/view?filename=a.mp4')
expect(result).toBeUndefined()
})
it('returns undefined for malformed responses', async () => {
mockResponse(true, { unexpected: true })
const result = await fetchVideoMetadata('/api/view?filename=a.mp4')
expect(result).toBeUndefined()
})
it('rejects metadata with invalid numeric values', async () => {
mockResponse(true, { ...metadata, width: -10 })
const result = await fetchVideoMetadata('/api/view?filename=a.mp4')
expect(result).toBeUndefined()
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/videoMetadataUtil.test.ts` around lines 61 - 67, The
malformed-response coverage in the fetchVideoMetadata tests does not exercise
the numeric schema guards. Add a test using the expected metadata shape but an
invalid numeric value, such as a negative width, and assert that
fetchVideoMetadata returns undefined; include the relevant positive,
nonnegative, or integer boundary cases as appropriate while preserving the
existing malformed-shape test.

Source: Path instructions

@jtydhr88 jtydhr88 reopened this Jul 26, 2026
@jtydhr88
jtydhr88 marked this pull request as draft July 26, 2026 21:25
@jtydhr88
jtydhr88 force-pushed the feat/trim-video-rich-widget branch from b244d6f to 680c75b Compare July 26, 2026 22:20
jtydhr88 added a commit that referenced this pull request Jul 26, 2026
#14120)

This is seperate part for the big PR of video edit, crop, trim PR
#14118

## Summary
- Optional contentInsetX maps pointer positions onto a track whose value
range is horizontally inset (e.g. tracks with handle gutters)
- Expose activeHandle so consumers can render per-handle UI while
dragging
@jtydhr88
jtydhr88 force-pushed the feat/trim-video-rich-widget branch from 680c75b to 1811e2f Compare July 26, 2026 22:45

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/videoEdit/VideoCropOverlay.test.ts`:
- Line 1: Remove the leading U+FEFF byte-order mark from
VideoCropOverlay.test.ts so the eslint-disable directive begins at the first
character of the file; leave the directive and remaining test content unchanged.
- Around line 23-27: Update the VideoCropOverlay tests to exercise the
lockedRatio option in renderOverlay, preferably by adding a case with
lockedRatio: 1 that resizes the crop box and asserts the resulting bounds remain
square; otherwise remove the unused option and related ratio-specific setup.

In `@src/components/videoEdit/VideoEditPanel.test.ts`:
- Around line 122-131: Extend the VideoEditPanel test suite to cover the reset
start/end frame controls by locating them via their existing aria-labels,
asserting each emits the corresponding update event with 0 or frameMax, and
verifying each is disabled at its respective extreme. Reuse the existing
trim-enabled setup and test helpers without changing panel behavior.

In `@src/components/videoEdit/VideoFilmstripTrim.test.ts`:
- Around line 45-49: Remove the mirrored expectedFrameAt helper and replace its
usages in the VideoFilmstripTrim tests with literal expected frame values, such
as 50 for clientX 100. Assert the resulting playheadFrame state directly so the
tests document intended behavior rather than duplicating the component’s inset,
normalization, and rounding logic.

In `@src/components/videoEdit/WidgetVideoEdit.test.ts`:
- Around line 16-42: The mock setup in
src/components/videoEdit/WidgetVideoEdit.test.ts lines 16-42 should stop using
require('vue') and eslint suppressions; add a top-level ref import and use it in
both useVideoSourceUrl and useVideoFilmstrip mock factories. In
src/components/videoEdit/VideoFilmstripTrim.test.ts lines 6-19, keep only a
plain mutable holder in vi.hoisted for activeHandle and create the Vue ref
inside the useRangeEditor mock factory.

In `@src/components/videoEdit/WidgetVideoEdit.vue`:
- Line 52: Update the computed node lookup to optional-chain app.canvas before
accessing graph, preserving the existing graph and getNodeById optional chaining
so evaluation remains safe before setup assigns the canvas.

In `@src/composables/video/useCropRatioLock.test.ts`:
- Around line 98-114: Extend the tests around createLock to cover enabling the
lock with initial bounds of width 0 and height 0. Assert that lockedRatio
remains null or finite and never captures NaN, preserving the expected startup
behavior before metadata resolves.

In `@src/composables/video/useCropRatioLock.ts`:
- Around line 72-82: Guard the ratio capture in the isLockEnabled setter so
locking only stores bounds.value.width / bounds.value.height when the computed
ratio is finite. If the dimensions produce NaN or Infinity, leave lockedRatio
unset so the lock remains disabled; preserve the existing unlock behavior and
valid-ratio flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c2cfd801-3348-4595-9b0e-77203b922884

📥 Commits

Reviewing files that changed from the base of the PR and between b244d6f and 1811e2f.

📒 Files selected for processing (39)
  • packages/design-system/src/css/style.css
  • src/components/videoEdit/VideoCropOverlay.test.ts
  • src/components/videoEdit/VideoCropOverlay.vue
  • src/components/videoEdit/VideoEditPanel.test.ts
  • src/components/videoEdit/VideoEditPanel.vue
  • src/components/videoEdit/VideoFilmstripTrim.test.ts
  • src/components/videoEdit/VideoFilmstripTrim.vue
  • src/components/videoEdit/WidgetVideoEdit.test.ts
  • src/components/videoEdit/WidgetVideoEdit.vue
  • src/composables/video/useCropBoxEditor.test.ts
  • src/composables/video/useCropBoxEditor.ts
  • src/composables/video/useCropRatioLock.test.ts
  • src/composables/video/useCropRatioLock.ts
  • src/composables/video/useTimelineScrub.ts
  • src/composables/video/useTrimPlayback.test.ts
  • src/composables/video/useTrimPlayback.ts
  • src/composables/video/useVideoEditFormats.test.ts
  • src/composables/video/useVideoEditFormats.ts
  • src/composables/video/useVideoEditModel.test.ts
  • src/composables/video/useVideoEditModel.ts
  • src/composables/video/useVideoFilmstrip.test.ts
  • src/composables/video/useVideoFilmstrip.ts
  • src/composables/video/useVideoSourceUrl.test.ts
  • src/composables/video/useVideoSourceUrl.ts
  • src/lib/litegraph/src/types/widgets.ts
  • src/lib/litegraph/src/widgets/VideoEditWidget.ts
  • src/lib/litegraph/src/widgets/widgetMap.ts
  • src/locales/en/main.json
  • src/renderer/extensions/vueNodes/components/LGraphNode.vue
  • src/renderer/extensions/vueNodes/widgets/components/layout/index.ts
  • src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts
  • src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.ts
  • src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts
  • src/schemas/nodeDef/nodeDefSchemaV2.ts
  • src/scripts/widgets.ts
  • src/utils/videoFrameUtil.test.ts
  • src/utils/videoFrameUtil.ts
  • src/utils/videoMetadataUtil.test.ts
  • src/utils/videoMetadataUtil.ts

@@ -0,0 +1,140 @@
/* eslint-disable testing-library/prefer-user-event -- crop dragging needs low-level pointer events */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Strip the stray BOM at the start of the file.

Line 1 begins with a U+FEFF byte-order mark before the eslint directive, which is an accidental artifact and can confuse tooling that expects a directive comment as the first token.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/VideoCropOverlay.test.ts` at line 1, Remove the
leading U+FEFF byte-order mark from VideoCropOverlay.test.ts so the
eslint-disable directive begins at the first character of the file; leave the
directive and remaining test content unchanged.

Comment on lines +23 to +27
function renderOverlay({
bounds = { x: 100, y: 100, width: 200, height: 200 },
disabled = false,
lockedRatio = null as number | null
} = {}) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

lockedRatio is accepted but never exercised.

No test passes a locked ratio, so the ratio-constrained resize path through useCropBoxEditor.ratioResize has no component-level coverage here. Add a case (e.g. lockedRatio: 1) asserting the resulting box stays square, or drop the unused option.

As per path instructions, .agents/checks/test-quality.md asks to check for "missing coverage for any newly added behaviors".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/VideoCropOverlay.test.ts` around lines 23 - 27,
Update the VideoCropOverlay tests to exercise the lockedRatio option in
renderOverlay, preferably by adding a case with lockedRatio: 1 that resizes the
crop box and asserts the resulting bounds remain square; otherwise remove the
unused option and related ratio-specific setup.

Source: Path instructions

Comment on lines +122 to +131
it('keeps the filmstrip visible but collapses trim controls until enabled', async () => {
renderPanel({ features: ['trim'] })

expect(screen.getByTestId('stub-filmstrip')).toBeTruthy()
expect(screen.queryByTestId('stub-number-input')).toBeNull()

await userEvent.click(screen.getByTestId('toggle-trim_enabled'))

expect(screen.getAllByTestId('stub-number-input')).toHaveLength(2)
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover the reset start/end frame buttons.

setStartFrame/setEndFrame (VideoEditPanel.vue lines 293-303) are real panel logic — they stop playback, snap the bound to 0/frameMax, and are disabled at the extremes — but no test clicks them. Their aria-labels (Reset start frame / Reset end frame) are already in the test i18n messages, so a case asserting the emitted update:startFrame and the disabled state is cheap.

As per path instructions, .agents/checks/test-quality.md asks to check for "missing coverage for any newly added behaviors".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/VideoEditPanel.test.ts` around lines 122 - 131,
Extend the VideoEditPanel test suite to cover the reset start/end frame controls
by locating them via their existing aria-labels, asserting each emits the
corresponding update event with 0 or frameMax, and verifying each is disabled at
its respective extreme. Reuse the existing trim-enabled setup and test helpers
without changing panel behavior.

Source: Path instructions

Comment on lines +45 to +49
function expectedFrameAt(clientX: number, width = 200, frameMax = 100) {
const contentWidth = Math.max(width - 32, 1)
const norm = Math.min(Math.max((clientX - 16) / contentWidth, 0), 1)
return Math.round(norm * frameMax)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer literal expected frames over a mirrored formula.

expectedFrameAt re-implements the component's inset/normalize/round math, so the assertions describe the algorithm rather than the intended outcome. expect(playheadFrame.value).toBe(50) for clientX: 100 documents the behavior more directly and fails loudly if the inset changes.

As per path instructions, .agents/checks/test-quality.md asks to avoid change-detector tests that assert internals instead of resulting state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/VideoFilmstripTrim.test.ts` around lines 45 - 49,
Remove the mirrored expectedFrameAt helper and replace its usages in the
VideoFilmstripTrim tests with literal expected frame values, such as 50 for
clientX 100. Assert the resulting playheadFrame state directly so the tests
document intended behavior rather than duplicating the component’s inset,
normalization, and rounding logic.

Source: Path instructions

Comment on lines +16 to +42
vi.mock('@/composables/video/useVideoSourceUrl', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref: createRef } = require('vue')
return {
useVideoSourceUrl: () => ({
videoUrl: createRef('/api/view?filename=clip.mp4')
})
}
})

vi.mock('@/composables/video/useVideoFilmstrip', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref: createRef } = require('vue')
return {
DEFAULT_VIDEO_FPS: 20,
useVideoFilmstrip: () => ({
thumbnails: createRef([]),
duration: createRef(10),
totalFrames: createRef(101),
width: createRef(1920),
height: createRef(1080),
fps: createRef(10),
fileSize: createRef(1024),
loading: createRef(false)
})
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Vue refs are created through require('vue') CJS interop in mock setup in both new test files. Both rely on require (plus eslint suppressions) to obtain ref, which is only necessary inside vi.hoisted; vi.mock factories run lazily and can use the ESM import directly.

  • src/components/videoEdit/WidgetVideoEdit.test.ts#L16-L42: remove both require('vue') calls and the eslint suppressions, and use a top-level import { ref } from 'vue' inside the two mock factories.
  • src/components/videoEdit/VideoFilmstripTrim.test.ts#L6-L19: move ref creation out of vi.hoisted into the vi.mock('@/composables/useRangeEditor') factory, keeping only a plain mutable holder hoisted for the test to drive activeHandle.
📍 Affects 2 files
  • src/components/videoEdit/WidgetVideoEdit.test.ts#L16-L42 (this comment)
  • src/components/videoEdit/VideoFilmstripTrim.test.ts#L6-L19
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/WidgetVideoEdit.test.ts` around lines 16 - 42, The
mock setup in src/components/videoEdit/WidgetVideoEdit.test.ts lines 16-42
should stop using require('vue') and eslint suppressions; add a top-level ref
import and use it in both useVideoSourceUrl and useVideoFilmstrip mock
factories. In src/components/videoEdit/VideoFilmstripTrim.test.ts lines 6-19,
keep only a plain mutable holder in vi.hoisted for activeHandle and create the
Vue ref inside the useRangeEditor mock factory.

(): VideoEditFeature[] => widget.options?.features ?? ['trim', 'crop']
)

const node = computed(() => app.canvas.graph?.getNodeById(nodeId))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=ts --type=vue 'app\.canvas\??\.graph' -g '!**/*.test.ts' | head -40

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 192


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Candidate files:"
fd -a 'WidgetVideoEdit\.vue$' . || true

echo
echo "Search app.canvas.graph and app.canvas patterns in Vue/TS files:"
rg -n --glob '!**/*.test.ts' --glob '!**/*.test.js' --glob '!**/*.spec.ts' --glob '!**/*.spec.js' 'app\.canvas(\?\.|\.)(graph|getCanvas)' | head -80 || true

echo
echo "Inspect WidgetVideoEdit outline and relevant lines:"
if [ -f src/components/videoEdit/WidgetVideoEdit.vue ]; then
  wc -l src/components/videoEdit/WidgetVideoEdit.vue
  ast-grep outline src/components/videoEdit/WidgetVideoEdit.vue --view compact || true
  sed -n '1,90p' src/components/videoEdit/WidgetVideoEdit.vue
fi

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 311


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "WidgetVideoEdit relevant content:"
wc -l src/components/videoEdit/WidgetVideoEdit.vue
sed -n '1,110p' src/components/videoEdit/WidgetVideoEdit.vue

echo
echo "Search app.canvas references (all files, excluding tests if present):"
rg -n 'app\.canvas' . | head -120 || true

echo
echo "Type definitions for app.canvas (if available):"
rg -n 'interface .*ComfyApp|type .*ComfyApp|class .*ComfyApp|declare const app|const app|canvas\?|canvas:' src -g '*.ts' -g '*.d.ts' | head -120 || true

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 26389


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate app definition:"
fd -a 'app\.(ts|js)$|app/|app\.ts' src scripts | head -40 || true

echo
echo "Search ComfyApp.canvas declaration/assignment:"
rg -n 'canvas(\??)=|canvas\??:' src scripts -g '*.ts' -g '*.js' -g '*.d.ts' | head -120 || true

echo
echo "Inspect app scripts around createApp/comfyApp:"
for f in $(fd -a 'app\.ts$|Comfy.*Class|script.*app' src scripts | head -20); do
  echo "--- $f"
  wc -l "$f"
  sed -n '1,140p' "$f"
done

echo
echo "Check WidgetVideoEdit tests mocks:"
sed -n '1,120p' src/components/videoEdit/WidgetVideoEdit.test.ts || true

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 21233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Canvas-related declarations and assignments in src/scripts/app.ts:"
rg -n 'canvas|LGraphCanvas|createLiteGraph|resizeCanvas' src/scripts/app.ts -C 3 | head -220

echo
echo "Standalone JS read-only probe for current expression shape:"
node - <<'JS'
const app = {}
function expression(canvas) {
  app.canvas = canvas
  return app.canvas.graph?.getNodeById?.('node-1')
}

function safeExpression(canvas) {
  app.canvas = canvas
  return app.canvas?.graph?.getNodeById?.('node-1')
}

console.log('undefined canvas shape:', expression(undefined))
console.log('safe undefined canvas shape:', safeExpression(undefined))
try {
  expression({})
  console.log('object canvas no crash')
} catch (error) {
  console.log('object canvas crash:', error.name + ': ' + error.message)
}
JS

echo
echo "Search App lifecycle that might mount WidgetVideoEdit before canvas init:"
rg -n "WidgetVideoEdit|videoEdit|createLiteGraph|resizeCanvas|app\.canvas" src -g '*.ts' -g '*.vue' | head -200 || true

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 7801


Optional-chain app.canvas before reading graph.

ComfyApp.canvas is declared non-null, but Vue 3 can evaluate this computed before setup() assigns it, so app.canvas.graph?.getNodeById(nodeId) throws with app.canvas === undefined. Use app.canvas?.graph?.getNodeById(nodeId) here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/WidgetVideoEdit.vue` at line 52, Update the computed
node lookup to optional-chain app.canvas before accessing graph, preserving the
existing graph and getNodeById optional chaining so evaluation remains safe
before setup assigns the canvas.

Comment thread src/composables/video/useCropRatioLock.test.ts
Comment thread src/composables/video/useCropRatioLock.ts
@jtydhr88
jtydhr88 force-pushed the feat/trim-video-rich-widget branch from 1811e2f to d61ae17 Compare July 27, 2026 15:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (10)
src/utils/videoMetadataUtil.test.ts (1)

71-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Still missing coverage for the numeric schema guards.

The malformed-response test only covers a wholly different shape ({ unexpected: true }). The schema hardening added in videoMetadataUtil.ts (.positive(), .nonnegative(), .int()) has no test confirming it actually rejects a validly-shaped-but-invalid payload (e.g., negative width).

♻️ Suggested addition
+  it('rejects metadata with invalid numeric values', async () => {
+    mockResponse(true, { ...metadata, width: -10 })
+
+    const result = await fetchVideoMetadata('/api/view?filename=a.mp4')
+
+    expect(result).toBeUndefined()
+  })

As per path instructions (.agents/checks/test-quality.md), which asks to flag "missing edge-case coverage" and cover error/exception flows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/videoMetadataUtil.test.ts` around lines 71 - 77, Add a test
alongside the malformed-response case in the video metadata test suite that
supplies a structurally valid response with an invalid numeric field, such as a
negative width, then verifies fetchVideoMetadata returns undefined. Ensure the
case exercises the numeric guards added in the video metadata schema, including
the relevant positive, nonnegative, or integer constraint.

Source: Path instructions

src/components/videoEdit/VideoCropOverlay.test.ts (1)

23-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

lockedRatio is accepted but never exercised.

No test passes a locked ratio, so the ratio-constrained resize path through useCropBoxEditor's ratio-lock logic has no component-level coverage in this file.

♻️ Suggested addition
+  it('keeps the crop box square when resizing with a locked ratio', async () => {
+    const { model } = renderOverlay({ lockedRatio: 1 })
+
+    const handle = screen.getByTestId('crop-handle-se')
+    handle.setPointerCapture = vi.fn()
+
+    await fireEvent.pointerDown(handle, { clientX: 0, clientY: 0, button: 0, pointerId: 1 })
+    await fireEvent.pointerMove(handle, { clientX: 10, clientY: 5, pointerId: 1 })
+    await fireEvent.pointerUp(handle, { pointerId: 1 })
+
+    expect(model.value.width).toBe(model.value.height)
+  })

As per path instructions (.agents/checks/test-quality.md), which asks to flag "missing coverage for any newly added behaviors."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/VideoCropOverlay.test.ts` around lines 23 - 27, Add
component-level coverage in VideoCropOverlay tests by rendering through
renderOverlay with a non-null lockedRatio and exercising the resize behavior
that uses useCropBoxEditor’s ratio-lock logic. Assert the resulting crop bounds
preserve the requested aspect ratio, while leaving existing unlocked and
disabled scenarios unchanged.

Source: Path instructions

src/composables/video/useVideoFilmstrip.ts (1)

149-190: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Thumbnail-sampling failures still wipe out already-resolved metadata.

The single try/catch spanning metadata resolution and sampleFilmstripFrames means any exception during frame capture (e.g. a tainted-canvas SecurityError from canvas.toDataURL) falls into the same catch that calls resetVideoState(), discarding duration, width, height, fps, and fileSize that were already successfully resolved via loadedmetadata/fetchVideoMetadata.

🩺 Proposed fix
-      const sampledThumbnails = await sampleFilmstripFrames(
-        video,
-        canvas,
-        context,
-        effectiveDuration,
-        sampleCount,
-        () => isLoadStale(loadId, url)
-      )
-
-      if (isLoadStale(loadId, url)) return
-
-      thumbnails.value = sampledThumbnails
+      let sampledThumbnails: string[] = []
+      try {
+        sampledThumbnails = await sampleFilmstripFrames(
+          video,
+          canvas,
+          context,
+          effectiveDuration,
+          sampleCount,
+          () => isLoadStale(loadId, url)
+        )
+      } catch {
+        // Preserve already-fetched duration/fps/size even if capture fails
+      }
+
+      if (isLoadStale(loadId, url)) return
+
+      thumbnails.value = sampledThumbnails
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useVideoFilmstrip.ts` around lines 149 - 190, Separate
metadata resolution from thumbnail sampling in the load flow around
fetchVideoMetadata and sampleFilmstripFrames. Preserve resolved duration,
dimensions, fps, fileSize, and totalFrames when sampling fails; handle sampling
errors independently by recording the load failure without calling
resetVideoState() on already-resolved metadata.
src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts (1)

46-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the crop-only feature gate.

The suite verifies trim-only and all-features defaults, but not features: ['crop']. A crop branch accidentally gated on trim would still pass current tests.

✅ Proposed test
+  it('only creates crop when crop is the enabled feature', () => {
+    const { node, addWidget } = createNode()
+
+    useVideoEditWidget()(node, {
+      type: 'VIDEO_EDIT',
+      name: 'crop',
+      features: ['crop']
+    })
+
+    const [, , value, , options] = addWidget.mock.calls[0]
+    expect(value).toEqual({ crop: { x: 0, y: 0, width: 0, height: 0 } })
+    expect(options).toMatchObject({ features: ['crop'] })
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts`
around lines 46 - 58, Add a test beside the existing trim-only case for
useVideoEditWidget with features set to ['crop']. Verify the generated value
contains only the crop default and the widget options preserve the crop-only
feature list, ensuring crop initialization is independently gated from trim.
src/components/videoEdit/WidgetVideoEdit.vue (1)

52-52: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Optional-chain app.canvas before reading graph.

ComfyApp.canvas is declared non-null, but this computed can evaluate before setup() assigns it, causing app.canvas.graph?.getNodeById(nodeId) to throw when app.canvas === undefined.

🛡️ Proposed fix
-const node = computed(() => app.canvas.graph?.getNodeById(nodeId))
+const node = computed(() => app.canvas?.graph?.getNodeById(nodeId))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/WidgetVideoEdit.vue` at line 52, Update the node
computed property to optional-chain app.canvas before accessing graph, so it
safely returns undefined while setup has not assigned the canvas; preserve the
existing getNodeById(nodeId) behavior once canvas and graph are available.
src/composables/video/useTimelineScrub.ts (1)

1-98: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing unit tests for useTimelineScrub.

This composable performs non-trivial pointer-to-frame math and drag-state clamping (similar in complexity to useCropBoxEditor.ts, which has a colocated test file), but no useTimelineScrub.test.ts exists. Consider covering pointerToFrame clamping, scrubToFrame bounds enforcement (scrubMin/scrubMax), and drag lifecycle cleanup.

As per path instructions, .agents/checks/test-quality.md requires flagging "missing edge-case coverage for the new video edit logic".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useTimelineScrub.ts` around lines 1 - 98, Add colocated
unit tests for useTimelineScrub, covering pointerToFrame behavior at and beyond
both track boundaries, scrubToFrame enforcement of scrubMin and scrubMax, and
startScrubDrag lifecycle cleanup including pointer movement, pointerup,
lostpointercapture, and disposal. Follow the existing useCropBoxEditor test
conventions and flag the missing edge-case coverage for the new video edit
logic.

Source: Path instructions

src/composables/video/useVideoEditModel.ts (1)

87-113: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Crop bounds aren't clamped to source dimensions, and no test covers it. The cropBounds setter only special-cases the "covers full frame" reset; any other out-of-range value (negative x/y, or x+width/y+height beyond the source size) is stored verbatim, unlike the meticulously-clamped trim setters in the same file. This was previously flagged and remains unresolved.

  • src/composables/video/useVideoEditModel.ts#L87-L113: clamp next.x/y/width/height against [0, width.value]/[0, height.value] before storing, mirroring the clamp() usage already used for startFrame/endFrame.
  • src/composables/video/useVideoEditModel.test.ts#L108-L147: add a regression test asserting that an out-of-bounds cropBounds write (e.g. negative x or oversized width) gets clamped, following the pattern used for the trim-handle-crossover regression tests.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useVideoEditModel.ts` around lines 87 - 113, Clamp
cropBounds values in the cropBounds setter of useVideoEditModel against the
source dimensions, constraining x/y and the resulting width/height to valid
frame bounds while preserving the full-frame reset behavior. Add a regression
test in src/composables/video/useVideoEditModel.test.ts:108-147 covering an
out-of-bounds cropBounds write, such as negative x or oversized width, and
assert the stored bounds are clamped; follow the existing trim-handle-crossover
test pattern there.
src/composables/video/useCropRatioLock.ts (1)

72-82: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Non-finite ratio capture is unguarded, and no test pins the degenerate-box case that triggers it. Both sites share one root cause: isLockEnabled's setter captures bounds.value.width / bounds.value.height without checking it's finite and positive.

  • src/composables/video/useCropRatioLock.ts#L72-L82: guard the capture — Number.isFinite(ratio) && ratio > 0 ? ratio : null — before assigning lockedRatio.value.
  • src/composables/video/useCropRatioLock.test.ts#L71-L114: add a case that enables the lock while bounds is {x:0,y:0,width:0,height:0} (the pre-metadata startup state) and asserts lockedRatio.value stays null/finite, never NaN.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useCropRatioLock.ts` around lines 72 - 82, Guard ratio
capture in isLockEnabled’s setter so only finite, positive width/height ratios
are assigned; otherwise keep lockedRatio.value null. In
src/composables/video/useCropRatioLock.ts:72-82, update the capture logic
accordingly. In src/composables/video/useCropRatioLock.test.ts:71-114, add
coverage enabling the lock with zero-sized bounds and assert lockedRatio.value
remains null and never becomes NaN.
src/composables/video/useTrimPlayback.ts (1)

40-50: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

waitForVideoSeek still has no timeout — stalled seeks permanently wedge isSeeking.

This is the same gap flagged in a prior review (and marked as addressed in commits eaafcd1–1811e2f), but the code in this diff still has no bound: if seeked/error never fire (stalled network, revoked source mid-seek), the promise never resolves, isSeeking stays true forever, and handleTimeUpdate (Line 113) permanently stops syncing the playhead / auto-stopping at the trim end. It also leaves the listeners attached to a possibly-detached video element if the component unmounts mid-seek.

🛡️ Proposed fix
+const SEEK_TIMEOUT_MS = 5000
+
 function waitForVideoSeek(video: HTMLVideoElement): Promise<void> {
   return new Promise((resolve) => {
-    const finish = () => {
+    const timer = setTimeout(finish, SEEK_TIMEOUT_MS)
+    function finish() {
+      clearTimeout(timer)
       video.removeEventListener('seeked', finish)
       video.removeEventListener('error', finish)
       resolve()
     }
     video.addEventListener('seeked', finish, { once: true })
     video.addEventListener('error', finish, { once: true })
   })
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useTrimPlayback.ts` around lines 40 - 50, Update
waitForVideoSeek to guarantee completion when seeked/error never fire by adding
a timeout fallback that resolves the promise. Ensure finish clears the timeout
and removes both listeners, preserving cleanup for stalled seeks and preventing
isSeeking from remaining true indefinitely.
src/components/videoEdit/VideoEditPanel.test.ts (1)

106-175: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing coverage for the reset start/end frame controls.

The i18n messages already include setStartFrame/setEndFrame labels (Lines 26-27), but no test exercises the actual reset buttons in VideoEditPanel.vue. A case asserting the emitted update:startFrame/update:endFrame and the disabled state at the extremes would close this gap cheaply, reusing the existing renderPanel({ features: ['trim'] }) setup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/VideoEditPanel.test.ts` around lines 106 - 175, The
VideoEditPanel tests lack coverage for the trim reset controls. Add a test using
renderPanel({ features: ['trim'] }) that verifies the start/end reset buttons
emit update:startFrame and update:endFrame with the expected reset values, and
confirms each button is disabled when its frame is already at the corresponding
extreme.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/composables/video/useCropRatioLock.ts`:
- Line 39: Extract and export a shared minWidthForRatio(ratio) helper alongside
MIN_CROP_SIZE in useCropBoxEditor.ts, returning the existing minimum-size
formula. Update both useCropBoxEditor.ts and useCropRatioLock.ts to call this
helper instead of duplicating Math.max(MIN_CROP_SIZE, MIN_CROP_SIZE * ratio),
preserving current sizing behavior.

In `@src/composables/video/useVideoEditModel.test.ts`:
- Around line 108-147: The crop bounds tests in the “crop bounds” describe block
lack coverage for out-of-bounds writes. Add a regression test through
createModel that assigns negative x/y and oversized width/height to cropBounds,
then assert modelValue.crop is clamped to the valid video-frame bounds.

---

Duplicate comments:
In `@src/components/videoEdit/VideoCropOverlay.test.ts`:
- Around line 23-27: Add component-level coverage in VideoCropOverlay tests by
rendering through renderOverlay with a non-null lockedRatio and exercising the
resize behavior that uses useCropBoxEditor’s ratio-lock logic. Assert the
resulting crop bounds preserve the requested aspect ratio, while leaving
existing unlocked and disabled scenarios unchanged.

In `@src/components/videoEdit/VideoEditPanel.test.ts`:
- Around line 106-175: The VideoEditPanel tests lack coverage for the trim reset
controls. Add a test using renderPanel({ features: ['trim'] }) that verifies the
start/end reset buttons emit update:startFrame and update:endFrame with the
expected reset values, and confirms each button is disabled when its frame is
already at the corresponding extreme.

In `@src/components/videoEdit/WidgetVideoEdit.vue`:
- Line 52: Update the node computed property to optional-chain app.canvas before
accessing graph, so it safely returns undefined while setup has not assigned the
canvas; preserve the existing getNodeById(nodeId) behavior once canvas and graph
are available.

In `@src/composables/video/useCropRatioLock.ts`:
- Around line 72-82: Guard ratio capture in isLockEnabled’s setter so only
finite, positive width/height ratios are assigned; otherwise keep
lockedRatio.value null. In src/composables/video/useCropRatioLock.ts:72-82,
update the capture logic accordingly. In
src/composables/video/useCropRatioLock.test.ts:71-114, add coverage enabling the
lock with zero-sized bounds and assert lockedRatio.value remains null and never
becomes NaN.

In `@src/composables/video/useTimelineScrub.ts`:
- Around line 1-98: Add colocated unit tests for useTimelineScrub, covering
pointerToFrame behavior at and beyond both track boundaries, scrubToFrame
enforcement of scrubMin and scrubMax, and startScrubDrag lifecycle cleanup
including pointer movement, pointerup, lostpointercapture, and disposal. Follow
the existing useCropBoxEditor test conventions and flag the missing edge-case
coverage for the new video edit logic.

In `@src/composables/video/useTrimPlayback.ts`:
- Around line 40-50: Update waitForVideoSeek to guarantee completion when
seeked/error never fire by adding a timeout fallback that resolves the promise.
Ensure finish clears the timeout and removes both listeners, preserving cleanup
for stalled seeks and preventing isSeeking from remaining true indefinitely.

In `@src/composables/video/useVideoEditModel.ts`:
- Around line 87-113: Clamp cropBounds values in the cropBounds setter of
useVideoEditModel against the source dimensions, constraining x/y and the
resulting width/height to valid frame bounds while preserving the full-frame
reset behavior. Add a regression test in
src/composables/video/useVideoEditModel.test.ts:108-147 covering an
out-of-bounds cropBounds write, such as negative x or oversized width, and
assert the stored bounds are clamped; follow the existing trim-handle-crossover
test pattern there.

In `@src/composables/video/useVideoFilmstrip.ts`:
- Around line 149-190: Separate metadata resolution from thumbnail sampling in
the load flow around fetchVideoMetadata and sampleFilmstripFrames. Preserve
resolved duration, dimensions, fps, fileSize, and totalFrames when sampling
fails; handle sampling errors independently by recording the load failure
without calling resetVideoState() on already-resolved metadata.

In
`@src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts`:
- Around line 46-58: Add a test beside the existing trim-only case for
useVideoEditWidget with features set to ['crop']. Verify the generated value
contains only the crop default and the widget options preserve the crop-only
feature list, ensuring crop initialization is independently gated from trim.

In `@src/utils/videoMetadataUtil.test.ts`:
- Around line 71-77: Add a test alongside the malformed-response case in the
video metadata test suite that supplies a structurally valid response with an
invalid numeric field, such as a negative width, then verifies
fetchVideoMetadata returns undefined. Ensure the case exercises the numeric
guards added in the video metadata schema, including the relevant positive,
nonnegative, or integer constraint.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 76d894b7-0fb3-4dc8-92f7-b2993eab11af

📥 Commits

Reviewing files that changed from the base of the PR and between 1811e2f and d61ae17.

📒 Files selected for processing (39)
  • packages/design-system/src/css/style.css
  • src/components/videoEdit/VideoCropOverlay.test.ts
  • src/components/videoEdit/VideoCropOverlay.vue
  • src/components/videoEdit/VideoEditPanel.test.ts
  • src/components/videoEdit/VideoEditPanel.vue
  • src/components/videoEdit/VideoFilmstripTrim.test.ts
  • src/components/videoEdit/VideoFilmstripTrim.vue
  • src/components/videoEdit/WidgetVideoEdit.test.ts
  • src/components/videoEdit/WidgetVideoEdit.vue
  • src/composables/video/useCropBoxEditor.test.ts
  • src/composables/video/useCropBoxEditor.ts
  • src/composables/video/useCropRatioLock.test.ts
  • src/composables/video/useCropRatioLock.ts
  • src/composables/video/useTimelineScrub.ts
  • src/composables/video/useTrimPlayback.test.ts
  • src/composables/video/useTrimPlayback.ts
  • src/composables/video/useVideoEditFormats.test.ts
  • src/composables/video/useVideoEditFormats.ts
  • src/composables/video/useVideoEditModel.test.ts
  • src/composables/video/useVideoEditModel.ts
  • src/composables/video/useVideoFilmstrip.test.ts
  • src/composables/video/useVideoFilmstrip.ts
  • src/composables/video/useVideoSourceUrl.test.ts
  • src/composables/video/useVideoSourceUrl.ts
  • src/lib/litegraph/src/types/widgets.ts
  • src/lib/litegraph/src/widgets/VideoEditWidget.ts
  • src/lib/litegraph/src/widgets/widgetMap.ts
  • src/locales/en/main.json
  • src/renderer/extensions/vueNodes/components/LGraphNode.vue
  • src/renderer/extensions/vueNodes/widgets/components/layout/index.ts
  • src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts
  • src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.ts
  • src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts
  • src/schemas/nodeDef/nodeDefSchemaV2.ts
  • src/scripts/widgets.ts
  • src/utils/videoFrameUtil.test.ts
  • src/utils/videoFrameUtil.ts
  • src/utils/videoMetadataUtil.test.ts
  • src/utils/videoMetadataUtil.ts

Math.max(sourceW - x, 0) / width,
Math.max(sourceH - y, 0) / height
)
width = Math.max(width * scale, MIN_CROP_SIZE, MIN_CROP_SIZE * ratio)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared min-width-for-ratio formula.

Math.max(MIN_CROP_SIZE, MIN_CROP_SIZE * ratio) here duplicates the identical expression in src/composables/video/useCropBoxEditor.ts (Line 87). Since this file already imports MIN_CROP_SIZE from that module, a shared minWidthForRatio(ratio) helper exported alongside MIN_CROP_SIZE would keep the two composables' minimum-size math from drifting independently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useCropRatioLock.ts` at line 39, Extract and export a
shared minWidthForRatio(ratio) helper alongside MIN_CROP_SIZE in
useCropBoxEditor.ts, returning the existing minimum-size formula. Update both
useCropBoxEditor.ts and useCropRatioLock.ts to call this helper instead of
duplicating Math.max(MIN_CROP_SIZE, MIN_CROP_SIZE * ratio), preserving current
sizing behavior.

Comment on lines +108 to +147
describe('crop bounds', () => {
it('shows the full frame for zero crop values', () => {
const { cropBounds } = createModel()

expect(cropBounds.value).toEqual({
x: 0,
y: 0,
width: 1920,
height: 1080
})
})

it('rounds and stores a partial crop', () => {
const { modelValue, cropBounds } = createModel()

cropBounds.value = { x: 10.4, y: 20.6, width: 640.2, height: 360.5 }

expect(modelValue.value.crop).toEqual({
x: 10,
y: 21,
width: 640,
height: 361
})
})

it('normalizes a full-frame crop back to zeros', () => {
const { modelValue, cropBounds } = createModel({
crop: { x: 10, y: 10, width: 100, height: 100 }
})

cropBounds.value = { x: 0, y: 0, width: 1920, height: 1080 }

expect(modelValue.value.crop).toEqual({
x: 0,
y: 0,
width: 0,
height: 0
})
})
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing regression test for out-of-bounds cropBounds clamping.

No test asserts that an out-of-bounds cropBounds write (negative x/y, or oversized width/height) gets clamped — mirroring the unresolved clamping gap in useVideoEditModel.ts (see that file's review comment).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useVideoEditModel.test.ts` around lines 108 - 147, The
crop bounds tests in the “crop bounds” describe block lack coverage for
out-of-bounds writes. Add a regression test through createModel that assigns
negative x/y and oversized width/height to cropBounds, then assert
modelValue.crop is clamped to the valid video-frame bounds.

@jtydhr88
jtydhr88 force-pushed the feat/trim-video-rich-widget branch from d61ae17 to b366170 Compare July 27, 2026 15:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

♻️ Duplicate comments (2)
src/composables/video/useTrimPlayback.ts (1)

40-50: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

waitForVideoSeek still has no timeout — isSeeking can wedge permanently.

The previous round marked this as addressed, but the current code has no timer: if neither seeked nor error fires (stalled network, source revoked mid-seek), the promise never settles and isSeeking stays true, permanently disabling handleTimeUpdate (Line 113) — trim sync and auto-stop-at-end stop working until reload.

🛡️ Proposed fix
+const SEEK_TIMEOUT_MS = 5000
+
 function waitForVideoSeek(video: HTMLVideoElement): Promise<void> {
   return new Promise((resolve) => {
-    const finish = () => {
+    function finish() {
+      clearTimeout(timer)
       video.removeEventListener('seeked', finish)
       video.removeEventListener('error', finish)
       resolve()
     }
+    const timer = setTimeout(finish, SEEK_TIMEOUT_MS)
     video.addEventListener('seeked', finish, { once: true })
     video.addEventListener('error', finish, { once: true })
   })
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useTrimPlayback.ts` around lines 40 - 50, Update
waitForVideoSeek to include a bounded timeout fallback that resolves the promise
when neither the seeked nor error event fires. Ensure the timeout is cleared and
both event listeners are removed when finish runs, preserving normal
event-driven completion while preventing isSeeking from remaining stuck.
src/composables/video/useVideoEditModel.ts (1)

87-113: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Crop geometry is never validated against the source frame. Nothing in the crop path enforces that a rectangle stays within [0, sourceWidth] × [0, sourceHeight] with at least MIN_CROP_SIZE extent, so invalid boxes enter the model and then corrupt the resize math downstream.

  • src/composables/video/useVideoEditModel.ts#L87-L113: clamp next.x/y and the resulting right/bottom edges against width.value/height.value in the cropBounds setter before storing, preserving the existing full-frame reset branch.
  • src/composables/video/useCropBoxEditor.ts#L29-L50: floor the w/n clamp upper bounds with Math.max(x2 - MIN_CROP_SIZE, 0) / Math.max(y2 - MIN_CROP_SIZE, 0) so a sub-minimum incoming box can't invert the clamp range.
  • src/composables/video/useVideoEditModel.test.ts#L108-L147: add a regression test writing negative x and oversized width to cropBounds and assert the stored crop is clamped.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useVideoEditModel.ts` around lines 87 - 113, Validate
crop geometry before storing it in cropBounds: clamp x/y and the resulting
right/bottom edges to the source frame while preserving the existing full-frame
reset branch. In src/composables/video/useCropBoxEditor.ts lines 29-50, bound
the w/n clamp maxima with Math.max(x2 - MIN_CROP_SIZE, 0) and Math.max(y2 -
MIN_CROP_SIZE, 0). In src/composables/video/useVideoEditModel.test.ts lines
108-147, add a regression test that writes negative x and oversized width
through cropBounds and verifies the stored crop is clamped.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/videoEdit/VideoFilmstripTrim.test.ts`:
- Around line 292-304: Add a test alongside “hides trim handles when disabled”
that renders the filmstrip with disabled true, triggers the track pointer
interaction used by startScrubDrag, and verifies scrubbing does not begin or
update the playhead. Reuse the existing test helpers and observable
callbacks/state assertions to cover the isDisabled() guard without changing
production behavior.

In `@src/composables/video/useVideoEditFormats.test.ts`:
- Around line 35-43: Add exact boundary assertions to the “picks the unit by
magnitude” test around formatFileSize: verify 1024 selects kilobytes and 1024 *
1024 selects megabytes, preserving the expected localized output values.

In `@src/composables/video/useVideoFilmstrip.test.ts`:
- Around line 46-56: Update the mock addEventListener implementation in the test
fixture to recognize the options-object form used by waitForEvent, specifically
{ once: true }, while retaining support for the boolean form if needed. Ensure
the one-time listener wrapper is installed and removes itself after the first
event so waitForEvent tests exercise the same once semantics as production.

In `@src/composables/video/useVideoFilmstrip.ts`:
- Around line 79-89: Move the video.currentTime = target assignment into the
!alreadyAtTarget branch alongside waitForEvent in the seeking loop. Preserve the
existing timeout handling and skip both assignment and waiting when the video is
already at the requested target.

In `@src/utils/videoMetadataUtil.ts`:
- Around line 47-60: Update fetchVideoMetadata to pass a short
AbortSignal.timeout(...) through the fetchApi options for the /video_metadata
request, ensuring stuck requests are cancelled and the existing undefined
fallback remains intact.

---

Duplicate comments:
In `@src/composables/video/useTrimPlayback.ts`:
- Around line 40-50: Update waitForVideoSeek to include a bounded timeout
fallback that resolves the promise when neither the seeked nor error event
fires. Ensure the timeout is cleared and both event listeners are removed when
finish runs, preserving normal event-driven completion while preventing
isSeeking from remaining stuck.

In `@src/composables/video/useVideoEditModel.ts`:
- Around line 87-113: Validate crop geometry before storing it in cropBounds:
clamp x/y and the resulting right/bottom edges to the source frame while
preserving the existing full-frame reset branch. In
src/composables/video/useCropBoxEditor.ts lines 29-50, bound the w/n clamp
maxima with Math.max(x2 - MIN_CROP_SIZE, 0) and Math.max(y2 - MIN_CROP_SIZE, 0).
In src/composables/video/useVideoEditModel.test.ts lines 108-147, add a
regression test that writes negative x and oversized width through cropBounds
and verifies the stored crop is clamped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 877e84b8-0c59-41ee-a5c5-308ea163edbc

📥 Commits

Reviewing files that changed from the base of the PR and between d61ae17 and b366170.

📒 Files selected for processing (39)
  • packages/design-system/src/css/style.css
  • src/components/videoEdit/VideoCropOverlay.test.ts
  • src/components/videoEdit/VideoCropOverlay.vue
  • src/components/videoEdit/VideoEditPanel.test.ts
  • src/components/videoEdit/VideoEditPanel.vue
  • src/components/videoEdit/VideoFilmstripTrim.test.ts
  • src/components/videoEdit/VideoFilmstripTrim.vue
  • src/components/videoEdit/WidgetVideoEdit.test.ts
  • src/components/videoEdit/WidgetVideoEdit.vue
  • src/composables/video/useCropBoxEditor.test.ts
  • src/composables/video/useCropBoxEditor.ts
  • src/composables/video/useCropRatioLock.test.ts
  • src/composables/video/useCropRatioLock.ts
  • src/composables/video/useTimelineScrub.ts
  • src/composables/video/useTrimPlayback.test.ts
  • src/composables/video/useTrimPlayback.ts
  • src/composables/video/useVideoEditFormats.test.ts
  • src/composables/video/useVideoEditFormats.ts
  • src/composables/video/useVideoEditModel.test.ts
  • src/composables/video/useVideoEditModel.ts
  • src/composables/video/useVideoFilmstrip.test.ts
  • src/composables/video/useVideoFilmstrip.ts
  • src/composables/video/useVideoSourceUrl.test.ts
  • src/composables/video/useVideoSourceUrl.ts
  • src/lib/litegraph/src/types/widgets.ts
  • src/lib/litegraph/src/widgets/VideoEditWidget.ts
  • src/lib/litegraph/src/widgets/widgetMap.ts
  • src/locales/en/main.json
  • src/renderer/extensions/vueNodes/components/LGraphNode.vue
  • src/renderer/extensions/vueNodes/widgets/components/layout/index.ts
  • src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.test.ts
  • src/renderer/extensions/vueNodes/widgets/composables/useVideoEditWidget.ts
  • src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts
  • src/schemas/nodeDef/nodeDefSchemaV2.ts
  • src/scripts/widgets.ts
  • src/utils/videoFrameUtil.test.ts
  • src/utils/videoFrameUtil.ts
  • src/utils/videoMetadataUtil.test.ts
  • src/utils/videoMetadataUtil.ts

Comment on lines +292 to +304
it('hides trim handles when disabled', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 10,
disabled: true
})

expect(screen.queryByTestId('handle-start')).toBeNull()
expect(screen.queryByTestId('handle-end')).toBeNull()
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for scrub/drag being blocked while disabled.

disabled is verified to hide the trim handles, but no test confirms that pointer scrubbing on the track itself (startScrubDragisDisabled()) is a no-op when disabled is true — this is real behavior gating a runtime path.

As per path instructions, .agents/checks/test-quality.md calls to review changed tests for "missing edge-case coverage (error/disabled/empty states)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/videoEdit/VideoFilmstripTrim.test.ts` around lines 292 - 304,
Add a test alongside “hides trim handles when disabled” that renders the
filmstrip with disabled true, triggers the track pointer interaction used by
startScrubDrag, and verifies scrubbing does not begin or update the playhead.
Reuse the existing test helpers and observable callbacks/state assertions to
cover the isDisabled() guard without changing production behavior.

Source: Path instructions

Comment thread src/composables/video/useVideoEditFormats.test.ts
Comment on lines +46 to +56
addEventListener(type: string, listener: VideoListener, options?: boolean) {
if (options === true) {
const wrapped = (event: Event) => {
this.removeEventListener(type, wrapped)
listener(event)
}
this.getListeners(type).add(wrapped)
return
}
this.getListeners(type).add(listener)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mock never emulates once because production passes an options object, not a boolean.

waitForEvent in useVideoFilmstrip.ts (Lines 44-45) calls addEventListener(name, fn, { once: true }). The options === true branch here only matches the legacy boolean capture form, so the auto-removal wrapper is dead code and once semantics go untested — a regression that dropped cleanup() would still pass.

♻️ Proposed fix
-  addEventListener(type: string, listener: VideoListener, options?: boolean) {
-    if (options === true) {
+  addEventListener(
+    type: string,
+    listener: VideoListener,
+    options?: boolean | AddEventListenerOptions
+  ) {
+    if (options === true || (typeof options === 'object' && options.once)) {

As per path instructions, .agents/checks/test-quality.md asks that assertions exercise real behavior rather than wiring that can never run.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
addEventListener(type: string, listener: VideoListener, options?: boolean) {
if (options === true) {
const wrapped = (event: Event) => {
this.removeEventListener(type, wrapped)
listener(event)
}
this.getListeners(type).add(wrapped)
return
}
this.getListeners(type).add(listener)
}
addEventListener(
type: string,
listener: VideoListener,
options?: boolean | AddEventListenerOptions
) {
if (options === true || (typeof options === 'object' && options.once)) {
const wrapped = (event: Event) => {
this.removeEventListener(type, wrapped)
listener(event)
}
this.getListeners(type).add(wrapped)
return
}
this.getListeners(type).add(listener)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useVideoFilmstrip.test.ts` around lines 46 - 56, Update
the mock addEventListener implementation in the test fixture to recognize the
options-object form used by waitForEvent, specifically { once: true }, while
retaining support for the boolean form if needed. Ensure the one-time listener
wrapper is installed and removes itself after the first event so waitForEvent
tests exercise the same once semantics as production.

Source: Path instructions

Comment on lines +79 to +89
const alreadyAtTarget =
Math.abs(video.currentTime - target) < 0.001 && video.readyState >= 2
video.currentTime = target
if (!alreadyAtTarget) {
try {
await waitForEvent(video, 'seeked', SEEK_EVENT_TIMEOUT_MS)
} catch (waitError) {
if (waitError instanceof EventTimeoutError) continue
throw waitError
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file outline =="
ast-grep outline src/composables/video/useVideoFilmstrip.ts --view expanded || true

echo "== relevant file lines =="
cat -n src/composables/video/useVideoFilmstrip.ts | sed -n '1,140p'

echo "== search waitForEvent usages =="
rg -n "function waitForEvent|const waitForEvent|export .*waitForEvent|waitForEvent\(" -S .

echo "== deterministic browser-like event queue probe =="
node - <<'JS'
console.log("Node cannot run browser seeking algorithms; inspecting source call graph is sufficient.")
JS

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 8787


🌐 Web query:

HTML spec seeked event seeked event firing algorithm currentTime attribute firing time seeked

💡 Result:

The seeked event is a non-cancelable, non-bubbling event fired by an HTMLMediaElement when a seek operation has completed [1]. This event signifies that the media element has finished moving to a new position, the current playback position has been updated, and the seeking IDL attribute has been set to false [1][2]. When a user or script sets the currentTime attribute of a media element, it triggers the media element's seek algorithm [3][4]. During this process: 1. The seeking IDL attribute is set to true, and a seeking event is fired [5][6]. 2. The user agent performs the necessary operations to update the media's playback position to the requested time [3]. 3. Once the seek operation is complete, the seeking IDL attribute is set back to false [1]. 4. The seeked event is then fired to notify the document that the transition to the new playback position is finished [1]. The seeked event is intended to be used as a confirmation that the media has successfully jumped to the specified time and that the currentTime attribute reflects the new, stable position [1]. Because the algorithm is complex and involves asynchronous tasks, the seeking and seeked events serve as the primary mechanism for developers to track the lifecycle of a seek request [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== loadVideo remainder =="
cat -n src/composables/video/useVideoFilmstrip.ts | sed -n '140,185p'

echo "== sample loop behavior with stale flag sample edge =="
python3 - <<'PY'
def sample_times(sample_count, duration):
    for index in range(sample_count):
        time = 0 if sample_count <= 1 else (duration * index) / (sample_count - 1)
        target = min(time, max(duration - 0.001, 0))
        yield index, target, time

for sample_count in [2, 10, 20]:
    print(f"sample_count={sample_count}")
    for index, target, time in sample_times(sample_count, 60.0):
        print(index, round(target, 3), round(time, 3), round(abs(target - time), 6))
PY

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2628


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== HTMLMediaElement currentTime currentTime setter spec =="
python3 - <<'PY'
spec_url = "https://html.spec.whatwg.org/dev/media.html#dom-htmlmediaelement-currenttime-setter"
print(spec_url)
PY

echo "== inspect whether a runtime DOM/video model exists in package =="
fd -a 'video|media|html' . | head -80

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 5882


Move video.currentTime = target under !alreadyAtTarget.

When alreadyAtTarget is true, the loop still assigns currentTime, which can queue another seeking algorithm and deliver a seeked event during the next waitForEvent. That event can let captureFrame run before the requested frame is rendered, causing repeated filmstrip frames.

🐛 Proposed fix
-    video.currentTime = target
-    if (!alreadyAtTarget) {
+    if (!alreadyAtTarget) {
+      video.currentTime = target
       try {
         await waitForEvent(video, 'seeked', SEEK_EVENT_TIMEOUT_MS)
       } catch (waitError) {
         if (waitError instanceof EventTimeoutError) continue
         throw waitError
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const alreadyAtTarget =
Math.abs(video.currentTime - target) < 0.001 && video.readyState >= 2
video.currentTime = target
if (!alreadyAtTarget) {
try {
await waitForEvent(video, 'seeked', SEEK_EVENT_TIMEOUT_MS)
} catch (waitError) {
if (waitError instanceof EventTimeoutError) continue
throw waitError
}
}
if (!alreadyAtTarget) {
video.currentTime = target
try {
await waitForEvent(video, 'seeked', SEEK_EVENT_TIMEOUT_MS)
} catch (waitError) {
if (waitError instanceof EventTimeoutError) continue
throw waitError
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/video/useVideoFilmstrip.ts` around lines 79 - 89, Move the
video.currentTime = target assignment into the !alreadyAtTarget branch alongside
waitForEvent in the seeking loop. Preserve the existing timeout handling and
skip both assignment and waiting when the video is already at the requested
target.

Comment on lines +47 to +60
export async function fetchVideoMetadata(
videoUrl: string
): Promise<VideoMetadata | undefined> {
const query = viewQueryFromUrl(videoUrl)
if (!query) return undefined

try {
const response = await api.fetchApi(`/video_metadata?${query}`)
if (!response.ok) return undefined
return zVideoMetadata.parse(await response.json())
} catch {
return undefined
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep run --pattern 'fetchApi($$$) { $$$ }' --lang typescript src/scripts/api.ts
rg -nP -C5 '\bfetchApi\s*\(' src/scripts/api.ts | head -60

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 2335


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate files =="
fd -a 'videoMetadataUtil\.ts$|api\.ts$' src | sed 's#^\./##'

echo "== videoMetadataUtil outline =="
FILE="$(fd 'videoMetadataUtil\.ts$' src | head -1)"
if [ -n "${FILE:-}" ]; then
  ast-grep outline "$FILE" || true
  echo "== videoMetadataUtil contents =="
  cat -n "$FILE"
fi

echo "== api.ts fetchApi relevant section =="
API="$(fd '^api\.ts$' src/scripts | head -1)"
if [ -n "${API:-}" ]; then
  sed -n '430,500p' "$API" | cat -n -v "$API" | sed -n '430,500p'
fi

echo "== fetchApi implementations and request usage =="
rg -n 'async fetchApi|fetchApi\(|signal:' src/scripts/api.ts src/utils/videoMetadataUtil.ts src | head -120

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 5460


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate useVideoFilmstrip =="
rg -n "useVideoFilmstrip|loadVideo|METADATA_EVENT_TIMEOUT_MS|SEEK_EVENT_TIMEOUT_MS" src --glob '*.{ts,vue}' | head -160

echo "== candidate video filmstrip files =="
fd 'video|filmstrip' src --type f | sed 's#^\./##' | head -80

echo "== api.ts fetchWithUnifiedRemint =="
rg -n "fetchWithUnifiedRemint|return fetch|new Request" src/scripts/api.ts | head -80
sed -n '1,120p' src/scripts/api.ts | cat -n
sed -n '420,500p' src/scripts/api.ts | cat -n

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 12767


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== unified remint files =="
fd -a 'remintRetry\.ts$' src | sed 's#^\./##'
FILE="$(fd 'remintRetry\.ts$' src | head -1)"
if [ -n "${FILE:-}" ]; then
  wc -l "$FILE"
  cat -n "$FILE"
fi

echo "== useVideoFilmstrip relevant sections =="
FILE="$(fd '^useVideoFilmstrip\.ts$' src/composables/video | head -1)"
if [ -n "${FILE:-}" ]; then
  sed -n '1,135p' "$FILE" | cat -n
fi

echo "== fetchVideoMetadata usages and tests =="
rg -n "fetchVideoMetadata|videoMetadata" src --glob '*.{ts,vue}' | head -120

echo "== behavioral probe: request options forward signal (static ReadRequestInit and RequestInit semantics only) =="
python3 - <<'PY'
from pathlib import Path
p=Path('src/scripts/api.ts')
src=p.read_text()
start=src.find('async fetchApi')
end=src.find('    /**', start+1)
print(src[start:end])
PY

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 49914


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== loadVideo continuation =="
FILE="$(fd '^useVideoFilmstrip\.ts$' src/composables/video | head -1)"
if [ -n "${FILE:-}" ]; then
  sed -n '129,215p' "$FILE" | cat -n
fi

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 3082


Add a short AbortSignal timeout to the metadata fetch.

fetchApi passes RequestInit through to fetchWithUnifiedRemint, which calls fetch(input, init), so AbortSignal.timeout(...) can cancel a stuck /video_metadata request. A hung metadata fetch currently blocks loadVideo after loadedmetadata before the fallback element-derived metadata path is reached.

🛡️ Proposed fix
+const METADATA_REQUEST_TIMEOUT_MS = 10_000
+
 export async function fetchVideoMetadata(
   videoUrl: string
 ): Promise<VideoMetadata | undefined> {
   const query = viewQueryFromUrl(videoUrl)
   if (!query) return undefined
 
   try {
-    const response = await api.fetchApi(`/video_metadata?${query}`)
+    const response = await api.fetchApi(`/video_metadata?${query}`, {
+      signal: AbortSignal.timeout(METADATA_REQUEST_TIMEOUT_MS)
+    })
     if (!response.ok) return undefined
     return zVideoMetadata.parse(await response.json())
   } catch {
     return undefined
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function fetchVideoMetadata(
videoUrl: string
): Promise<VideoMetadata | undefined> {
const query = viewQueryFromUrl(videoUrl)
if (!query) return undefined
try {
const response = await api.fetchApi(`/video_metadata?${query}`)
if (!response.ok) return undefined
return zVideoMetadata.parse(await response.json())
} catch {
return undefined
}
}
const METADATA_REQUEST_TIMEOUT_MS = 10_000
export async function fetchVideoMetadata(
videoUrl: string
): Promise<VideoMetadata | undefined> {
const query = viewQueryFromUrl(videoUrl)
if (!query) return undefined
try {
const response = await api.fetchApi(`/video_metadata?${query}`, {
signal: AbortSignal.timeout(METADATA_REQUEST_TIMEOUT_MS)
})
if (!response.ok) return undefined
return zVideoMetadata.parse(await response.json())
} catch {
return undefined
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/videoMetadataUtil.ts` around lines 47 - 60, Update
fetchVideoMetadata to pass a short AbortSignal.timeout(...) through the fetchApi
options for the /video_metadata request, ensuring stuck requests are cancelled
and the existing undefined fallback remains intact.

dante01yoon pushed a commit that referenced this pull request Jul 27, 2026
#14120)

This is seperate part for the big PR of video edit, crop, trim PR
#14118

## Summary
- Optional contentInsetX maps pointer positions onto a track whose value
range is horizontally inset (e.g. tracks with handle gutters)
- Expose activeHandle so consumers can render per-handle UI while
dragging
jtydhr88 added a commit that referenced this pull request Jul 27, 2026
This is seperate part for the big PR of video edit, crop, trim PR
#14118

## Summary
- useVideoSourceUrl resolves a playable url for the video a node
operates on: the linked upstream node (with subgraph passthrough) or the
node itself, preferring executed output previews over the file widget;
keyed store subscription, rand cache-buster stripped
- useVideoFilmstrip samples thumbnail strips off a hidden video element
with seek timeouts and stale-load bail-out
- videoMetadataUtil fetches exact fps/frame count/duration/size from the
backend /video_metadata endpoint (falls back gracefully when absent);
videoFrameUtil holds frame/time conversion helpers
@jtydhr88
jtydhr88 force-pushed the feat/trim-video-rich-widget branch from b366170 to 9927fe8 Compare July 27, 2026 17:44
jtydhr88 added a commit that referenced this pull request Jul 27, 2026
This is seperate part for the big PR of video edit, crop, trim PR
#14118

## Summary
- useCropBoxEditor: drag/move and eight-direction resize of a crop rect
in source-pixel space, clamped to the frame with a minimum size;
optional locked aspect ratio anchored on the opposite edge/corner
- useCropRatioLock: aspect-ratio presets and lock state (same presets as
the image crop widget), reshaping the bounds on selection
- VideoCropOverlay: percent-positioned crop rectangle with handles
rendered over a media preview, following the imagecrop visual language
@jtydhr88
jtydhr88 force-pushed the feat/trim-video-rich-widget branch from 9927fe8 to 3fce8da Compare July 27, 2026 18:50
@jtydhr88
jtydhr88 force-pushed the feat/trim-video-rich-widget branch 3 times, most recently from feecf3b to c3b1e0d Compare July 28, 2026 02:31
@jtydhr88
jtydhr88 force-pushed the feat/trim-video-rich-widget branch from c3b1e0d to ba1ad30 Compare July 28, 2026 02:53
jtydhr88 added a commit that referenced this pull request Jul 28, 2026
This is seperate part for the big PR of video edit, crop, trim PR
#14118

## Summary
Timeline building blocks for the upcoming VIDEO_EDIT rich widget:
- VideoFilmstripTrim: filmstrip strip with trim range handles
(useRangeEditor), scrub playhead, and selected-range shading
- useTimelineScrub: pointer scrubbing on the filmstrip track mapped to
frame positions
- useTrimPlayback: play/pause within the trimmed range, restarting from
the trim start when playback reaches the trim end
- useVideoEditFormats: duration and file-size display formatting
- design tokens for the filmstrip/timeline surfaces
jtydhr88 added a commit that referenced this pull request Jul 29, 2026
This is seperate last part for the big PR of video edit, crop, trim PR
#14118

## Summary

Wires the VIDEO_EDIT input type end to end and assembles the editor UI
from the previously merged building blocks:
- litegraph: VideoEditValue/VideoEditTrim widget value types,
VideoEditWidget class and widgetMap/constructor registration
- schema/registry: VIDEO_EDIT zod spec in nodeDefSchemaV2, widget
registry entry rendering WidgetVideoEdit
- useVideoEditModel: canonical seconds/pixels edit state with
frame-based setters, enable toggles, and handle crossover clamps
- VideoEditPanel: trim timeline (filmstrip + range handles + playback)
and crop overlay with ratio lock, driven by backend video metadata
- WidgetVideoEdit: widget shell resolving the source video via
useVideoSourceUrl and suppressing the default node media preview
@jtydhr88 jtydhr88 closed this Jul 30, 2026
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.

2 participants