Compare commits

...

2 Commits

Author SHA1 Message Date
Terry Jia
25205c0f55 feat: add Load3DAdvanced node (#12723)
## Summary
add Load3DAdvanced node, without FE render, upload BE and HDRI.
BE https://github.com/Comfy-Org/ComfyUI/pull/14316

## Screenshots (if applicable)

https://github.com/user-attachments/assets/e561c919-bb52-4904-97da-fb01885762a7
2026-06-10 13:51:50 -04:00
Dante
598cf33ab7 [bugfix] Truncate long workspace names in workspace switcher (#12762)
## Summary

Long team workspace names wrapped onto multiple lines in the user-menu
workspace switcher, overflowing the fixed 54px rows and breaking the
dropdown layout. Applies the same single-line ellipsis pattern already
used by the current-workspace header
(`CurrentUserPopoverWorkspace.vue`).

## Changes

- **What**: `truncate` on the switcher name span, `max-w-full` on the
name row, `shrink-0` on avatar/tier badge/check icon so only the name
shrinks (`WorkspaceSwitcherPopover.vue`, 5 lines)
- Regression tests: Vitest component test + `@cloud` Playwright e2e
measuring single-line render height

Fixes
[FE-778](https://linear.app/comfyorg/issue/FE-778/bug-team-workspace-names-wrapping-to-multiple-lines-display-poorly-in)

## Red-Green Verification

| Commit | CI | Result |
|---|---|---|
| `30e04e2` test only | [Tests
Unit](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27278378157)
/ [Tests
E2E](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27278378213)
| 🔴 new unit test + cloud e2e fail (proves tests catch the
bug) |
| `d8f9a5c` fix | [Tests
Unit](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27279508881)
/ [Tests
E2E](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27279508715)
| 🟢 same tests pass |

## Screenshots

| Before | After |
|---|---|
| <img width="320" alt="before — name wraps to 4 lines, rows collide"
src="https://github.com/user-attachments/assets/90f3286a-5b50-4477-9b5c-9d32d0b026e4"
/> | <img width="320" alt="after — single line with ellipsis, row height
intact"
src="https://github.com/user-attachments/assets/8e47bbb2-b5b1-4945-a008-68491f39dc46"
/> |

## Review Focus

- Truncation chain: the name span is a flex item, so `truncate`
(overflow-hidden) zeroes its automatic min size; `max-w-full` caps the
`items-start` row at the container width. Mirrors the header pattern —
no new component.
- Figma `Team Plan - Workspaces` (Workspaces Menu component, node
2045-14413) specifies compact single-line rows; long-name overflow was
undesigned, truncation preserves the spec'd layout.
2026-06-10 14:45:36 +00:00
20 changed files with 604 additions and 39 deletions

View File

@@ -0,0 +1,101 @@
import { expect } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
// text-sm rows render a single 20px line; a wrapped name is 40px+.
const SINGLE_LINE_MAX_HEIGHT_PX = 28
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
workspaces: [
{
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
},
{
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
created_at: '2026-01-02T00:00:00Z',
joined_at: '2026-01-02T00:00:00Z',
role: 'member'
}
]
}
const mockTokenResponse: WorkspaceTokenResponse = {
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: {
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal'
},
role: 'owner',
permissions: []
}
const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRemoteConfig)
})
)
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListWorkspacesResponse)
})
})
await page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTokenResponse)
})
)
await use(page)
}
})
test.describe('Workspace switcher', { tag: '@cloud' }, () => {
test('renders a long team workspace name on a single line', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByText(PERSONAL_WORKSPACE_NAME).click()
const longName = page.getByText(LONG_WORKSPACE_NAME)
await expect(longName).toBeVisible()
const box = await longName.boundingBox()
expect(box).not.toBeNull()
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
})
})

View File

@@ -111,6 +111,7 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -591,7 +591,15 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

View File

@@ -23,6 +23,8 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@@ -86,7 +88,7 @@
/>
<RecordingControls
v-if="!isPreview"
v-if="canUseRecording && !isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -117,9 +119,18 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -130,11 +141,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
onMounted(() => {
node.value = resolveNode(props.nodeId!) ?? null
node.value = resolveNode(nodeId) ?? null
})
}

View File

@@ -0,0 +1,47 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -0,0 +1,21 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,6 +52,7 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -81,6 +82,7 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -129,12 +131,16 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
}>()

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="!hasBackgroundImage">
<div v-if="showBackgroundImage && !hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,12 +83,16 @@
</div>
<PopupSlider
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -114,8 +118,9 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false } = defineProps<{
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -22,6 +22,7 @@ import {
LOAD3D_NONE_MODEL,
SUPPORTED_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -413,16 +414,10 @@ useExtensionService().registerExtension({
if (cached) return cached
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
currentLoad3d.stopRecording()
const { camera_info, model_3d_info } = snapshotLoad3dState(
node,
currentLoad3d
)
const {
scene: imageData,
@@ -441,16 +436,11 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
camera_info,
recording: '',
model_3d_info
}

View File

@@ -23,6 +23,7 @@ const mtlLoaderStub = {
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
setBaseObject3d: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
@@ -58,6 +59,7 @@ vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
setBaseObject3d = objLoaderStub.setBaseObject3d
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
@@ -247,6 +249,24 @@ describe('MeshModelAdapter', () => {
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
it('resets baseObject3d on every load so meshes do not accumulate across calls', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'first.obj')
await adapter.load(ctx, '/api/view/', 'second.obj')
expect(objLoaderStub.setBaseObject3d).toHaveBeenCalledTimes(2)
const bases = objLoaderStub.setBaseObject3d.mock.calls.map(
([base]) => base
)
expect(bases[0]).toBeInstanceOf(THREE.Object3D)
expect(bases[1]).toBeInstanceOf(THREE.Object3D)
// Each call should hand the loader a fresh container, not the same one.
expect(bases[0]).not.toBe(bases[1])
})
})
describe('GLTF loader path', () => {

View File

@@ -102,6 +102,8 @@ export class MeshModelAdapter implements ModelAdapter {
path: string,
filename: string
): Promise<THREE.Object3D> {
this.objLoader.setBaseObject3d(new THREE.Object3D())
if (ctx.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
function makeNode(props: Record<string, unknown> = {}): LGraphNode {
return { properties: { ...props } } as unknown as LGraphNode
}
const baseCameraState: CameraState = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
} as unknown as CameraState
function makeLoad3d({
cameraType = 'perspective',
fov = 35,
modelInfo = { transform: { position: [0, 0, 0] } } as unknown
}: {
cameraType?: string
fov?: number
modelInfo?: unknown
} = {}) {
return {
getCurrentCameraType: vi.fn(() => cameraType),
cameraManager: { perspectiveCamera: { fov } },
getCameraState: vi.fn(() => baseCameraState),
stopRecording: vi.fn(),
getModelInfo: vi.fn(() => modelInfo)
} as unknown as Load3d
}
describe('snapshotLoad3dState', () => {
it('returns only camera_info and model_3d_info', () => {
const result = snapshotLoad3dState(makeNode(), makeLoad3d())
expect(Object.keys(result).sort()).toEqual(['camera_info', 'model_3d_info'])
})
it('writes the camera state into properties["Camera Config"]', () => {
const node = makeNode()
snapshotLoad3dState(node, makeLoad3d({ fov: 42 }))
const cfg = node.properties['Camera Config'] as Record<string, unknown>
expect(cfg).toMatchObject({
cameraType: 'perspective',
fov: 42,
state: baseCameraState
})
})
it('preserves an existing Camera Config object instead of replacing it', () => {
const existing = { cameraType: 'orthographic', fov: 99 }
const node = makeNode({ 'Camera Config': existing })
snapshotLoad3dState(node, makeLoad3d())
// Same object reference (mutated in place), with state attached.
expect(node.properties['Camera Config']).toBe(existing)
expect(
(node.properties['Camera Config'] as Record<string, unknown>).state
).toBe(baseCameraState)
})
it('stops in-progress recording as a side effect', () => {
const load3d = makeLoad3d()
snapshotLoad3dState(makeNode(), load3d)
expect(load3d.stopRecording).toHaveBeenCalledOnce()
})
it('returns model_3d_info as a single-element list when a model is loaded', () => {
const info = { transform: { position: [1, 2, 3] } }
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: info })
)
expect(result.model_3d_info).toEqual([info])
})
it('returns an empty model_3d_info list when no model is loaded', () => {
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: null })
)
expect(result.model_3d_info).toEqual([])
})
})

View File

@@ -0,0 +1,36 @@
import type Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraConfig,
CameraState,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
export type Load3dSerializedBase = {
camera_info: CameraState | null
model_3d_info: Model3DInfo
}
export function snapshotLoad3dState(
node: LGraphNode,
load3d: Load3d
): Load3dSerializedBase {
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
| CameraConfig
| undefined) || {
cameraType: load3d.getCurrentCameraType(),
fov: load3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = load3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
load3d.stopRecording()
const modelInfo = load3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
camera_info: cameraConfig.state ?? null,
model_3d_info
}
}

View File

@@ -9,7 +9,12 @@ const LOAD3D_PREVIEW_NODES = new Set([
'PreviewPointCloud'
])
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)

View File

@@ -0,0 +1,103 @@
import { nextTick } from 'vue'
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
const inputSpecLoad3DAdvanced: CustomInputSpec = {
name: 'viewport_state',
type: 'LOAD_3D_ADVANCED',
isPreview: false
}
useExtensionService().registerExtension({
name: 'Comfy.Load3DAdvanced',
beforeRegisterNodeDef(_nodeType, nodeData) {
if (nodeData.name !== 'Load3DAdvanced') return
if (!nodeData.input?.required) return
nodeData.input.required.viewport_state = ['LOAD_3D_ADVANCED', {}]
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
getCustomWidgets() {
return {
LOAD_3D_ADVANCED(node) {
const widget = new ComponentWidgetImpl({
node,
name: 'viewport_state',
component: Load3DAdvanced,
inputSpec: inputSpecLoad3DAdvanced,
options: {}
})
widget.type = 'load3DAdvanced'
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 600)])
await nextTick()
useLoad3d(node).onLoad3dReady((load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (!modelWidget || !width || !height) return
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const config = new Load3DConfiguration(load3d, node.properties)
config.configure({
loadFolder: 'input',
modelWidget,
cameraState,
width,
height
})
})
useLoad3d(node).waitForLoad3d(() => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
return snapshotLoad3dState(node, currentLoad3d)
}
})
}
})

View File

@@ -37,6 +37,7 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
// Import extensions - they self-register via useExtensionService()
await Promise.all([
import('./load3d'),
import('./load3dAdvanced'),
import('./load3dPreviewExtensions'),
import('./saveMesh')
])
@@ -66,6 +67,12 @@ useExtensionService().registerExtension({
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

View File

@@ -0,0 +1,94 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import WorkspaceSwitcherPopover from './WorkspaceSwitcherPopover.vue'
vi.mock('@/platform/workspace/composables/useWorkspaceSwitch', () => ({
useWorkspaceSwitch: () => ({ switchWorkspace: vi.fn() })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ subscription: ref(null) })
}))
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
workspaceSwitcher: {
personal: 'Personal',
roleOwner: 'Owner',
roleMember: 'Member',
createWorkspace: 'Create new workspace',
maxWorkspacesReached:
'You can only own 10 workspaces. Delete one to create a new one.'
}
}
}
})
function createWorkspaceState(overrides: Record<string, unknown>) {
return {
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: [],
...overrides
}
}
function renderComponent() {
return render(WorkspaceSwitcherPopover, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
teamWorkspace: {
activeWorkspaceId: 'ws-personal',
isFetchingWorkspaces: false,
workspaces: [
createWorkspaceState({
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}),
createWorkspaceState({
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
role: 'member'
})
]
}
}
}),
i18n
],
stubs: {
WorkspaceProfilePic: true
}
}
})
}
describe('WorkspaceSwitcherPopover', () => {
it('exposes the full team workspace name as a tooltip on the row', () => {
renderComponent()
const name = screen.getByText(LONG_WORKSPACE_NAME)
expect(name).toHaveAttribute('title', LONG_WORKSPACE_NAME)
})
})

View File

@@ -34,21 +34,20 @@
@click="handleSelectWorkspace(workspace)"
>
<WorkspaceProfilePic
class="size-8 text-sm"
class="size-8 shrink-0 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
<div class="flex items-center gap-1.5">
<span class="text-sm text-base-foreground">
{{
workspace.type === 'personal'
? $t('workspaceSwitcher.personal')
: workspace.name
}}
<div class="flex max-w-full items-center gap-1.5">
<span
:title="getDisplayName(workspace)"
class="truncate text-sm text-base-foreground"
>
{{ getDisplayName(workspace) }}
</span>
<span
v-if="resolveTierLabel(workspace)"
class="rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
class="shrink-0 rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ resolveTierLabel(workspace) }}
</span>
@@ -59,7 +58,7 @@
</div>
<i
v-if="isCurrentWorkspace(workspace)"
class="pi pi-check text-sm text-base-foreground"
class="pi pi-check shrink-0 text-sm text-base-foreground"
/>
</button>
</div>
@@ -171,6 +170,12 @@ function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
return workspace.id === workspaceId.value
}
function getDisplayName(workspace: AvailableWorkspace): string {
return workspace.type === 'personal'
? t('workspaceSwitcher.personal')
: workspace.name
}
function getRoleLabel(role: AvailableWorkspace['role']): string {
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
if (role === 'member') return t('workspaceSwitcher.roleMember')

View File

@@ -33,6 +33,10 @@ const WorkspaceTokenResponseSchema = z.object({
permissions: z.array(z.string())
})
export type WorkspaceTokenResponse = z.infer<
typeof WorkspaceTokenResponseSchema
>
export class WorkspaceAuthError extends Error {
constructor(
message: string,

View File

@@ -51,6 +51,9 @@ const AudioPreviewPlayer = defineAsyncComponent(
const Load3D = defineAsyncComponent(
() => import('@/components/load3d/Load3D.vue')
)
const Load3DAdvanced = defineAsyncComponent(
() => import('@/components/load3d/Load3DAdvanced.vue')
)
const WidgetImageCrop = defineAsyncComponent(
() => import('@/components/imagecrop/WidgetImageCrop.vue')
)
@@ -169,6 +172,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
}
],
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }],
[
'load3DAdvanced',
{
component: Load3DAdvanced,
aliases: ['LOAD_3D_ADVANCED'],
essential: false
}
],
[
'imagecrop',
{
@@ -243,6 +254,7 @@ const EXPANDING_TYPES = [
'textarea',
'markdown',
'load3D',
'load3DAdvanced',
'curve',
'painter',
'imagecompare',