Compare commits

...

1 Commits

Author SHA1 Message Date
Terry Jia
9b649d99f3 CORE-329 feat: wire up Save 3D (Advanced) node family 2026-07-01 08:24:44 -04:00
8 changed files with 288 additions and 154 deletions

View File

@@ -143,6 +143,7 @@ async function loadExtensionsFresh(): Promise<{
load3DExt: ExtCreated
preview3DExt: ExtCreated
preview3DAdvancedExt: ExtCreated
save3DAdvancedExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
@@ -150,7 +151,8 @@ async function loadExtensionsFresh(): Promise<{
return {
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated,
save3DAdvancedExt: registerExtensionMock.mock.calls[3][0] as ExtCreated
}
}
@@ -264,14 +266,15 @@ function setupBaseMocks() {
describe('load3d module registration', () => {
beforeEach(setupBaseMocks)
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
it('registers Comfy.Load3D, Comfy.Preview3D, Comfy.Preview3DAdvanced, and Comfy.Save3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt, save3DAdvancedExt } =
await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
expect(load3DExt.name).toBe('Comfy.Load3D')
expect(preview3DExt.name).toBe('Comfy.Preview3D')
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
expect(save3DAdvancedExt.name).toBe('Comfy.Save3DAdvanced')
})
})
@@ -1032,6 +1035,50 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
})
})
describe('Comfy.Save3DAdvanced.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
await save3DAdvancedExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('restores persisted models from the output folder, not temp', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({
comfyClass: 'Save3DAdvanced',
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.glb' }
})
await save3DAdvancedExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00001_.glb',
{ silentOnNotFound: true }
)
})
it('onExecuted loads the saved file from the output folder', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
await save3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['3d/ComfyUI_00002_.glb'] })
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00002_.glb',
{ silentOnNotFound: true }
)
})
})
describe('Comfy.Load3D scene widget serializeValue caching', () => {
beforeEach(setupBaseMocks)

View File

@@ -48,6 +48,7 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComfyExtension } from '@/types/comfy'
import { isLoad3dNode } from '@/utils/litegraphUtil'
const inputSpecLoad3D: CustomInputSpec = {
@@ -287,8 +288,11 @@ useExtensionService().registerExtension({
getCustomWidgets() {
const VIEWPORT_STATE_NODES = new Set([
'Preview3DAdvanced',
'Save3DAdvanced',
'PreviewGaussianSplat',
'PreviewPointCloud'
'PreviewPointCloud',
'SaveGaussianSplat',
'SavePointCloud'
])
return {
LOAD_3D(node) {
@@ -679,155 +683,178 @@ useExtensionService().registerExtension({
}
})
useExtensionService().registerExtension({
name: 'Comfy.Preview3DAdvanced',
function createPreview3DAdvancedExtension(
comfyClass: string,
extensionName: string,
loadFolder: 'temp' | 'output'
): ComfyExtension {
return {
name: extensionName,
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== comfyClass) return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},
return createExportMenuItems(load3d)
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== comfyClass) return
const [oldWidth, oldHeight] = node.size
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
await nextTick()
await nextTick()
const onExecuted = node.onExecuted
const onExecuted = node.onExecuted
useLoad3d(node).onLoad3dReady((load3d) => {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!lastTimeModelFile) return
useLoad3d(node).onLoad3dReady((load3d) => {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
if (!cameraState) return
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
load3d.setCameraState(cameraState)
load3d.forceRender()
})
.catch((error) => {
console.error(
'Failed to restore camera state for Preview3DAdvanced:',
error
)
})
})
useLoad3d(node).waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
load3d.setTargetSize(
widthWidget.value as number,
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
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
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
node.onExecuted = function (output: Preview3DAdvancedOutput) {
onExecuted?.call(this, output)
const result = output.result
const filePath = result?.[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
const currentLoad3d = resolveLoad3d()
const config = new Load3DConfiguration(currentLoad3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraState = result?.[1]
const modelTransform = result?.[2]?.[0]
if (cameraState || modelTransform) {
const targetGeneration = currentLoad3d.currentLoadGeneration
void currentLoad3d
.whenLoadIdle()
.then(() => {
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
return
if (cameraState) currentLoad3d.setCameraState(cameraState)
if (modelTransform)
currentLoad3d.applyModelTransform(modelTransform)
})
.catch((error) => {
console.error(
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
error
)
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
if (!cameraState) return
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
load3d.setCameraState(cameraState)
load3d.forceRender()
})
.catch((error) => {
console.error(
`Failed to restore camera state for ${comfyClass}:`,
error
)
})
})
useLoad3d(node).waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find(
(w) => w.name === 'viewport_state'
)
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
load3d.setTargetSize(
widthWidget.value as number,
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
}
})
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
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
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
node.onExecuted = function (output: Preview3DAdvancedOutput) {
onExecuted?.call(this, output)
const result = output.result
const filePath = result?.[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
const currentLoad3d = resolveLoad3d()
const config = new Load3DConfiguration(currentLoad3d, node.properties)
config.configureForSaveMesh(loadFolder, normalizedPath, {
silentOnNotFound: true
})
const cameraState = result?.[1]
const modelTransform = result?.[2]?.[0]
if (cameraState || modelTransform) {
const targetGeneration = currentLoad3d.currentLoadGeneration
void currentLoad3d
.whenLoadIdle()
.then(() => {
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
return
if (cameraState) currentLoad3d.setCameraState(cameraState)
if (modelTransform)
currentLoad3d.applyModelTransform(modelTransform)
})
.catch((error) => {
console.error(
`Failed to apply input camera_info / model_3d_info from ${comfyClass}:`,
error
)
})
}
}
})
}
}
})
}
useExtensionService().registerExtension(
createPreview3DAdvancedExtension(
'Preview3DAdvanced',
'Comfy.Preview3DAdvanced',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DAdvancedExtension(
'Save3DAdvanced',
'Comfy.Save3DAdvanced',
'output'
)
)

View File

@@ -13,7 +13,10 @@ const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud'
])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>

View File

@@ -90,7 +90,10 @@ describe('load3dLazy', () => {
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud',
'SaveGLB'
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud'
])(
'recognizes %s as a 3D node type and triggers the lazy-load path',
async (nodeType) => {

View File

@@ -76,14 +76,19 @@ type ExtCreated = ComfyExtension & {
async function loadExtensionsFresh(): Promise<{
splatExt: ExtCreated
pointCloudExt: ExtCreated
saveSplatExt: ExtCreated
savePointCloudExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3dPreviewExtensions')
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
const [splatCall, pointCloudCall, saveSplatCall, savePointCloudCall] =
registerExtensionMock.mock.calls
return {
splatExt: splatCall[0] as ExtCreated,
pointCloudExt: pointCloudCall[0] as ExtCreated
pointCloudExt: pointCloudCall[0] as ExtCreated,
saveSplatExt: saveSplatCall[0] as ExtCreated,
savePointCloudExt: savePointCloudCall[0] as ExtCreated
}
}
@@ -151,12 +156,43 @@ function setupBaseMocks() {
describe('load3dPreviewExtensions module registration', () => {
beforeEach(setupBaseMocks)
it('registers both preview extensions on import', async () => {
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
it('registers preview and save extensions on import', async () => {
const { splatExt, pointCloudExt, saveSplatExt, savePointCloudExt } =
await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
expect(splatExt.name).toBe('Comfy.PreviewGaussianSplat')
expect(pointCloudExt.name).toBe('Comfy.PreviewPointCloud')
expect(saveSplatExt.name).toBe('Comfy.SaveGaussianSplat')
expect(savePointCloudExt.name).toBe('Comfy.SavePointCloud')
})
it('save extensions load the saved file from the output folder, not temp', async () => {
const { saveSplatExt, savePointCloudExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const splatNode = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
await saveSplatExt.nodeCreated(splatNode)
splatNode.onExecuted!({ result: ['3d/ComfyUI_00001_.ply'] })
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'3d/ComfyUI_00001_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
const pcNode = makePreviewNode({ comfyClass: 'SavePointCloud' })
await savePointCloudExt.nodeCreated(pcNode)
pcNode.onExecuted!({ result: ['3d/ComfyUI_00002_.ply'] })
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'3d/ComfyUI_00002_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
})

View File

@@ -29,7 +29,8 @@ function applyResultToLoad3d(
node: LGraphNode,
load3d: Load3d,
filePath: string,
cameraState: CameraState | undefined
cameraState: CameraState | undefined,
loadFolder: 'temp' | 'output'
): void {
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
@@ -46,7 +47,7 @@ function applyResultToLoad3d(
}
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
config.configureForSaveMesh(loadFolder, normalizedPath, {
silentOnNotFound: true
})
@@ -60,7 +61,8 @@ function applyResultToLoad3d(
function createPreview3DExtension(
comfyClass: string,
extensionName: string
extensionName: string,
loadFolder: 'temp' | 'output' = 'temp'
): ComfyExtension {
const applyPreviewOutput = (
node: LGraphNode,
@@ -71,7 +73,7 @@ function createPreview3DExtension(
if (!filePath) return
useLoad3d(node).waitForLoad3d((load3d) => {
applyResultToLoad3d(node, load3d, filePath, cameraState)
applyResultToLoad3d(node, load3d, filePath, cameraState, loadFolder)
})
}
@@ -119,7 +121,7 @@ function createPreview3DExtension(
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
silentOnNotFound: true
})
@@ -199,7 +201,7 @@ function createPreview3DExtension(
return
}
applyResultToLoad3d(node, load3d, filePath, result?.[1])
applyResultToLoad3d(node, load3d, filePath, result?.[1], loadFolder)
}
})
}
@@ -212,3 +214,13 @@ useExtensionService().registerExtension(
useExtensionService().registerExtension(
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
)
useExtensionService().registerExtension(
createPreview3DExtension(
'SaveGaussianSplat',
'Comfy.SaveGaussianSplat',
'output'
)
)
useExtensionService().registerExtension(
createPreview3DExtension('SavePointCloud', 'Comfy.SavePointCloud', 'output')
)

View File

@@ -81,6 +81,9 @@ describe('Comfy.SaveImageExtraOutput', () => {
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',

View File

@@ -16,6 +16,9 @@ const saveNodeTypes = new Set([
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',