Compare commits

..

4 Commits

Author SHA1 Message Date
huang47
8a73cef38e fix: enable RUM on Cloud preview environments 2026-07-17 16:54:02 -07:00
huang47
4e86bafed5 Merge remote-tracking branch 'origin/main' into codex/cloud-datadog-rum
# Conflicts:
#	pnpm-lock.yaml
2026-07-17 15:10:15 -07:00
huang47
086cc313ef fix: own Cloud RUM initialization in frontend 2026-07-17 15:05:20 -07:00
huang47
4c3c25bb59 fix: initialize Cloud RUM before app bootstrap 2026-07-15 16:35:45 -07:00
31 changed files with 360 additions and 638 deletions

View File

@@ -110,6 +110,24 @@ jobs:
fi
echo '✅ No PostHog references found'
- name: Scan dist for Datadog RUM references
run: |
set -euo pipefail
echo '🔍 Scanning for Datadog RUM references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '@datadog/browser-rum' \
-e '041a9897-5516-4b1f-a245-1a9aa6895488' \
-e 'pub7704486e5b64eb4ff6f62891cda45559' \
-e 'comfy-cloud-frontend' \
dist; then
echo '❌ ERROR: Datadog RUM references found in dist assets!'
echo 'Datadog RUM must be properly tree-shaken from OSS builds.'
exit 1
fi
echo '✅ No Datadog RUM references found'
- name: Scan dist for Customer.io telemetry references
run: |
set -euo pipefail

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,9 +1,3 @@
coverage:
status:
patch:
default:
informational: true
comment:
layout: 'header, diff, flags, files'
behavior: default

View File

@@ -162,6 +162,6 @@
})()
</script>
<div id="vue-app"></div>
<script type="module" src="src/main.ts"></script>
<script type="module" src="src/bootstrap.ts"></script>
</body>
</html>

View File

@@ -74,6 +74,7 @@
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@datadog/browser-rum": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

31
pnpm-lock.yaml generated
View File

@@ -27,6 +27,9 @@ catalogs:
'@customerio/cdp-analytics-browser':
specifier: ^0.5.3
version: 0.5.6
'@datadog/browser-rum':
specifier: ^6.33.0
version: 6.33.0
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1
@@ -471,6 +474,9 @@ importers:
'@customerio/cdp-analytics-browser':
specifier: 'catalog:'
version: 0.5.6
'@datadog/browser-rum':
specifier: 'catalog:'
version: 6.33.0
'@formkit/auto-animate':
specifier: 'catalog:'
version: 0.9.0
@@ -1531,6 +1537,20 @@ packages:
'@cyberalien/svg-utils@1.2.18':
resolution: {integrity: sha512-avRdGj4MNoFyjehtaMhAggiceWmKRS5tB+P9YKZdcCATQsTYeiYifBjo0suyXOMIdadCcqxAZYniA305dpaMAA==}
'@datadog/browser-core@6.33.0':
resolution: {integrity: sha512-h/xuBuY4Xi32CbBHkChMMWlOQ4al/mFF08x3o0hqrderfJvX7VO5tD3OVNwF+pfMXOGlDtBCIhwazr9sn24h3g==}
'@datadog/browser-rum-core@6.33.0':
resolution: {integrity: sha512-Ais4x7zQBrBI6JyILYwFAmjUsqy0iQp2C7JcEEfzU4YYYcVO3DGpyVuYYrqHT27jY1QToHY8EnQUr3Yaj6saDA==}
'@datadog/browser-rum@6.33.0':
resolution: {integrity: sha512-zdxs1PyKt1RQ/fdxsmaCJUeBhB+leCIaPJBcum7uDi/DL8nuu9A9NUc5qGVPlY9zc12v7t2NI8bLpOrMjv+jTg==}
peerDependencies:
'@datadog/browser-logs': 6.33.0
peerDependenciesMeta:
'@datadog/browser-logs':
optional: true
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
@@ -10070,6 +10090,17 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@datadog/browser-core@6.33.0': {}
'@datadog/browser-rum-core@6.33.0':
dependencies:
'@datadog/browser-core': 6.33.0
'@datadog/browser-rum@6.33.0':
dependencies:
'@datadog/browser-core': 6.33.0
'@datadog/browser-rum-core': 6.33.0
'@dimforge/rapier3d-compat@0.12.0': {}
'@dual-bundle/import-meta-resolve@4.2.1': {}

View File

@@ -17,6 +17,7 @@ catalog:
'@astrojs/vue': ^6.0.1
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@datadog/browser-rum': ^6.33.0
'@eslint/js': ^10.0.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178

8
src/bootstrap.ts Normal file
View File

@@ -0,0 +1,8 @@
if (__DISTRIBUTION__ === 'cloud') {
const { initDatadogRum } = await import('@/platform/telemetry/initDatadogRum')
initDatadogRum()
}
await import('./main')
export {}

View File

@@ -10,7 +10,6 @@ import {
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import { toValue } from 'vue'
import type { StyleValue } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
@@ -20,12 +19,7 @@ defineOptions({
inheritAttrs: false
})
defineProps<{
itemClass: string
contentClass: string
contentStyle?: StyleValue
item: MenuItem
}>()
defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
</script>
<template>
<DropdownMenuSeparator
@@ -43,7 +37,6 @@ defineProps<{
<DropdownMenuPortal>
<DropdownMenuSubContent
:class="contentClass"
:style="contentStyle"
:side-offset="2"
:align-offset="-5"
>
@@ -53,7 +46,6 @@ defineProps<{
:item="subitem"
:item-class
:content-class
:content-style
/>
</DropdownMenuSubContent>
</DropdownMenuPortal>

View File

@@ -72,7 +72,6 @@ const contentStyle = useModalLiftedZIndex(open)
:key="toValue(item.label) ?? index"
:item-class
:content-class
:content-style
:item
/>
</slot>

View File

@@ -1,8 +1,7 @@
import { ZIndex } from '@primeuix/utils/zindex'
import { render, screen, within } from '@testing-library/vue'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { MenuItem } from 'primevue/menuitem'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
@@ -15,9 +14,9 @@ const i18n = createI18n({
messages: { en: enMessages }
})
function renderMenu(entries: MenuItem[] = [{ label: 'Item A' }]) {
function renderMenu() {
return render(DropdownMenu, {
props: { entries },
props: { entries: [{ label: 'Item A' }] },
global: { plugins: [i18n], directives: { tooltip: {} } }
})
}
@@ -54,32 +53,4 @@ describe('DropdownMenu z-index', () => {
expect(menu.style.zIndex).toBe('')
expect(menu.className).toContain('z-1700')
})
it('opens a nested menu above a registered dialog', async () => {
openModal = document.createElement('div')
ZIndex.set('modal', openModal, 1700)
const dialogZ = Number(openModal.style.zIndex)
const command = vi.fn()
const user = userEvent.setup()
renderMenu([
{
label: 'Change role',
items: [
{ label: 'Owner', command },
{ label: 'Member', command }
]
}
])
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('menuitem', { name: 'Change role' }))
expect(await screen.findByRole('menuitem', { name: 'Owner' })).toBeVisible()
expect(screen.getByRole('menuitem', { name: 'Member' })).toBeVisible()
const submenu = screen
.getAllByRole('menu')
.find((menu) => within(menu).queryByRole('menuitem', { name: 'Owner' }))
expect(submenu).toBeDefined()
expect(Number(submenu?.style.zIndex)).toBeGreaterThan(dialogZ)
})
})

View File

@@ -833,44 +833,6 @@ describe('useLoad3d', () => {
expect(composable.sceneConfig.value.backgroundImage).toBe('')
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('')
})
it('should reset a leftover panorama mode to tiled when uploading a new image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.sceneConfig.value.backgroundRenderMode = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(composable.sceneConfig.value.backgroundRenderMode).toBe('tiled')
await nextTick()
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenLastCalledWith(
'tiled'
)
})
it('should not clear the background or touch render mode when the upload fails', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue(undefined)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.sceneConfig.value.backgroundImage = 'existing.jpg'
composable.sceneConfig.value.backgroundRenderMode = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(composable.sceneConfig.value.backgroundImage).toBe('existing.jpg')
expect(composable.sceneConfig.value.backgroundRenderMode).toBe('panorama')
})
})
describe('model export', () => {

View File

@@ -770,9 +770,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
if (!uploadedPath) return
sceneConfig.value.backgroundRenderMode = 'tiled'
sceneConfig.value.backgroundImage = uploadedPath
await load3d?.setBackgroundImage(uploadedPath)
}

View File

@@ -560,44 +560,6 @@ describe('useLoad3dViewer', () => {
expect(viewer.hasBackgroundImage.value).toBe(false)
})
it('should reset render mode to tiled when uploading a new image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.backgroundRenderMode.value = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(viewer.backgroundRenderMode.value).toBe('tiled')
})
it('should not clear the background or touch render mode when the upload fails', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(undefined)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.backgroundImage.value = 'existing.jpg'
viewer.backgroundRenderMode.value = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(viewer.backgroundImage.value).toBe('existing.jpg')
expect(viewer.backgroundRenderMode.value).toBe('panorama')
})
it('should handle upload errors', async () => {
vi.mocked(Load3dUtils.uploadFile).mockRejectedValueOnce(
new Error('Upload failed')

View File

@@ -752,7 +752,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
)
if (uploadPath) {
backgroundRenderMode.value = 'tiled'
backgroundImage.value = uploadPath
hasBackgroundImage.value = true
}

View File

@@ -353,10 +353,7 @@ describe('Load3d', () => {
expect(sceneResize).toHaveBeenCalledWith(800, 400)
})
function makeRenderMainSceneContext(
backgroundType: 'color' | 'image' = 'color',
activeCamera: THREE.Camera = ctx.cameraManager.activeCamera
) {
it('renderMainScene applies the letterboxed viewport and feeds aspect to the camera', () => {
const setViewport = vi.fn()
const setScissor = vi.fn()
const setScissorTest = vi.fn()
@@ -365,10 +362,6 @@ describe('Load3d', () => {
const render = vi.fn()
const updateAspectRatio = vi.fn()
const renderBackground = vi.fn()
const getCurrentBackgroundInfo = vi.fn(() => ({
type: backgroundType,
value: ''
}))
const scene = {} as THREE.Scene
@@ -390,133 +383,19 @@ describe('Load3d', () => {
targetHeight: 200,
targetAspectRatio: 2,
isViewerMode: false,
cameraManager: {
...ctx.cameraManager,
updateAspectRatio,
activeCamera
},
sceneManager: {
...ctx.sceneManager,
renderBackground,
getCurrentBackgroundInfo,
scene
}
})
return {
setViewport,
setScissor,
setScissorTest,
clear,
render,
updateAspectRatio,
renderBackground,
scene
}
}
it('renderMainScene renders the full canvas with an extrapolated view offset so the letterboxed rect is unchanged', () => {
const r = makeRenderMainSceneContext()
const camera = ctx.cameraManager.activeCamera as THREE.PerspectiveCamera
let viewAtRender: THREE.PerspectiveCamera['view'] = null
r.render.mockImplementationOnce(() => {
viewAtRender = camera.view ? { ...camera.view } : null
cameraManager: { ...ctx.cameraManager, updateAspectRatio },
sceneManager: { ...ctx.sceneManager, renderBackground, scene }
})
ctx.load3d.renderMainScene()
// Container 800x600, target aspect 2:1 → letterboxed rect 800x400 at y=100
expect(r.setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(r.setScissor).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(r.setScissorTest).toHaveBeenCalledWith(true)
expect(r.clear).toHaveBeenCalledOnce()
expect(r.updateAspectRatio).toHaveBeenCalledWith(2)
expect(r.render).toHaveBeenNthCalledWith(1, r.scene, camera)
expect(viewAtRender).not.toBeNull()
expect(viewAtRender!.enabled).toBe(true)
expect(viewAtRender!.fullWidth).toBe(800)
expect(viewAtRender!.fullHeight).toBe(400)
expect(viewAtRender!.offsetX).toBeCloseTo(0)
expect(viewAtRender!.offsetY).toBe(-100)
expect(viewAtRender!.width).toBe(800)
expect(viewAtRender!.height).toBe(600)
expect(camera.view?.enabled ?? false).toBe(false)
})
it('renderMainScene dims the letterbox bars after rendering the scene', () => {
const r = makeRenderMainSceneContext()
ctx.load3d.renderMainScene()
expect(r.render).toHaveBeenCalledTimes(3)
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 0, 800, 100)
expect(r.setScissor).toHaveBeenNthCalledWith(2, 0, 0, 800, 100)
expect(r.setViewport).toHaveBeenNthCalledWith(3, 0, 500, 800, 100)
expect(r.setScissor).toHaveBeenNthCalledWith(3, 0, 500, 800, 100)
})
it('renderMainScene keeps a color background covering the whole canvas', () => {
const r = makeRenderMainSceneContext('color')
ctx.load3d.renderMainScene()
expect(r.renderBackground).toHaveBeenCalledWith()
// No viewport narrowing before the bars are dimmed.
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 0, 800, 100)
})
it('renderMainScene confines an image background to the letterboxed rect', () => {
const r = makeRenderMainSceneContext('image')
ctx.load3d.renderMainScene()
// Viewport/scissor narrow to the letterbox rect for the background
// pass, then restore to the full canvas for the scene pass.
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.setViewport).toHaveBeenNthCalledWith(3, 0, 0, 800, 600)
expect(r.setScissor).toHaveBeenNthCalledWith(3, 0, 0, 800, 600)
const backgroundOrder = r.renderBackground.mock.invocationCallOrder[0]
expect(backgroundOrder).toBeGreaterThan(
r.setViewport.mock.invocationCallOrder[1]
)
expect(backgroundOrder).toBeLessThan(
r.setViewport.mock.invocationCallOrder[2]
)
})
it('renderMainScene applies the view offset to orthographic cameras too', () => {
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1)
const r = makeRenderMainSceneContext('color', camera)
let viewEnabledAtRender = false
r.render.mockImplementationOnce(() => {
viewEnabledAtRender = camera.view?.enabled ?? false
})
ctx.load3d.renderMainScene()
expect(r.render).toHaveBeenNthCalledWith(1, r.scene, camera)
expect(viewEnabledAtRender).toBe(true)
expect(camera.view?.enabled ?? false).toBe(false)
})
it('renderMainScene falls back to the letterboxed viewport for cameras without view-offset support', () => {
const camera = new THREE.Camera()
const r = makeRenderMainSceneContext('color', camera)
ctx.load3d.renderMainScene()
expect(r.setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.render).toHaveBeenCalledTimes(1)
expect(r.render).toHaveBeenCalledWith(r.scene, camera)
expect(r.renderBackground).toHaveBeenCalledWith()
expect(setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(setScissor).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(updateAspectRatio).toHaveBeenCalledWith(2)
expect(setScissorTest).toHaveBeenCalledWith(true)
expect(render).toHaveBeenCalledWith(scene, ctx.cameraManager.activeCamera)
})
it('setBackgroundImage updates background size with letterbox dimensions when a texture is loaded', async () => {

View File

@@ -13,7 +13,6 @@ type CameraStub = {
getCurrentCameraType: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
updateAspectRatio: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
activeCamera: THREE.Camera
}
@@ -49,7 +48,6 @@ function makeViewportInstance() {
getCurrentCameraType: vi.fn(() => 'perspective' as const),
handleResize: vi.fn(),
updateAspectRatio: vi.fn(),
dispose: vi.fn(),
activeCamera: new THREE.PerspectiveCamera()
}
const sceneManager: SceneStub = {
@@ -449,82 +447,6 @@ describe('Viewport3d', () => {
})
})
describe('letterbox dimmer lifecycle', () => {
type DimmerInternals = {
dimLetterboxBars(
bars: { x: number; y: number; width: number; height: number }[]
): void
disposeManagers(): void
letterboxDimmer: {
scene: THREE.Scene
camera: THREE.OrthographicCamera
geometry: THREE.PlaneGeometry
material: THREE.MeshBasicMaterial
} | null
}
it('does not create the dim overlay when there are no letterbox bars', () => {
const render = vi.fn()
Object.assign(ctx.viewport, {
view: {
renderer: { setViewport: vi.fn(), setScissor: vi.fn(), render }
}
})
const internals = ctx.viewport as unknown as DimmerInternals
internals.dimLetterboxBars([])
expect(render).not.toHaveBeenCalled()
expect(internals.letterboxDimmer ?? null).toBeNull()
})
it('renders each bar with a lazily created dim overlay', () => {
const setViewport = vi.fn()
const setScissor = vi.fn()
const render = vi.fn()
Object.assign(ctx.viewport, {
view: { renderer: { setViewport, setScissor, render } }
})
const internals = ctx.viewport as unknown as DimmerInternals
internals.dimLetterboxBars([
{ x: 0, y: 0, width: 100, height: 20 },
{ x: 0, y: 80, width: 100, height: 20 }
])
const dimmer = internals.letterboxDimmer
expect(dimmer).not.toBeNull()
expect(setViewport).toHaveBeenNthCalledWith(1, 0, 0, 100, 20)
expect(setViewport).toHaveBeenNthCalledWith(2, 0, 80, 100, 20)
expect(render).toHaveBeenCalledTimes(2)
expect(render).toHaveBeenCalledWith(dimmer!.scene, dimmer!.camera)
})
it('disposeManagers disposes the dim overlay resources', () => {
Object.assign(ctx.viewport, {
view: {
renderer: {
setViewport: vi.fn(),
setScissor: vi.fn(),
render: vi.fn()
}
}
})
const internals = ctx.viewport as unknown as DimmerInternals
internals.dimLetterboxBars([{ x: 0, y: 0, width: 100, height: 20 }])
const dimmer = internals.letterboxDimmer!
const geometryDispose = vi.spyOn(dimmer.geometry, 'dispose')
const materialDispose = vi.spyOn(dimmer.material, 'dispose')
internals.disposeManagers()
expect(geometryDispose).toHaveBeenCalledOnce()
expect(materialDispose).toHaveBeenCalledOnce()
expect(internals.letterboxDimmer).toBeNull()
})
})
describe('start / remove lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()

View File

@@ -18,52 +18,13 @@ import type {
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import type {
LetterboxedViewport,
LetterboxNdc,
ViewportRect
} from './load3dViewport'
import type { LetterboxNdc } from './load3dViewport'
import {
clientPointToLetterboxNdc,
computeLetterboxBars,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
const LETTERBOX_CLEAR_COLOR = 0x0a0a0a
const LETTERBOX_DIM_OPACITY = 0.5
type LetterboxDimmer = {
scene: THREE.Scene
camera: THREE.OrthographicCamera
geometry: THREE.PlaneGeometry
material: THREE.MeshBasicMaterial
}
function supportsViewOffset(
camera: THREE.Camera
): camera is THREE.PerspectiveCamera | THREE.OrthographicCamera {
return (
camera instanceof THREE.PerspectiveCamera ||
camera instanceof THREE.OrthographicCamera
)
}
function createLetterboxDimmer(): LetterboxDimmer {
const geometry = new THREE.PlaneGeometry(2, 2)
const material = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: LETTERBOX_DIM_OPACITY,
depthTest: false,
depthWrite: false
})
const scene = new THREE.Scene()
scene.add(new THREE.Mesh(geometry, material))
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
return { scene, camera, geometry, material }
}
const VIEW_HELPER_SIZE = 128
export type Viewport3dDeps = {
@@ -106,7 +67,6 @@ export class Viewport3d {
private overlay: SceneOverlay | null = null
private initialRenderTimer: ReturnType<typeof setTimeout> | null = null
private viewPixelScale = 1
private letterboxDimmer: LetterboxDimmer | null = null
constructor(
container: HTMLElement,
@@ -297,96 +257,29 @@ export class Viewport3d {
this.renderer.setScissor(0, 0, viewWidth, viewHeight)
this.renderer.setScissorTest(true)
if (!this.shouldMaintainAspectRatio()) {
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: viewWidth, height: viewHeight },
this.targetAspectRatio
)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
this.cameraManager.updateAspectRatio(width / height)
} else {
this.renderer.setClearColor(
this.view.state.clearColor,
this.view.state.clearAlpha
)
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
return
}
const container = { width: viewWidth, height: viewHeight }
const viewport = computeLetterboxedViewport(
container,
this.targetAspectRatio
)
this.renderer.setClearColor(LETTERBOX_CLEAR_COLOR)
this.renderer.clear()
this.cameraManager.updateAspectRatio(viewport.width / viewport.height)
const camera = this.getRenderCamera()
if (!supportsViewOffset(camera)) {
this.renderer.setViewport(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.renderer.setScissor(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, camera)
return
}
camera.setViewOffset(
viewport.width,
viewport.height,
-viewport.offsetX,
-viewport.offsetY,
viewWidth,
viewHeight
)
this.renderLetterboxedBackground(viewport)
this.renderer.render(this.sceneManager.scene, camera)
camera.clearViewOffset()
this.dimLetterboxBars(computeLetterboxBars(container, viewport))
}
private renderLetterboxedBackground(viewport: LetterboxedViewport): void {
if (this.sceneManager.getCurrentBackgroundInfo().type !== 'image') {
this.sceneManager.renderBackground()
return
}
this.renderer.setViewport(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.renderer.setScissor(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.sceneManager.renderBackground()
this.renderer.setViewport(0, 0, this.view.width, this.view.height)
this.renderer.setScissor(0, 0, this.view.width, this.view.height)
}
private dimLetterboxBars(bars: ViewportRect[]): void {
if (bars.length === 0) return
const dimmer = (this.letterboxDimmer ??= createLetterboxDimmer())
for (const bar of bars) {
this.renderer.setViewport(bar.x, bar.y, bar.width, bar.height)
this.renderer.setScissor(bar.x, bar.y, bar.width, bar.height)
this.renderer.render(dimmer.scene, dimmer.camera)
}
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
}
clientPointToNdc(clientX: number, clientY: number): LetterboxNdc | null {
@@ -542,11 +435,6 @@ export class Viewport3d {
this.overlay.dispose()
this.overlay = null
}
if (this.letterboxDimmer) {
this.letterboxDimmer.geometry.dispose()
this.letterboxDimmer.material.dispose()
this.letterboxDimmer = null
}
this.sceneManager.dispose()
this.cameraManager.dispose()
this.controlsManager.dispose()

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'
import {
clientPointToLetterboxNdc,
computeLetterboxBars,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
@@ -83,53 +82,6 @@ describe('computeLetterboxedViewport', () => {
})
})
describe('computeLetterboxBars', () => {
it('returns left and right bars for a pillarboxed viewport', () => {
const container = { width: 800, height: 400 }
const bars = computeLetterboxBars(
container,
computeLetterboxedViewport(container, 1)
)
expect(bars).toEqual([
{ x: 0, y: 0, width: 200, height: 400 },
{ x: 600, y: 0, width: 200, height: 400 }
])
})
it('returns bottom and top bars for a letterboxed viewport', () => {
const container = { width: 400, height: 800 }
const bars = computeLetterboxBars(
container,
computeLetterboxedViewport(container, 1)
)
expect(bars).toEqual([
{ x: 0, y: 0, width: 400, height: 200 },
{ x: 0, y: 600, width: 400, height: 200 }
])
})
it('returns no bars when the viewport fills the container', () => {
const container = { width: 1024, height: 768 }
const bars = computeLetterboxBars(
container,
computeLetterboxedViewport(container, 1024 / 768)
)
expect(bars).toEqual([])
})
it('ignores sub-pixel offsets', () => {
const bars = computeLetterboxBars(
{ width: 800, height: 400.5 },
{ offsetX: 0, offsetY: 0.25, width: 800, height: 400 }
)
expect(bars).toEqual([])
})
})
describe('isLoad3dActive', () => {
const idle: Load3dActivityFlags = {
mouseOnNode: false,

View File

@@ -2,20 +2,13 @@ import { denormalize, normalize } from '@/utils/mathUtil'
type Size = { width: number; height: number }
export type LetterboxedViewport = {
type LetterboxedViewport = {
offsetX: number
offsetY: number
width: number
height: number
}
export type ViewportRect = {
x: number
y: number
width: number
height: number
}
export function computeLetterboxedViewport(
container: Size,
targetAspectRatio: number
@@ -76,37 +69,6 @@ export function clientPointToLetterboxNdc(
)
}
export function computeLetterboxBars(
container: Size,
viewport: LetterboxedViewport
): ViewportRect[] {
if (viewport.offsetX >= 1) {
return [
{ x: 0, y: 0, width: viewport.offsetX, height: container.height },
{
x: viewport.offsetX + viewport.width,
y: 0,
width: container.width - viewport.offsetX - viewport.width,
height: container.height
}
]
}
if (viewport.offsetY >= 1) {
return [
{ x: 0, y: 0, width: container.width, height: viewport.offsetY },
{
x: 0,
y: viewport.offsetY + viewport.height,
width: container.width,
height: container.height - viewport.offsetY - viewport.height
}
]
}
return []
}
export type Load3dActivityFlags = {
mouseOnNode: boolean
mouseOnScene: boolean

View File

@@ -1,77 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
const settings = vi.hoisted(() => ({
values: {} as Record<string, unknown>
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => settings.values[key] ?? [])
}))
}))
describe('keybindingService - registerUserKeybindings', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
settings.values = {}
setActivePinia(createTestingPinia({ stubActions: false }))
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('does not warn when unset binding targets a command that no longer exists', () => {
// A command removed from the app (e.g. ConvertSelectedNodesToGroupNode,
// removed in #12931) can still linger in the persisted UnsetBindings.
settings.values['Comfy.Keybinding.UnsetBindings'] = [
{
commandId: 'ConvertSelectedNodesToGroupNode',
combo: { key: 'g', ctrl: true, alt: false, shift: false }
}
]
useKeybindingService().registerUserKeybindings()
expect(warnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Trying to unset non-exist keybinding')
)
})
it('still unsets bindings for commands that are registered', () => {
const commandStore = useCommandStore()
commandStore.registerCommand({
id: 'Comfy.Test.Registered',
function: vi.fn()
})
const keybindingStore = useKeybindingStore()
const combo = { key: 'g', ctrl: true, alt: false, shift: false }
keybindingStore.addDefaultKeybinding(
new KeybindingImpl({ commandId: 'Comfy.Test.Registered', combo })
)
settings.values['Comfy.Keybinding.UnsetBindings'] = [
{ commandId: 'Comfy.Test.Registered', combo }
]
useKeybindingService().registerUserKeybindings()
expect(warnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Trying to unset non-exist keybinding')
)
expect(
keybindingStore.getKeybindingByCommandId('Comfy.Test.Registered')
).toBeUndefined()
})
})

View File

@@ -121,9 +121,6 @@ export function useKeybindingService() {
function registerUserKeybindings() {
const unsetBindings = settingStore.get('Comfy.Keybinding.UnsetBindings')
for (const keybinding of unsetBindings) {
if (!commandStore.isRegistered(keybinding.commandId)) {
continue
}
keybindingStore.unsetKeybinding(new KeybindingImpl(keybinding))
}
const newBindings = settingStore.get('Comfy.Keybinding.NewBindings')

View File

@@ -0,0 +1,62 @@
import { fromPartial } from '@total-typescript/shoehorn'
import type { RumErrorEvent } from '@datadog/browser-rum'
import { describe, expect, it } from 'vitest'
import { classifyRumErrorOrigin, rumBeforeSend } from './datadogRumBeforeSend'
function createErrorEvent(message: string, stack?: string): RumErrorEvent {
return fromPartial<RumErrorEvent>({
type: 'error',
error: { message, source: 'source', stack }
})
}
describe('rumBeforeSend', () => {
it('drops known third-party network noise', () => {
const event = createErrorEvent(
'Failed to fetch https://px.ads.linkedin.com/pixel'
)
expect(rumBeforeSend(event, fromPartial({}))).toBe(false)
})
it('keeps application errors and tags their origin', () => {
const event = createErrorEvent(
'Application failed',
'at render (https://cloud.comfy.org/assets/app.js:1:2)'
)
expect(rumBeforeSend(event, fromPartial({}))).toBe(true)
expect(event.context).toEqual({ error: { origin: 'first_party' } })
})
it('tags custom extension errors with their folder', () => {
const event = createErrorEvent(
'Extension failed',
'at run (https://cloud.comfy.org/extensions/comfyui-foo/main.js:1:2)'
)
expect(rumBeforeSend(event, fromPartial({}))).toBe(true)
expect(event.context).toEqual({
error: { origin: 'extension', extension: 'comfyui-foo' }
})
})
})
describe('classifyRumErrorOrigin', () => {
it('uses the first recognizable in-app stack frame', () => {
const stack = [
'at external (https://cdn.example.com/library.js:1:2)',
'at cloud (https://cloud.comfy.org/extensions/cloud/main.js:1:2)',
'at custom (https://cloud.comfy.org/extensions/comfyui-foo/main.js:1:2)'
].join('\n')
expect(classifyRumErrorOrigin(stack)).toEqual({ origin: 'first_party' })
})
it('classifies stacks without in-app frames as third party', () => {
expect(
classifyRumErrorOrigin('at external (https://cdn.example.com/app.js:1:2)')
).toEqual({ origin: 'third_party' })
})
})

View File

@@ -0,0 +1,72 @@
import type { RumBeforeSend, RumErrorEvent } from '@datadog/browser-rum'
const RUM_NOISE_HOSTS = [
'facebook.com',
'px.ads.linkedin.com',
'browser-intake-us5-datadoghq.com',
'e2.sy-d.io',
'google-analytics.com',
'googletagmanager.com'
]
const FIRST_PARTY_EXTENSION_FOLDERS = new Set(['cloud', 'core'])
type RumErrorOrigin =
| { origin: 'first_party' }
| { origin: 'extension'; extension: string }
| { origin: 'third_party' }
export function classifyRumErrorOrigin(stack?: string): RumErrorOrigin {
if (!stack) return { origin: 'third_party' }
for (const line of stack.split('\n')) {
const extensionFolder = /\/extensions\/([^/?#]+)\//.exec(line)?.[1]
if (extensionFolder) {
return FIRST_PARTY_EXTENSION_FOLDERS.has(extensionFolder)
? { origin: 'first_party' }
: { origin: 'extension', extension: extensionFolder }
}
if (line.includes('/assets/')) return { origin: 'first_party' }
}
return { origin: 'third_party' }
}
function shouldKeepRumEvent(event: Parameters<RumBeforeSend>[0]): boolean {
if (event.type !== 'error') return true
const message = event.error.message
if (message.startsWith('intervention:')) return false
if (message.includes('ResizeObserver loop')) return false
const isNetworkNoise =
message.includes('csp_violation') || message.includes('Failed to fetch')
return (
!isNetworkNoise || !RUM_NOISE_HOSTS.some((host) => message.includes(host))
)
}
function tagRumErrorOrigin(event: RumErrorEvent): void {
try {
const errorOrigin = classifyRumErrorOrigin(event.error.stack)
const existingErrorContext = event.context?.error
const errorContext =
typeof existingErrorContext === 'object' && existingErrorContext !== null
? existingErrorContext
: {}
event.context = {
...event.context,
error: { ...errorContext, ...errorOrigin }
}
} catch {
return
}
}
export const rumBeforeSend: RumBeforeSend = (event) => {
if (!shouldKeepRumEvent(event)) return false
if (event.type === 'error') tagRumErrorOrigin(event)
return true
}

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => ({
getInitConfiguration: vi.fn(),
init: vi.fn()
}))
vi.mock('@datadog/browser-rum', () => ({
datadogRum: hoisted
}))
import { rumBeforeSend } from './datadogRumBeforeSend'
import { initDatadogRum } from './initDatadogRum'
describe('initDatadogRum', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.getInitConfiguration.mockReturnValue(undefined)
})
it.for([
{ hostname: 'cloud.comfy.org', env: 'prod-v2' },
{ hostname: 'stagingcloud.comfy.org', env: 'stg-v2' },
{ hostname: 'testcloud.comfy.org', env: 'test-v2' },
{ hostname: 'fe-pr-13691.testenvs.comfy.org', env: 'test-v2' }
])('initializes $hostname with the $env environment', ({ hostname, env }) => {
initDatadogRum(hostname)
expect(hoisted.init).toHaveBeenCalledWith({
clientToken: 'pub7704486e5b64eb4ff6f62891cda45559',
applicationId: '041a9897-5516-4b1f-a245-1a9aa6895488',
site: 'us5.datadoghq.com',
service: 'comfy-cloud-frontend',
env,
version: __COMFYUI_FRONTEND_VERSION__,
beforeSend: rumBeforeSend,
sessionSampleRate: 100,
sessionReplaySampleRate: 0,
allowedTracingUrls: [expect.any(RegExp)]
})
})
it.for([
'localhost',
'testenvs.comfy.org',
'eviltestenvs.comfy.org',
'preview.testenvs.comfy.org.example.com'
])('does not initialize on unknown hostname %s', (hostname) => {
initDatadogRum(hostname)
expect(hoisted.init).not.toHaveBeenCalled()
})
it('does not initialize twice', () => {
hoisted.getInitConfiguration.mockReturnValue({})
initDatadogRum('cloud.comfy.org')
expect(hoisted.init).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,29 @@
import { datadogRum } from '@datadog/browser-rum'
import { rumBeforeSend } from './datadogRumBeforeSend'
const DATADOG_ENV_BY_HOSTNAME = new Map([
['cloud.comfy.org', 'prod-v2'],
['stagingcloud.comfy.org', 'stg-v2'],
['testcloud.comfy.org', 'test-v2']
])
export function initDatadogRum(hostname = window.location.hostname): void {
const env =
DATADOG_ENV_BY_HOSTNAME.get(hostname) ??
(hostname.endsWith('.testenvs.comfy.org') ? 'test-v2' : undefined)
if (!env || datadogRum.getInitConfiguration()) return
datadogRum.init({
clientToken: 'pub7704486e5b64eb4ff6f62891cda45559',
applicationId: '041a9897-5516-4b1f-a245-1a9aa6895488',
site: 'us5.datadoghq.com',
service: 'comfy-cloud-frontend',
env,
version: __COMFYUI_FRONTEND_VERSION__,
beforeSend: rumBeforeSend,
sessionSampleRate: 100,
sessionReplaySampleRate: 0,
allowedTracingUrls: [/^https:\/\/[^/]+\.comfy\.org/]
})
}

View File

@@ -534,7 +534,7 @@ function handleSubscribe(tierKey: CheckoutTierKey) {
}
function handleContactUs() {
window.open('https://discord.com/invite/comfyorg', '_blank')
window.open('https://www.comfy.org/discord', '_blank')
}
function handleViewEnterprise() {

View File

@@ -3,7 +3,7 @@
<i18n-t keypath="subscription.preview.termsAgreement" tag="span">
<template #terms>
<a
href="https://www.comfy.org/terms-of-service"
href="https://www.comfy.org/terms"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"
@@ -13,7 +13,7 @@
</template>
<template #privacy>
<a
href="https://www.comfy.org/privacy-policy"
href="https://www.comfy.org/privacy"
target="_blank"
rel="noopener noreferrer"
class="underline hover:text-base-foreground"

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { shouldLoadExtension } from './extensionService'
describe('shouldLoadExtension', () => {
it('skips the legacy Cloud RUM extension in cloud builds', () => {
expect(shouldLoadExtension('/extensions/cloud/rum.js', true)).toBe(false)
})
it('keeps the legacy path available outside cloud builds', () => {
expect(shouldLoadExtension('/extensions/cloud/rum.js', false)).toBe(true)
})
it('skips core extensions that load through the core entry point', () => {
expect(shouldLoadExtension('/extensions/core/foo.js', false)).toBe(false)
})
it('loads other extensions', () => {
expect(shouldLoadExtension('/extensions/comfyui-foo/main.js', true)).toBe(
true
)
})
})

View File

@@ -15,6 +15,16 @@ import type { AuthUserInfo } from '@/types/authTypes'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
const LEGACY_CLOUD_RUM_EXTENSION = '/extensions/cloud/rum.js'
export function shouldLoadExtension(
extension: string,
isCloudBuild: boolean
): boolean {
if (extension.includes('extensions/core')) return false
return !isCloudBuild || extension !== LEGACY_CLOUD_RUM_EXTENSION
}
export const useExtensionService = () => {
const extensionStore = useExtensionStore()
const settingStore = useSettingStore()
@@ -41,7 +51,9 @@ export const useExtensionService = () => {
extensionStore.captureCoreExtensions()
await Promise.all(
extensions
.filter((extension) => !extension.includes('extensions/core'))
.filter((extension) =>
shouldLoadExtension(extension, __DISTRIBUTION__ === 'cloud')
)
.map(async (ext) => {
try {
await import(/* @vite-ignore */ api.fileURL(ext))

View File

@@ -607,6 +607,11 @@ export default defineConfig({
test: /[\\/]node_modules[\\/]@sentry[\\/]/,
priority: 15
},
{
name: 'vendor-datadog',
test: /[\\/]node_modules[\\/]@datadog[\\/]/,
priority: 15
},
// UI component libraries
{