mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-28 08:55:12 +00:00
Compare commits
1 Commits
refactor/m
...
save-previ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439e88f956 |
@@ -74,7 +74,12 @@ vi.mock('@/scripts/domWidget', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { apiURL: (p: string) => p }
|
||||
api: {
|
||||
apiURL: (p: string) => p,
|
||||
addEventListener: vi.fn(),
|
||||
addCustomEventListener: vi.fn(),
|
||||
fetchApi: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
|
||||
@@ -4,6 +4,8 @@ import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
// Side-effect import: registers the WS listener for preview3d.render_request.
|
||||
import '@/extensions/core/load3d/renderBridge'
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState
|
||||
@@ -79,7 +81,7 @@ async function handleModelUpload(files: FileList, node: LGraphNode) {
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
try {
|
||||
load3d.loadModel(modelUrl)
|
||||
load3d.loadModel(modelUrl, undefined, { force: true })
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
|
||||
}
|
||||
|
||||
@@ -877,6 +877,235 @@ describe('Load3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureSceneFixedCamera', () => {
|
||||
function setupForFixedCapture() {
|
||||
const cameraStub = {
|
||||
toggleCamera: vi.fn(),
|
||||
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
|
||||
setCameraState: vi.fn()
|
||||
}
|
||||
const controlsStub = {
|
||||
controls: { update: vi.fn() }
|
||||
}
|
||||
const captureResult = {
|
||||
scene: 'data:image/png;base64,scene',
|
||||
mask: 'data:image/png;base64,mask',
|
||||
normal: 'data:image/png;base64,normal'
|
||||
}
|
||||
const sceneCaptureMock = vi.fn().mockResolvedValue(captureResult)
|
||||
Object.assign(ctx.load3d, {
|
||||
cameraManager: cameraStub,
|
||||
controlsManager: controlsStub,
|
||||
sceneManager: {
|
||||
...ctx.sceneManager,
|
||||
gridHelper: { visible: true },
|
||||
captureScene: sceneCaptureMock
|
||||
},
|
||||
modelManager: {
|
||||
...ctx.modelManager,
|
||||
currentModel: new THREE.Object3D()
|
||||
}
|
||||
})
|
||||
return { cameraStub, controlsStub, sceneCaptureMock, captureResult }
|
||||
}
|
||||
|
||||
const makeCameraState = (
|
||||
cameraType: 'perspective' | 'orthographic' = 'perspective'
|
||||
) => ({
|
||||
position: new THREE.Vector3(1, 2, 3),
|
||||
target: new THREE.Vector3(0, 0, 0),
|
||||
zoom: 1,
|
||||
cameraType
|
||||
})
|
||||
|
||||
it('throws when no model is loaded', async () => {
|
||||
Object.assign(ctx.load3d, {
|
||||
modelManager: { ...ctx.modelManager, currentModel: null }
|
||||
})
|
||||
|
||||
await expect(
|
||||
ctx.load3d.captureSceneFixedCamera(512, 512)
|
||||
).rejects.toThrow('No model loaded for fixed-camera capture')
|
||||
})
|
||||
|
||||
it('captures without setting any camera state when none is provided', async () => {
|
||||
const { cameraStub, sceneCaptureMock, captureResult } =
|
||||
setupForFixedCapture()
|
||||
|
||||
const result = await ctx.load3d.captureSceneFixedCamera(512, 512)
|
||||
|
||||
expect(result).toBe(captureResult)
|
||||
expect(sceneCaptureMock).toHaveBeenCalledWith(512, 512)
|
||||
expect(cameraStub.setCameraState).not.toHaveBeenCalled()
|
||||
expect(cameraStub.toggleCamera).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the grid during capture and restores its prior visibility afterward', async () => {
|
||||
const { sceneCaptureMock } = setupForFixedCapture()
|
||||
const visibilityDuringCapture: boolean[] = []
|
||||
sceneCaptureMock.mockImplementation(async () => {
|
||||
visibilityDuringCapture.push(
|
||||
(ctx.load3d.sceneManager as { gridHelper: { visible: boolean } })
|
||||
.gridHelper.visible
|
||||
)
|
||||
return { scene: 's', mask: 'm', normal: 'n' }
|
||||
})
|
||||
|
||||
await ctx.load3d.captureSceneFixedCamera(512, 512)
|
||||
|
||||
expect(visibilityDuringCapture).toEqual([false])
|
||||
expect(
|
||||
(ctx.load3d.sceneManager as { gridHelper: { visible: boolean } })
|
||||
.gridHelper.visible
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('applies cameraState before capture when provided', async () => {
|
||||
const { cameraStub, sceneCaptureMock } = setupForFixedCapture()
|
||||
const cameraState = makeCameraState()
|
||||
const setCameraBeforeCapture: number[] = []
|
||||
cameraStub.setCameraState.mockImplementation(() =>
|
||||
setCameraBeforeCapture.push(Date.now())
|
||||
)
|
||||
|
||||
let captureRan = false
|
||||
sceneCaptureMock.mockImplementation(async () => {
|
||||
captureRan = true
|
||||
// At least one setCameraState must have happened before render.
|
||||
expect(setCameraBeforeCapture).toHaveLength(1)
|
||||
return { scene: 's', mask: 'm', normal: 'n' }
|
||||
})
|
||||
|
||||
await ctx.load3d.captureSceneFixedCamera(512, 512, cameraState)
|
||||
|
||||
expect(captureRan).toBe(true)
|
||||
})
|
||||
|
||||
it('re-applies cameraState AFTER capture (race-defense against concurrent thumbnail capture)', async () => {
|
||||
const { cameraStub } = setupForFixedCapture()
|
||||
const cameraState = makeCameraState()
|
||||
|
||||
await ctx.load3d.captureSceneFixedCamera(512, 512, cameraState)
|
||||
|
||||
// The bridge fix: setCameraState must be called twice — once
|
||||
// before the capture and once again after, so any racing
|
||||
// captureThumbnail's finally cannot leave the viewer stuck on
|
||||
// its restored pre-thumbnail state.
|
||||
expect(cameraStub.setCameraState).toHaveBeenCalledTimes(2)
|
||||
expect(cameraStub.setCameraState).toHaveBeenNthCalledWith(1, cameraState)
|
||||
expect(cameraStub.setCameraState).toHaveBeenNthCalledWith(2, cameraState)
|
||||
})
|
||||
|
||||
it('toggles the camera type when cameraState requests a different type', async () => {
|
||||
const { cameraStub } = setupForFixedCapture()
|
||||
cameraStub.getCurrentCameraType.mockReturnValue('perspective')
|
||||
const cameraState = makeCameraState('orthographic')
|
||||
|
||||
await ctx.load3d.captureSceneFixedCamera(512, 512, cameraState)
|
||||
|
||||
expect(cameraStub.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
})
|
||||
|
||||
it('does not toggle the camera when cameraState matches the current type', async () => {
|
||||
const { cameraStub } = setupForFixedCapture()
|
||||
cameraStub.getCurrentCameraType.mockReturnValue('perspective')
|
||||
|
||||
await ctx.load3d.captureSceneFixedCamera(
|
||||
512,
|
||||
512,
|
||||
makeCameraState('perspective')
|
||||
)
|
||||
|
||||
expect(cameraStub.toggleCamera).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still runs the grid + controls restore in finally when captureScene throws', async () => {
|
||||
const { sceneCaptureMock } = setupForFixedCapture()
|
||||
sceneCaptureMock.mockRejectedValueOnce(new Error('render error'))
|
||||
|
||||
await expect(
|
||||
ctx.load3d.captureSceneFixedCamera(512, 512)
|
||||
).rejects.toThrow('render error')
|
||||
|
||||
expect(ctx.forceRender).toHaveBeenCalled()
|
||||
expect(
|
||||
(ctx.load3d.sceneManager as { gridHelper: { visible: boolean } })
|
||||
.gridHelper.visible
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModel same-URL short-circuit', () => {
|
||||
function setupForLoad(currentLoadedUrl: string | null, hasModel: boolean) {
|
||||
const internal = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(ctx.load3d, {
|
||||
_loadGeneration: 0,
|
||||
loadingPromise: null,
|
||||
currentLoadedUrl,
|
||||
_loadModelInternal: internal,
|
||||
modelManager: {
|
||||
...ctx.modelManager,
|
||||
currentModel: hasModel ? new THREE.Object3D() : null
|
||||
}
|
||||
})
|
||||
return { internal }
|
||||
}
|
||||
|
||||
it('does NOT call _loadModelInternal when URL matches and a model is loaded', async () => {
|
||||
const { internal } = setupForLoad('/api/view?filename=a.glb', true)
|
||||
|
||||
await ctx.load3d.loadModel('/api/view?filename=a.glb')
|
||||
|
||||
expect(internal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still ticks _loadGeneration on short-circuit (callers tracking generation see the bump)', async () => {
|
||||
setupForLoad('/api/view?filename=a.glb', true)
|
||||
const baseline = ctx.load3d.currentLoadGeneration
|
||||
|
||||
await ctx.load3d.loadModel('/api/view?filename=a.glb')
|
||||
|
||||
expect(ctx.load3d.currentLoadGeneration).toBe(baseline + 1)
|
||||
})
|
||||
|
||||
it('normalizes away the &rand=... cache-bust param so two URLs that only differ in rand still short-circuit', async () => {
|
||||
const { internal } = setupForLoad(
|
||||
'/api/view?filename=a.glb&rand=0.1',
|
||||
true
|
||||
)
|
||||
|
||||
await ctx.load3d.loadModel('/api/view?filename=a.glb&rand=0.9')
|
||||
|
||||
expect(internal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls through to a full load when the URL genuinely differs', async () => {
|
||||
const { internal } = setupForLoad('/api/view?filename=a.glb', true)
|
||||
|
||||
await ctx.load3d.loadModel('/api/view?filename=b.glb')
|
||||
|
||||
expect(internal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls through when there is no current model even if the URL matches', async () => {
|
||||
const { internal } = setupForLoad('/api/view?filename=a.glb', false)
|
||||
|
||||
await ctx.load3d.loadModel('/api/view?filename=a.glb')
|
||||
|
||||
expect(internal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls through when force: true is passed even if the URL matches', async () => {
|
||||
const { internal } = setupForLoad('/api/view?filename=a.glb', true)
|
||||
|
||||
await ctx.load3d.loadModel('/api/view?filename=a.glb', undefined, {
|
||||
force: true
|
||||
})
|
||||
|
||||
expect(internal).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportModel', () => {
|
||||
beforeEach(() => {
|
||||
cloneSkinnedMock.mockReset()
|
||||
|
||||
@@ -74,6 +74,7 @@ class Load3d {
|
||||
private renderLoop: RenderLoopHandle | null = null
|
||||
private loadingPromise: Promise<void> | null = null
|
||||
private _loadGeneration: number = 0
|
||||
private currentLoadedUrl: string | null = null
|
||||
private onContextMenuCallback?: (event: MouseEvent) => void
|
||||
private getDimensionsCallback?: () => { width: number; height: number } | null
|
||||
|
||||
@@ -540,6 +541,18 @@ class Load3d {
|
||||
): Promise<void> {
|
||||
this._loadGeneration += 1
|
||||
|
||||
const normalize = (u: string | null | undefined) =>
|
||||
typeof u === 'string' ? u.replace(/[?&]rand=[^&]*/g, '') : ''
|
||||
|
||||
if (
|
||||
!options?.force &&
|
||||
this.currentLoadedUrl !== null &&
|
||||
normalize(this.currentLoadedUrl) === normalize(url) &&
|
||||
this.modelManager.currentModel
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.loadingPromise) {
|
||||
try {
|
||||
await this.loadingPromise
|
||||
@@ -574,6 +587,7 @@ class Load3d {
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.animationManager.dispose()
|
||||
this.currentLoadedUrl = null
|
||||
|
||||
await this.loaderManager.loadModel(url, originalFileName, options)
|
||||
|
||||
@@ -583,6 +597,7 @@ class Load3d {
|
||||
this.modelManager.currentModel,
|
||||
this.modelManager.originalModel
|
||||
)
|
||||
this.currentLoadedUrl = url
|
||||
}
|
||||
|
||||
this.handleResize()
|
||||
@@ -607,6 +622,7 @@ class Load3d {
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.adapterRef.current = null
|
||||
this.currentLoadedUrl = null
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
@@ -802,6 +818,52 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public async captureSceneFixedCamera(
|
||||
width: number,
|
||||
height: number,
|
||||
cameraState?: CameraState
|
||||
): Promise<CaptureResult> {
|
||||
if (!this.modelManager.currentModel) {
|
||||
throw new Error('No model loaded for fixed-camera capture')
|
||||
}
|
||||
|
||||
const savedGridVisible = this.sceneManager.gridHelper.visible
|
||||
|
||||
try {
|
||||
this.sceneManager.gridHelper.visible = false
|
||||
|
||||
if (cameraState) {
|
||||
if (
|
||||
this.cameraManager.getCurrentCameraType() !== cameraState.cameraType
|
||||
) {
|
||||
this.cameraManager.toggleCamera(cameraState.cameraType)
|
||||
}
|
||||
this.cameraManager.setCameraState(cameraState)
|
||||
}
|
||||
|
||||
const result = await this.captureScene(width, height)
|
||||
|
||||
// Re-apply cameraState here, AFTER the await. useLoad3d's
|
||||
// modelReady event handler kicks off its own captureThumbnail
|
||||
// for asset-preview persistence concurrently with this call.
|
||||
// captureScene's body is internally synchronous, but the
|
||||
// await-continuations run as microtasks in registration order:
|
||||
// captureThumbnail's continuation (its finally restores the
|
||||
// pre-thumbnail camera state) runs BEFORE ours. Without this
|
||||
// re-apply, that restore clobbers cameraState and the viewer
|
||||
// snaps back to the model's default fit-to-bbox.
|
||||
if (cameraState) {
|
||||
this.cameraManager.setCameraState(cameraState)
|
||||
}
|
||||
|
||||
return result
|
||||
} finally {
|
||||
this.sceneManager.gridHelper.visible = savedGridVisible
|
||||
this.controlsManager.controls?.update()
|
||||
this.forceRender()
|
||||
}
|
||||
}
|
||||
|
||||
public async captureThumbnail(
|
||||
width: number = 256,
|
||||
height: number = 256
|
||||
|
||||
45
src/extensions/core/load3d/cameraInfo.test.ts
Normal file
45
src/extensions/core/load3d/cameraInfo.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toCameraState } from '@/extensions/core/load3d/cameraInfo'
|
||||
|
||||
describe('toCameraState', () => {
|
||||
it('builds Vector3 instances from plain {x,y,z} objects', () => {
|
||||
const state = toCameraState({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 4, y: 5, z: 6 },
|
||||
zoom: 1.5,
|
||||
cameraType: 'perspective'
|
||||
})
|
||||
|
||||
expect(state.position).toBeInstanceOf(THREE.Vector3)
|
||||
expect(state.target).toBeInstanceOf(THREE.Vector3)
|
||||
expect(state.position.toArray()).toEqual([1, 2, 3])
|
||||
expect(state.target.toArray()).toEqual([4, 5, 6])
|
||||
})
|
||||
|
||||
it('passes zoom and cameraType through unchanged', () => {
|
||||
const state = toCameraState({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 2,
|
||||
cameraType: 'orthographic'
|
||||
})
|
||||
|
||||
expect(state.zoom).toBe(2)
|
||||
expect(state.cameraType).toBe('orthographic')
|
||||
})
|
||||
|
||||
it('does not alias the input — mutating the result leaves the input intact', () => {
|
||||
const input = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 1,
|
||||
cameraType: 'perspective' as const
|
||||
}
|
||||
const state = toCameraState(input)
|
||||
state.position.set(99, 99, 99)
|
||||
|
||||
expect(input.position).toEqual({ x: 1, y: 2, z: 3 })
|
||||
})
|
||||
})
|
||||
28
src/extensions/core/load3d/cameraInfo.ts
Normal file
28
src/extensions/core/load3d/cameraInfo.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Wire format for Load3DCamera.CameraInfo coming from the backend
|
||||
* (Python `Load3DCamera.CameraInfo` TypedDict). Vectors arrive as plain
|
||||
* {x, y, z} objects after JSON serialization.
|
||||
*/
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { CameraState, CameraType } from './interfaces'
|
||||
|
||||
export interface CameraInfoSerialized {
|
||||
position: { x: number; y: number; z: number }
|
||||
target: { x: number; y: number; z: number }
|
||||
zoom: number
|
||||
cameraType: CameraType
|
||||
}
|
||||
|
||||
export function toCameraState(info: CameraInfoSerialized): CameraState {
|
||||
return {
|
||||
position: new THREE.Vector3(
|
||||
info.position.x,
|
||||
info.position.y,
|
||||
info.position.z
|
||||
),
|
||||
target: new THREE.Vector3(info.target.x, info.target.y, info.target.z),
|
||||
zoom: info.zoom,
|
||||
cameraType: info.cameraType
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,12 @@ export interface LoadModelOptions {
|
||||
* (e.g. shared workflows on a fresh machine).
|
||||
*/
|
||||
silentOnNotFound?: boolean
|
||||
/**
|
||||
* Bypass the "same URL already loaded" short-circuit. Use when the
|
||||
* file may have been overwritten on disk under the same name and the
|
||||
* caller actually wants a fresh fetch + setupForModel pass.
|
||||
*/
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
export interface LoaderManagerInterface {
|
||||
|
||||
357
src/extensions/core/load3d/renderBridge.test.ts
Normal file
357
src/extensions/core/load3d/renderBridge.test.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
addCustomEventListenerMock,
|
||||
fetchApiMock,
|
||||
getNodeByLocatorIdMock,
|
||||
nodeToLoad3dMap,
|
||||
configureForSaveMeshMock,
|
||||
uploadTempImageMock
|
||||
} = vi.hoisted(() => ({
|
||||
addCustomEventListenerMock: vi.fn(),
|
||||
fetchApiMock: vi.fn(),
|
||||
getNodeByLocatorIdMock: vi.fn(),
|
||||
nodeToLoad3dMap: new Map<unknown, unknown>(),
|
||||
configureForSaveMeshMock: vi.fn(),
|
||||
uploadTempImageMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addCustomEventListener: addCustomEventListenerMock,
|
||||
fetchApi: fetchApiMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: {} }
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
getNodeByLocatorId: getNodeByLocatorIdMock
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
nodeToLoad3dMap
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configureForSaveMesh = configureForSaveMeshMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
uploadTempImage: uploadTempImageMock
|
||||
}
|
||||
}))
|
||||
|
||||
type RenderRequestPayload = {
|
||||
render_id: string
|
||||
node_id: string
|
||||
file_path: string
|
||||
type: 'input' | 'output' | 'temp'
|
||||
width?: number
|
||||
height?: number
|
||||
camera_info?: {
|
||||
position: { x: number; y: number; z: number }
|
||||
target: { x: number; y: number; z: number }
|
||||
zoom: number
|
||||
cameraType: 'perspective' | 'orthographic'
|
||||
} | null
|
||||
}
|
||||
|
||||
async function loadRenderBridge() {
|
||||
vi.resetModules()
|
||||
addCustomEventListenerMock.mockClear()
|
||||
await import('@/extensions/core/load3d/renderBridge')
|
||||
}
|
||||
|
||||
function getRegisteredHandler(): (event: CustomEvent<unknown>) => void {
|
||||
const call = addCustomEventListenerMock.mock.calls[0]
|
||||
expect(call).toBeDefined()
|
||||
expect(call[0]).toBe('preview3d.render_request')
|
||||
return call[1] as (event: CustomEvent<unknown>) => void
|
||||
}
|
||||
|
||||
function makeLoad3d(
|
||||
overrides: Partial<{
|
||||
whenLoadIdle: ReturnType<typeof vi.fn>
|
||||
captureSceneFixedCamera: ReturnType<typeof vi.fn>
|
||||
handleResize: ReturnType<typeof vi.fn>
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
whenLoadIdle:
|
||||
overrides.whenLoadIdle ?? vi.fn().mockResolvedValue(undefined),
|
||||
captureSceneFixedCamera:
|
||||
overrides.captureSceneFixedCamera ??
|
||||
vi.fn().mockResolvedValue({
|
||||
scene: 'data:scene',
|
||||
mask: 'data:mask',
|
||||
normal: 'data:normal'
|
||||
}),
|
||||
handleResize: overrides.handleResize ?? vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function makePayload(
|
||||
overrides: Partial<RenderRequestPayload> = {}
|
||||
): RenderRequestPayload {
|
||||
return {
|
||||
render_id: 'rid-1',
|
||||
node_id: 'node-1',
|
||||
file_path: '3d/mesh.glb',
|
||||
type: 'output',
|
||||
width: 512,
|
||||
height: 512,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchAndFlush(
|
||||
handler: (event: CustomEvent<unknown>) => void,
|
||||
detail: RenderRequestPayload | null
|
||||
) {
|
||||
handler({ detail } as CustomEvent<unknown>)
|
||||
// Let the fire-and-forget handler microtasks settle.
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
}
|
||||
|
||||
describe('renderBridge', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
fetchApiMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('registers a "preview3d.render_request" listener at module load', async () => {
|
||||
await loadRenderBridge()
|
||||
expect(addCustomEventListenerMock).toHaveBeenCalledOnce()
|
||||
expect(addCustomEventListenerMock.mock.calls[0][0]).toBe(
|
||||
'preview3d.render_request'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores events without a render_id (does not POST a response)', async () => {
|
||||
await loadRenderBridge()
|
||||
const handler = getRegisteredHandler()
|
||||
|
||||
await dispatchAndFlush(handler, null)
|
||||
handler({ detail: { render_id: '' } } as CustomEvent<unknown>)
|
||||
|
||||
expect(fetchApiMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('POSTs an error when the node id cannot be resolved', async () => {
|
||||
await loadRenderBridge()
|
||||
getNodeByLocatorIdMock.mockReturnValue(null)
|
||||
const handler = getRegisteredHandler()
|
||||
|
||||
await dispatchAndFlush(handler, makePayload())
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
const body = JSON.parse(fetchApiMock.mock.calls[0][1].body as string)
|
||||
expect(body.render_id).toBe('rid-1')
|
||||
expect(body.error).toContain('node node-1 not found')
|
||||
})
|
||||
|
||||
it('POSTs an error when the node has no load3d instance', async () => {
|
||||
await loadRenderBridge()
|
||||
const node = { id: 1 }
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
// intentionally don't add to nodeToLoad3dMap
|
||||
const handler = getRegisteredHandler()
|
||||
|
||||
await dispatchAndFlush(handler, makePayload())
|
||||
|
||||
const body = JSON.parse(fetchApiMock.mock.calls[0][1].body as string)
|
||||
expect(body.error).toContain('load3d instance not available')
|
||||
})
|
||||
|
||||
it('captures, uploads, and POSTs image/mask paths on success', async () => {
|
||||
await loadRenderBridge()
|
||||
const node = { properties: {} }
|
||||
const load3d = makeLoad3d()
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
uploadTempImageMock
|
||||
.mockResolvedValueOnce({ name: 'scene_123.png' })
|
||||
.mockResolvedValueOnce({ name: 'mask_123.png' })
|
||||
|
||||
const handler = getRegisteredHandler()
|
||||
await dispatchAndFlush(handler, makePayload())
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/mesh.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
expect(load3d.whenLoadIdle).toHaveBeenCalled()
|
||||
expect(load3d.captureSceneFixedCamera).toHaveBeenCalledWith(
|
||||
512,
|
||||
512,
|
||||
undefined
|
||||
)
|
||||
expect(uploadTempImageMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'data:scene',
|
||||
'preview3d_scene'
|
||||
)
|
||||
expect(uploadTempImageMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'data:mask',
|
||||
'preview3d_mask'
|
||||
)
|
||||
expect(load3d.handleResize).toHaveBeenCalled()
|
||||
|
||||
const body = JSON.parse(fetchApiMock.mock.calls[0][1].body as string)
|
||||
expect(body).toEqual({
|
||||
render_id: 'rid-1',
|
||||
image: 'threed/scene_123.png [temp]',
|
||||
mask: 'threed/mask_123.png [temp]'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes upstream camera_info (priority 1) to captureSceneFixedCamera', async () => {
|
||||
await loadRenderBridge()
|
||||
const node = {
|
||||
properties: {
|
||||
// Should be ignored because payload.camera_info wins.
|
||||
'Camera Config': {
|
||||
state: {
|
||||
position: { x: 99, y: 99, z: 99 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 1,
|
||||
cameraType: 'perspective'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const load3d = makeLoad3d()
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
uploadTempImageMock.mockResolvedValue({ name: 'x.png' })
|
||||
|
||||
const handler = getRegisteredHandler()
|
||||
await dispatchAndFlush(
|
||||
handler,
|
||||
makePayload({
|
||||
camera_info: {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 4, y: 5, z: 6 },
|
||||
zoom: 2,
|
||||
cameraType: 'perspective'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const [w, h, cameraState] = load3d.captureSceneFixedCamera.mock.calls[0]
|
||||
expect(w).toBe(512)
|
||||
expect(h).toBe(512)
|
||||
expect(cameraState.position.toArray()).toEqual([1, 2, 3])
|
||||
expect(cameraState.target.toArray()).toEqual([4, 5, 6])
|
||||
expect(cameraState.zoom).toBe(2)
|
||||
})
|
||||
|
||||
it('falls back to node.properties Camera Config (priority 2) when no upstream camera_info', async () => {
|
||||
await loadRenderBridge()
|
||||
const node = {
|
||||
properties: {
|
||||
'Camera Config': {
|
||||
state: {
|
||||
position: { x: 7, y: 8, z: 9 },
|
||||
target: { x: 1, y: 1, z: 1 },
|
||||
zoom: 3,
|
||||
cameraType: 'perspective'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const load3d = makeLoad3d()
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
uploadTempImageMock.mockResolvedValue({ name: 'x.png' })
|
||||
|
||||
const handler = getRegisteredHandler()
|
||||
await dispatchAndFlush(handler, makePayload())
|
||||
|
||||
const cameraState = load3d.captureSceneFixedCamera.mock.calls[0][2]
|
||||
expect(cameraState).toBeDefined()
|
||||
expect(cameraState.position.toArray()).toEqual([7, 8, 9])
|
||||
expect(cameraState.zoom).toBe(3)
|
||||
})
|
||||
|
||||
it('passes undefined (priority 3) when neither input nor saved Camera Config exists', async () => {
|
||||
await loadRenderBridge()
|
||||
const node = { properties: {} }
|
||||
const load3d = makeLoad3d()
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
uploadTempImageMock.mockResolvedValue({ name: 'x.png' })
|
||||
|
||||
const handler = getRegisteredHandler()
|
||||
await dispatchAndFlush(handler, makePayload())
|
||||
|
||||
const cameraState = load3d.captureSceneFixedCamera.mock.calls[0][2]
|
||||
expect(cameraState).toBeUndefined()
|
||||
})
|
||||
|
||||
it('POSTs an error response when captureSceneFixedCamera throws', async () => {
|
||||
await loadRenderBridge()
|
||||
const node = { properties: {} }
|
||||
const load3d = makeLoad3d({
|
||||
captureSceneFixedCamera: vi.fn().mockRejectedValue(new Error('boom'))
|
||||
})
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
const handler = getRegisteredHandler()
|
||||
await dispatchAndFlush(handler, makePayload())
|
||||
|
||||
const body = JSON.parse(fetchApiMock.mock.calls[0][1].body as string)
|
||||
expect(body.render_id).toBe('rid-1')
|
||||
expect(body.error).toBe('boom')
|
||||
})
|
||||
|
||||
it('falls back to the frontend default resolution when payload omits width/height', async () => {
|
||||
await loadRenderBridge()
|
||||
const node = { properties: {} }
|
||||
const load3d = makeLoad3d()
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
uploadTempImageMock.mockResolvedValue({ name: 'x.png' })
|
||||
|
||||
const handler = getRegisteredHandler()
|
||||
const payload = makePayload()
|
||||
// Backend now omits width/height — frontend should default to 512.
|
||||
delete payload.width
|
||||
delete payload.height
|
||||
await dispatchAndFlush(handler, payload)
|
||||
|
||||
expect(load3d.captureSceneFixedCamera).toHaveBeenCalledWith(
|
||||
512,
|
||||
512,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('honors payload.type=input when constructing the load folder', async () => {
|
||||
await loadRenderBridge()
|
||||
const node = { properties: {} }
|
||||
const load3d = makeLoad3d()
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
uploadTempImageMock.mockResolvedValue({ name: 'x.png' })
|
||||
|
||||
const handler = getRegisteredHandler()
|
||||
await dispatchAndFlush(handler, makePayload({ type: 'input' }))
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'input',
|
||||
'3d/mesh.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
134
src/extensions/core/load3d/renderBridge.ts
Normal file
134
src/extensions/core/load3d/renderBridge.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Round-trip render bridge for Preview3D / SaveGLB IMAGE+MASK outputs.
|
||||
*
|
||||
* The backend node sends a "preview3d.render_request" websocket event with
|
||||
* {render_id, node_id, file_path, type, camera_info?}. We find the
|
||||
* matching node, load the model, capture at the (frontend-defaulted)
|
||||
* resolution with the derived camera state, upload the PNGs to /temp/,
|
||||
* then POST {render_id, image, mask} back to /3d/render_response so the
|
||||
* awaiting backend Future resolves.
|
||||
*/
|
||||
import { nodeToLoad3dMap } from '@/composables/useLoad3d'
|
||||
import {
|
||||
type CameraInfoSerialized,
|
||||
toCameraState
|
||||
} from '@/extensions/core/load3d/cameraInfo'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import type { CameraState } from '@/extensions/core/load3d/interfaces'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface RenderRequestPayload {
|
||||
render_id: string
|
||||
node_id: string
|
||||
file_path: string
|
||||
type: 'input' | 'output' | 'temp'
|
||||
width?: number
|
||||
height?: number
|
||||
camera_info?: CameraInfoSerialized | null
|
||||
}
|
||||
|
||||
const DEFAULT_RENDER_WIDTH = 512
|
||||
const DEFAULT_RENDER_HEIGHT = 512
|
||||
|
||||
async function postResponse(
|
||||
render_id: string,
|
||||
body: { image?: string; mask?: string; error?: string }
|
||||
) {
|
||||
try {
|
||||
await api.fetchApi('/3d/render_response', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ render_id, ...body })
|
||||
})
|
||||
} catch (error) {
|
||||
// POST failure leaves the backend Future to time out on its own.
|
||||
// Log for observability and debugging.
|
||||
console.error(
|
||||
`[Preview3D bridge] failed to POST render response for render_id=${render_id}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenderRequest(payload: RenderRequestPayload) {
|
||||
const { render_id, node_id, file_path, type } = payload
|
||||
const width = payload.width ?? DEFAULT_RENDER_WIDTH
|
||||
const height = payload.height ?? DEFAULT_RENDER_HEIGHT
|
||||
|
||||
const node = getNodeByLocatorId(app.rootGraph, node_id)
|
||||
if (!node) {
|
||||
await postResponse(render_id, { error: `node ${node_id} not found` })
|
||||
return
|
||||
}
|
||||
|
||||
const load3d = nodeToLoad3dMap.get(node)
|
||||
if (!load3d) {
|
||||
// Realistic only if the node was deleted between queue and execute.
|
||||
await postResponse(render_id, {
|
||||
error: `load3d instance not available for node ${node_id}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const folder: 'input' | 'output' = type === 'input' ? 'input' : 'output'
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(folder, file_path, { silentOnNotFound: true })
|
||||
|
||||
await load3d.whenLoadIdle()
|
||||
|
||||
// Camera priority:
|
||||
// 1. payload.camera_info (upstream camera_info node input) — overwrites
|
||||
// everything, including user's manual adjustments
|
||||
// 2. node.properties['Camera Config'].state — the user's persisted manual
|
||||
// adjustments (tracked by useLoad3d's cameraChanged handler)
|
||||
// 3. undefined — leave the viewer at its current state (which after the
|
||||
// load is the model's default fit-to-bbox)
|
||||
let cameraState: CameraState | undefined
|
||||
if (payload.camera_info) {
|
||||
cameraState = toCameraState(payload.camera_info)
|
||||
} else {
|
||||
const savedConfig = node.properties['Camera Config'] as
|
||||
| { state?: CameraInfoSerialized }
|
||||
| undefined
|
||||
if (savedConfig?.state) {
|
||||
cameraState = toCameraState(savedConfig.state)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await load3d.captureSceneFixedCamera(
|
||||
width,
|
||||
height,
|
||||
cameraState
|
||||
)
|
||||
|
||||
const [imageUpload, maskUpload] = await Promise.all([
|
||||
Load3dUtils.uploadTempImage(result.scene, 'preview3d_scene'),
|
||||
Load3dUtils.uploadTempImage(result.mask, 'preview3d_mask')
|
||||
])
|
||||
|
||||
load3d.handleResize()
|
||||
|
||||
await postResponse(render_id, {
|
||||
image: `threed/${imageUpload.name} [temp]`,
|
||||
mask: `threed/${maskUpload.name} [temp]`
|
||||
})
|
||||
} catch (error) {
|
||||
await postResponse(render_id, {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
api.addCustomEventListener(
|
||||
'preview3d.render_request',
|
||||
(event: CustomEvent<unknown>) => {
|
||||
const detail = event.detail as RenderRequestPayload | null
|
||||
if (!detail || !detail.render_id) return
|
||||
void handleRenderRequest(detail)
|
||||
}
|
||||
)
|
||||
@@ -340,4 +340,119 @@ describe('Comfy.SaveGLB.onNodeOutputsUpdated', () => {
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-applies camera_info when present even if the same model is already loaded', async () => {
|
||||
const setCameraStateMock = vi.fn()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: unknown) => void) => {
|
||||
cb({
|
||||
whenLoadIdle: () => Promise.resolve(),
|
||||
captureThumbnail: vi.fn(),
|
||||
setCameraState: setCameraStateMock
|
||||
})
|
||||
})
|
||||
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode({
|
||||
properties: {
|
||||
'Last Time Model File': 'sub/mesh.glb',
|
||||
'Last Time Model Folder': 'output'
|
||||
}
|
||||
})
|
||||
;(
|
||||
node.widgets!.find((w) => w.name === 'image') as { value: string }
|
||||
).value = 'sub/mesh.glb'
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
ext.onNodeOutputsUpdated!({
|
||||
'7': {
|
||||
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }],
|
||||
camera_info: [
|
||||
{
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 1,
|
||||
cameraType: 'perspective'
|
||||
}
|
||||
]
|
||||
}
|
||||
} as never)
|
||||
|
||||
// The early-return short-circuit must NOT trigger when cameraInfo is
|
||||
// present — we need a full path through to setCameraState.
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledOnce()
|
||||
// setCameraState lives in a whenLoadIdle().then() chain — let it settle.
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(setCameraStateMock).toHaveBeenCalledOnce()
|
||||
const state = setCameraStateMock.mock.calls[0][0]
|
||||
expect(state.position.toArray()).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.SaveGLB.onExecuted with camera_info', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('applies output.camera_info via setCameraState after the load idles', async () => {
|
||||
const setCameraStateMock = vi.fn()
|
||||
const fakeLoad3d = {
|
||||
whenLoadIdle: () => Promise.resolve(),
|
||||
captureThumbnail: vi.fn(),
|
||||
setCameraState: setCameraStateMock
|
||||
}
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: unknown) => void) => {
|
||||
cb(fakeLoad3d)
|
||||
})
|
||||
onLoad3dReadyMock.mockImplementation((cb: (load3d: unknown) => void) => {
|
||||
cb(fakeLoad3d)
|
||||
})
|
||||
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode()
|
||||
await ext.nodeCreated(node)
|
||||
|
||||
node.onExecuted!({
|
||||
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }],
|
||||
camera_info: [
|
||||
{
|
||||
position: { x: 9, y: 8, z: 7 },
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
zoom: 2,
|
||||
cameraType: 'perspective'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(setCameraStateMock).toHaveBeenCalledOnce()
|
||||
const state = setCameraStateMock.mock.calls[0][0]
|
||||
expect(state.position.toArray()).toEqual([9, 8, 7])
|
||||
expect(state.zoom).toBe(2)
|
||||
})
|
||||
|
||||
it('does NOT call setCameraState when output has no camera_info', async () => {
|
||||
const setCameraStateMock = vi.fn()
|
||||
const fakeLoad3d = {
|
||||
whenLoadIdle: () => Promise.resolve(),
|
||||
captureThumbnail: vi.fn(),
|
||||
setCameraState: setCameraStateMock
|
||||
}
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: unknown) => void) => {
|
||||
cb(fakeLoad3d)
|
||||
})
|
||||
onLoad3dReadyMock.mockImplementation((cb: (load3d: unknown) => void) => {
|
||||
cb(fakeLoad3d)
|
||||
})
|
||||
|
||||
const ext = await loadSaveMeshExtensionFresh()
|
||||
const node = makeNode()
|
||||
await ext.nodeCreated(node)
|
||||
|
||||
node.onExecuted!({
|
||||
'3d': [{ filename: 'mesh.glb', subfolder: 'sub', type: 'output' }]
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(setCameraStateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,10 @@ import { nextTick } from 'vue'
|
||||
|
||||
import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import {
|
||||
type CameraInfoSerialized,
|
||||
toCameraState
|
||||
} from '@/extensions/core/load3d/cameraInfo'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
@@ -15,6 +19,7 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
type SaveMeshOutput = NodeOutputWith<{
|
||||
'3d'?: ResultItem[]
|
||||
camera_info?: [CameraInfoSerialized]
|
||||
}>
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
@@ -34,7 +39,11 @@ const inputSpec: CustomInputSpec = {
|
||||
isPreview: true
|
||||
}
|
||||
|
||||
function applySaveGLBOutput(node: LGraphNode, fileInfo: ResultItem): void {
|
||||
function applySaveGLBOutput(
|
||||
node: LGraphNode,
|
||||
fileInfo: ResultItem,
|
||||
cameraInfo?: CameraInfoSerialized
|
||||
): void {
|
||||
const filePath = (fileInfo.subfolder ?? '') + '/' + (fileInfo.filename ?? '')
|
||||
const loadFolder = fileInfo.type as 'input' | 'output'
|
||||
|
||||
@@ -44,7 +53,8 @@ function applySaveGLBOutput(node: LGraphNode, fileInfo: ResultItem): void {
|
||||
if (
|
||||
modelWidget.value === filePath &&
|
||||
node.properties['Last Time Model File'] === filePath &&
|
||||
node.properties['Last Time Model Folder'] === loadFolder
|
||||
node.properties['Last Time Model Folder'] === loadFolder &&
|
||||
!cameraInfo
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -60,6 +70,13 @@ function applySaveGLBOutput(node: LGraphNode, fileInfo: ResultItem): void {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
if (cameraInfo) {
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => load3d.setCameraState(toCameraState(cameraInfo)))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
if (isAssetPreviewSupported()) {
|
||||
const filename = fileInfo.filename ?? ''
|
||||
void load3d
|
||||
@@ -95,7 +112,8 @@ useExtensionService().registerExtension({
|
||||
const node = getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
if (!node || node.constructor.comfyClass !== 'SaveGLB') continue
|
||||
|
||||
applySaveGLBOutput(node, fileInfo)
|
||||
const cameraInfo = (output as SaveMeshOutput).camera_info?.[0]
|
||||
applySaveGLBOutput(node, fileInfo, cameraInfo)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -183,17 +201,24 @@ useExtensionService().registerExtension({
|
||||
|
||||
modelWidget.value = filePath
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
|
||||
const loadFolder = fileInfo.type as 'input' | 'output'
|
||||
|
||||
node.properties['Last Time Model File'] = filePath
|
||||
node.properties['Last Time Model Folder'] = loadFolder
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, filePath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraInfo = output.camera_info?.[0]
|
||||
if (cameraInfo) {
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => load3d.setCameraState(toCameraState(cameraInfo)))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
if (isAssetPreviewSupported()) {
|
||||
const filename = fileInfo.filename ?? ''
|
||||
|
||||
|
||||
Reference in New Issue
Block a user