import type { MaybeRef } from 'vue' import { toRef, useDebounceFn } from '@vueuse/core' import { getActivePinia } from 'pinia' import { ref, toRaw, watch } from 'vue' import { useChainCallback } from '@/composables/functional/useChainCallback' import type Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import { createLoad3d } from '@/extensions/core/load3d/createLoad3d' import { persistThumbnail } from '@/platform/assets/utils/assetPreviewUtil' import type { AnimationItem, CameraConfig, CameraState, CameraType, EventCallback, GizmoConfig, GizmoMode, LightConfig, MaterialMode, ModelConfig, SceneConfig, UpDirection } from '@/extensions/core/load3d/interfaces' import { t } from '@/i18n' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { useSettingStore } from '@/platform/settings/settingStore' import { useToastStore } from '@/platform/updates/common/toastStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { api } from '@/scripts/api' import { app } from '@/scripts/app' import { useLoad3dService } from '@/services/load3dService' type Load3dReadyCallback = (load3d: Load3d) => void export const nodeToLoad3dMap = new Map() const pendingCallbacks = new Map() const persistentReadyCallbacks = new Map() const nodesWithCleanup = new WeakSet() const ensureNodeCleanupChained = (node: LGraphNode): void => { if (nodesWithCleanup.has(node)) return nodesWithCleanup.add(node) node.onRemoved = useChainCallback(node.onRemoved, () => { useLoad3dService().removeLoad3d(node) pendingCallbacks.delete(node) persistentReadyCallbacks.delete(node) }) } const invokeReadyCallback = ( callback: Load3dReadyCallback, instance: Load3d ): void => { try { callback(instance) } catch (error) { console.error('Load3d ready callback failed:', error) } } export const useLoad3d = (nodeOrRef: MaybeRef) => { const nodeRef = toRef(nodeOrRef) let load3d: Load3d | null = null let isFirstModelLoad = true const debouncedHandleResize = useDebounceFn(() => { load3d?.handleResize() }, 150) watch( () => (getActivePinia() ? useCanvasStore().appScalePercentage : 0), debouncedHandleResize ) const sceneConfig = ref({ showGrid: true, backgroundColor: '#000000', backgroundImage: '', backgroundRenderMode: 'tiled' }) const modelConfig = ref({ upDirection: 'original', materialMode: 'original', showSkeleton: false, gizmo: { enabled: false, mode: 'translate', position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } } }) const hasSkeleton = ref(false) const cameraConfig = ref({ cameraType: 'perspective', fov: 75 }) const lightConfig = ref({ intensity: 5, hdri: { enabled: false, hdriPath: '', showAsBackground: false, intensity: 1 } }) const lastNonHdriLightIntensity = ref(lightConfig.value.intensity) const isRecording = ref(false) const hasRecording = ref(false) const recordingDuration = ref(0) const animations = ref([]) const playing = ref(false) const selectedSpeed = ref(1) const selectedAnimation = ref(0) const animationProgress = ref(0) const animationDuration = ref(0) const loading = ref(false) const loadingMessage = ref('') const isPreview = ref(false) const isSplatModel = ref(false) const isPlyModel = ref(false) const canFitToViewer = ref(true) const canUseGizmo = ref(true) const canUseLighting = ref(true) const canExport = ref(true) const materialModes = ref([ 'original', 'normal', 'wireframe' ]) const initializeLoad3d = async (containerRef: HTMLElement) => { const rawNode = toRaw(nodeRef.value) if (!containerRef || !rawNode) return const node = rawNode as LGraphNode try { const widthWidget = node.widgets?.find((w) => w.name === 'width') const heightWidget = node.widgets?.find((w) => w.name === 'height') if (!(widthWidget && heightWidget)) { isPreview.value = true } load3d = createLoad3d(containerRef, { width: widthWidget?.value as number | undefined, height: heightWidget?.value as number | undefined, // Provide dynamic dimension getter for reactive updates getDimensions: widthWidget && heightWidget ? () => ({ width: widthWidget.value as number, height: heightWidget.value as number }) : undefined, getZoomScale: () => app.canvas?.ds?.scale ?? 1, onContextMenu: (event) => { const menuOptions = app.canvas.getNodeMenuOptions(node) new LiteGraph.ContextMenu(menuOptions, { event, title: node.type, extra: node }) } }) await restoreConfigurationsFromNode(node) node.onMouseEnter = useChainCallback(node.onMouseEnter, () => { load3d?.refreshViewport() load3d?.updateStatusMouseOnNode(true) }) node.onMouseLeave = useChainCallback(node.onMouseLeave, () => { load3d?.updateStatusMouseOnNode(false) }) node.onResize = useChainCallback(node.onResize, () => { load3d?.handleResize() }) node.onDrawBackground = useChainCallback( node.onDrawBackground, function (this: LGraphNode) { if (load3d) { load3d.renderer.domElement.hidden = this.flags.collapsed ?? false } } ) ensureNodeCleanupChained(node) nodeToLoad3dMap.set(node, load3d) const callbacks = pendingCallbacks.get(node) if (callbacks && load3d) { callbacks.forEach((callback) => { if (load3d) invokeReadyCallback(callback, load3d) }) pendingCallbacks.delete(node) } const persistent = persistentReadyCallbacks.get(node) if (persistent && load3d) { persistent.forEach((callback) => { if (load3d) invokeReadyCallback(callback, load3d) }) } handleEvents('add') } catch (error) { console.error('Error initializing Load3d:', error) useToastStore().addAlert( t('toastMessages.failedToInitializeLoad3dViewer') ) } } const restoreConfigurationsFromNode = async (node: LGraphNode) => { if (!load3d) return // Restore configs - watchers will handle applying them to the Three.js scene const savedSceneConfig = node.properties['Scene Config'] as SceneConfig if (savedSceneConfig) { sceneConfig.value = { ...sceneConfig.value, ...savedSceneConfig, backgroundRenderMode: savedSceneConfig.backgroundRenderMode || 'tiled' } } const savedModelConfig = node.properties['Model Config'] as ModelConfig if (savedModelConfig) { modelConfig.value = { ...savedModelConfig, gizmo: savedModelConfig.gizmo ? { ...savedModelConfig.gizmo, scale: savedModelConfig.gizmo.scale ?? { x: 1, y: 1, z: 1 } } : { enabled: false, mode: 'translate', position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } } } } const savedCameraConfig = node.properties['Camera Config'] as CameraConfig if (savedCameraConfig) { cameraConfig.value = savedCameraConfig } const savedLightConfig = node.properties['Light Config'] as LightConfig const savedHdriEnabled = savedLightConfig?.hdri?.enabled ?? false if (savedLightConfig) { lightConfig.value = { intensity: savedLightConfig.intensity ?? lightConfig.value.intensity, hdri: { ...lightConfig.value.hdri!, ...savedLightConfig.hdri, enabled: false } } lastNonHdriLightIntensity.value = lightConfig.value.intensity } const hdri = lightConfig.value.hdri let hdriLoaded = false if (hdri?.hdriPath) { const hdriUrl = api.apiURL( Load3dUtils.getResourceURL( ...Load3dUtils.splitFilePath(hdri.hdriPath), 'input' ) ) try { await load3d.loadHDRI(hdriUrl) hdriLoaded = true } catch (error) { console.warn('Failed to restore HDRI:', error) lightConfig.value = { ...lightConfig.value, hdri: { ...lightConfig.value.hdri!, hdriPath: '', enabled: false } } } } if (hdriLoaded && savedHdriEnabled) { lightConfig.value = { ...lightConfig.value, hdri: { ...lightConfig.value.hdri!, enabled: true } } } applySceneConfigToLoad3d() applyLightConfigToLoad3d() } const applySceneConfigToLoad3d = () => { if (!load3d) return const cfg = sceneConfig.value load3d.toggleGrid(cfg.showGrid) if (!lightConfig.value.hdri?.enabled) { load3d.setBackgroundColor(cfg.backgroundColor) } if (cfg.backgroundRenderMode) { load3d.setBackgroundRenderMode(cfg.backgroundRenderMode) } } const applyGizmoConfigToLoad3d = () => { if (!load3d) return const gizmo = modelConfig.value.gizmo if (!gizmo) return const hasTransform = gizmo.position.x !== 0 || gizmo.position.y !== 0 || gizmo.position.z !== 0 || gizmo.rotation.x !== 0 || gizmo.rotation.y !== 0 || gizmo.rotation.z !== 0 || gizmo.scale.x !== 1 || gizmo.scale.y !== 1 || gizmo.scale.z !== 1 if (hasTransform) { load3d.applyGizmoTransform(gizmo.position, gizmo.rotation, gizmo.scale) } if (gizmo.enabled) { load3d.setGizmoEnabled(true) } if (gizmo.mode !== 'translate') { load3d.setGizmoMode(gizmo.mode) } } const applyLightConfigToLoad3d = () => { if (!load3d) return const cfg = lightConfig.value load3d.setLightIntensity(cfg.intensity) const hdri = cfg.hdri if (!hdri) return load3d.setHDRIIntensity(hdri.intensity) load3d.setHDRIAsBackground(hdri.showAsBackground) load3d.setHDRIEnabled(hdri.enabled) } const persistLightConfigToNode = () => { const n = nodeRef.value if (n) { n.properties['Light Config'] = lightConfig.value } } const waitForLoad3d = (callback: Load3dReadyCallback) => { const rawNode = toRaw(nodeRef.value) if (!rawNode) return const node = rawNode as LGraphNode const existingInstance = nodeToLoad3dMap.get(node) if (existingInstance) { invokeReadyCallback(callback, existingInstance) return } if (!pendingCallbacks.has(node)) { pendingCallbacks.set(node, []) } pendingCallbacks.get(node)!.push(callback) ensureNodeCleanupChained(node) } const onLoad3dReady = (callback: Load3dReadyCallback) => { const rawNode = toRaw(nodeRef.value) if (!rawNode) return const node = rawNode as LGraphNode if (!persistentReadyCallbacks.has(node)) { persistentReadyCallbacks.set(node, []) } persistentReadyCallbacks.get(node)!.push(callback) ensureNodeCleanupChained(node) const existingInstance = nodeToLoad3dMap.get(node) if (existingInstance) invokeReadyCallback(callback, existingInstance) } watch( sceneConfig, (newValue) => { if (nodeRef.value) { nodeRef.value.properties['Scene Config'] = newValue } }, { deep: true } ) watch( () => sceneConfig.value.showGrid, (showGrid) => { load3d?.toggleGrid(showGrid) } ) watch( () => sceneConfig.value.backgroundColor, (color) => { if (!load3d || lightConfig.value.hdri?.enabled) return load3d.setBackgroundColor(color) } ) watch( () => sceneConfig.value.backgroundImage, async (image) => { if (!load3d) return await load3d.setBackgroundImage(image || '') } ) watch( () => sceneConfig.value.backgroundRenderMode, (mode) => { if (mode) load3d?.setBackgroundRenderMode(mode) } ) watch( modelConfig, (newValue) => { if (nodeRef.value) { nodeRef.value.properties['Model Config'] = newValue } }, { deep: true } ) watch( () => modelConfig.value.upDirection, (newValue) => { if (load3d) load3d.setUpDirection(newValue) } ) watch( () => modelConfig.value.materialMode, (newValue) => { if (load3d) load3d.setMaterialMode(newValue) } ) watch( () => modelConfig.value.showSkeleton, (newValue) => { if (load3d) load3d.setShowSkeleton(newValue) } ) watch( cameraConfig, (newValue) => { if (load3d && nodeRef.value) { nodeRef.value.properties['Camera Config'] = newValue load3d.toggleCamera(newValue.cameraType) load3d.setFOV(newValue.fov) } }, { deep: true } ) watch( () => lightConfig.value.intensity, (intensity) => { if (!load3d || !nodeRef.value) return if (!lightConfig.value.hdri?.enabled) { lastNonHdriLightIntensity.value = intensity } persistLightConfigToNode() load3d.setLightIntensity(intensity) } ) watch( () => lightConfig.value.hdri?.intensity, (intensity) => { if (!load3d || !nodeRef.value) return if (intensity === undefined) return persistLightConfigToNode() load3d.setHDRIIntensity(intensity) } ) watch( () => lightConfig.value.hdri?.showAsBackground, (show) => { if (!load3d || !nodeRef.value) return if (show === undefined) return persistLightConfigToNode() load3d.setHDRIAsBackground(show) } ) watch( () => lightConfig.value.hdri?.enabled, (enabled, prevEnabled) => { if (!load3d || !nodeRef.value) return if (enabled === undefined) return if (enabled && prevEnabled === false) { lastNonHdriLightIntensity.value = lightConfig.value.intensity } if (!enabled && prevEnabled === true) { lightConfig.value = { ...lightConfig.value, intensity: lastNonHdriLightIntensity.value } } persistLightConfigToNode() load3d.setHDRIEnabled(enabled) } ) watch(playing, (newValue) => { if (load3d) { load3d.toggleAnimation(newValue) } }) watch(selectedSpeed, (newValue) => { if (load3d && newValue) { load3d.setAnimationSpeed(newValue) } }) watch(selectedAnimation, (newValue) => { if (load3d && newValue !== undefined) { load3d.updateSelectedAnimation(newValue) } }) const handleMouseEnter = () => { load3d?.updateStatusMouseOnScene(true) } const handleMouseLeave = () => { load3d?.updateStatusMouseOnScene(false) } const handleStartRecording = async () => { if (load3d) { await load3d.startRecording() isRecording.value = true } } const handleStopRecording = () => { if (load3d) { load3d.stopRecording() isRecording.value = false recordingDuration.value = load3d.getRecordingDuration() hasRecording.value = recordingDuration.value > 0 } } const handleExportRecording = () => { if (load3d) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const filename = `${timestamp}-scene-recording.mp4` load3d.exportRecording(filename) } } const handleClearRecording = () => { if (load3d) { load3d.clearRecording() hasRecording.value = false recordingDuration.value = 0 } } const handleSeek = (progress: number) => { if (load3d && animationDuration.value > 0) { const time = (progress / 100) * animationDuration.value load3d.setAnimationTime(time) } } const handleHDRIFileUpdate = async (file: File | null) => { const capturedLoad3d = load3d if (!capturedLoad3d) return if (!file) { lightConfig.value = { ...lightConfig.value, hdri: { ...lightConfig.value.hdri!, hdriPath: '', enabled: false, showAsBackground: false } } capturedLoad3d.clearHDRI() return } const resourceFolder = (nodeRef.value?.properties['Resource Folder'] as string) || '' const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d' const uploadedPath = await Load3dUtils.uploadFile(file, subfolder) if (!uploadedPath) { return } // Re-validate: node may have been removed during upload if (load3d !== capturedLoad3d) return const hdriUrl = api.apiURL( Load3dUtils.getResourceURL( ...Load3dUtils.splitFilePath(uploadedPath), 'input' ) ) try { loading.value = true loadingMessage.value = t('load3d.loadingHDRI') await capturedLoad3d.loadHDRI(hdriUrl) if (load3d !== capturedLoad3d) return let sceneMin = 1 let sceneMax = 10 if (getActivePinia() != null) { const settingStore = useSettingStore() sceneMin = settingStore.get( 'Comfy.Load3D.LightIntensityMinimum' ) as number sceneMax = settingStore.get( 'Comfy.Load3D.LightIntensityMaximum' ) as number } const mappedHdriIntensity = Load3dUtils.mapSceneLightIntensityToHdri( lightConfig.value.intensity, sceneMin, sceneMax ) lightConfig.value = { ...lightConfig.value, hdri: { ...lightConfig.value.hdri!, hdriPath: uploadedPath, enabled: true, showAsBackground: true, intensity: mappedHdriIntensity } } } catch (error) { console.error('Failed to load HDRI:', error) capturedLoad3d.clearHDRI() lightConfig.value = { ...lightConfig.value, hdri: { ...lightConfig.value.hdri!, hdriPath: '', enabled: false, showAsBackground: false } } useToastStore().addAlert(t('toastMessages.failedToLoadHDRI')) } finally { loading.value = false loadingMessage.value = '' } } const handleBackgroundImageUpdate = async (file: File | null) => { if (!file) { sceneConfig.value.backgroundImage = '' await load3d?.setBackgroundImage('') return } const resourceFolder = (nodeRef.value?.properties['Resource Folder'] as string) || '' const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d' const uploadedPath = await Load3dUtils.uploadFile(file, subfolder) sceneConfig.value.backgroundImage = uploadedPath await load3d?.setBackgroundImage(uploadedPath) } const handleExportModel = async (format: string) => { if (!load3d) { useToastStore().addAlert(t('toastMessages.no3dSceneToExport')) return } try { await load3d.exportModel(format) } catch (error) { console.error('Error exporting model:', error) useToastStore().addAlert( t('toastMessages.failedToExportModel', { format: format.toUpperCase() }) ) } } const handleModelDrop = async (file: File) => { if (!load3d) { useToastStore().addAlert(t('toastMessages.no3dScene')) return } const node = toRaw(nodeRef.value) if (!node) return try { const resourceFolder = (node.properties['Resource Folder'] as string) || '' const subfolder = resourceFolder.trim() ? `3d/${resourceFolder.trim()}` : '3d' loading.value = true loadingMessage.value = t('load3d.uploadingModel') const uploadedPath = await Load3dUtils.uploadFile(file, subfolder) if (!uploadedPath) { useToastStore().addAlert(t('toastMessages.fileUploadFailed')) return } const modelUrl = api.apiURL( Load3dUtils.getResourceURL( ...Load3dUtils.splitFilePath(uploadedPath), isPreview.value ? 'output' : 'input' ) ) loadingMessage.value = t('load3d.loadingModel') await load3d.loadModel(modelUrl) const modelWidget = node.widgets?.find((w) => w.name === 'model_file') if (modelWidget) { const options = modelWidget.options as { values?: string[] } | undefined if (options?.values && !options.values.includes(uploadedPath)) { options.values.push(uploadedPath) } modelWidget.value = uploadedPath } } catch (error) { console.error('Model drop failed:', error) useToastStore().addAlert(t('toastMessages.failedToLoadModel')) } finally { loading.value = false loadingMessage.value = '' } } const eventConfig = { materialModeChange: (value: string) => { modelConfig.value.materialMode = value as MaterialMode }, backgroundColorChange: (value: string) => { sceneConfig.value.backgroundColor = value }, backgroundRenderModeChange: (value: string) => { sceneConfig.value.backgroundRenderMode = value as 'tiled' | 'panorama' }, lightIntensityChange: (value: number) => { lightConfig.value.intensity = value }, fovChange: (value: number) => { cameraConfig.value.fov = value }, cameraTypeChange: (value: string) => { cameraConfig.value.cameraType = value as CameraType }, showGridChange: (value: boolean) => { sceneConfig.value.showGrid = value }, upDirectionChange: (value: string) => { modelConfig.value.upDirection = value as UpDirection }, backgroundImageChange: (value: string) => { sceneConfig.value.backgroundImage = value }, backgroundImageLoadingStart: () => { loadingMessage.value = t('load3d.loadingBackgroundImage') loading.value = true }, backgroundImageLoadingEnd: () => { loadingMessage.value = '' loading.value = false }, modelLoadingStart: () => { loadingMessage.value = t('load3d.loadingModel') loading.value = true if (!isFirstModelLoad) { modelConfig.value = { upDirection: 'original', materialMode: 'original', showSkeleton: false, gizmo: { enabled: false, mode: 'translate', position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } } } } }, modelLoadingEnd: () => { loadingMessage.value = '' loading.value = false isSplatModel.value = load3d?.isSplatModel() ?? false isPlyModel.value = load3d?.isPlyModel() ?? false const caps = load3d?.getCurrentModelCapabilities() canFitToViewer.value = caps?.fitToViewer ?? true canUseGizmo.value = caps?.gizmoTransform ?? true canUseLighting.value = caps?.lighting ?? true canExport.value = caps?.exportable ?? true materialModes.value = caps?.materialModes ?? [ 'original', 'normal', 'wireframe' ] hasSkeleton.value = load3d?.hasSkeleton() ?? false applyGizmoConfigToLoad3d() isFirstModelLoad = false }, modelReady: () => { if (!load3d) return const node = nodeRef.value const modelWidget = node?.widgets?.find( (w) => w.name === 'model_file' || w.name === 'image' ) const value = modelWidget?.value if (typeof value !== 'string' || !value) return const filename = value.trim().replace(/\s*\[output\]$/, '') const modelName = Load3dUtils.splitFilePath(filename)[1] load3d .captureThumbnail(256, 256) .then((dataUrl) => fetch(dataUrl).then((r) => r.blob())) .then((blob) => persistThumbnail(modelName, blob)) .catch(() => {}) }, skeletonVisibilityChange: (value: boolean) => { modelConfig.value.showSkeleton = value }, exportLoadingStart: (message: string) => { loadingMessage.value = message || t('load3d.exportingModel') loading.value = true }, exportLoadingEnd: () => { loadingMessage.value = '' loading.value = false }, recordingStatusChange: (value: boolean) => { isRecording.value = value if (!value && load3d) { recordingDuration.value = load3d.getRecordingDuration() hasRecording.value = recordingDuration.value > 0 } }, animationListChange: (newValue: AnimationItem[]) => { animations.value = newValue }, animationProgressChange: (data: { progress: number currentTime: number duration: number }) => { animationProgress.value = data.progress animationDuration.value = data.duration }, cameraChanged: (cameraState: CameraState) => { const rawNode = toRaw(nodeRef.value) if (rawNode) { const node = rawNode as LGraphNode if (!node.properties) node.properties = {} const cameraConfigProp = node.properties['Camera Config'] if (cameraConfigProp) { ;(cameraConfigProp as CameraConfig).state = cameraState } else { node.properties['Camera Config'] = { cameraType: cameraConfig.value.cameraType, fov: cameraConfig.value.fov, state: cameraState } } } }, gizmoTransformChange: (data: GizmoConfig) => { if (modelConfig.value.gizmo && nodeRef.value) { modelConfig.value.gizmo.position = data.position modelConfig.value.gizmo.rotation = data.rotation modelConfig.value.gizmo.scale = data.scale modelConfig.value.gizmo.enabled = data.enabled modelConfig.value.gizmo.mode = data.mode } } } as const const handleToggleGizmo = (enabled: boolean) => { if (load3d && modelConfig.value.gizmo) { modelConfig.value.gizmo.enabled = enabled load3d.setGizmoEnabled(enabled) } } const handleSetGizmoMode = (mode: GizmoMode) => { if (load3d && modelConfig.value.gizmo) { modelConfig.value.gizmo.mode = mode load3d.setGizmoMode(mode) } } const handleFitToViewer = () => { if (!load3d) return load3d.fitToViewer() if (!modelConfig.value.gizmo) return const transform = load3d.getGizmoTransform() modelConfig.value.gizmo.position = transform.position modelConfig.value.gizmo.scale = transform.scale } const handleResetGizmoTransform = () => { if (load3d) { load3d.resetGizmoTransform() } } const handleEvents = (action: 'add' | 'remove') => { Object.entries(eventConfig).forEach(([event, handler]) => { const method = `${action}EventListener` as const load3d?.[method](event, handler as EventCallback) }) } const cleanup = () => { handleEvents('remove') const rawNode = toRaw(nodeRef.value) if (!rawNode) return const node = rawNode as LGraphNode if (nodeToLoad3dMap.get(node) === load3d) { nodeToLoad3dMap.delete(node) } load3d?.remove() load3d = null } return { // state load3d, sceneConfig, modelConfig, cameraConfig, lightConfig, isRecording, isPreview, isSplatModel, isPlyModel, canFitToViewer, canUseGizmo, canUseLighting, canExport, materialModes, hasSkeleton, hasRecording, recordingDuration, animations, playing, selectedSpeed, selectedAnimation, animationProgress, animationDuration, loading, loadingMessage, // Methods initializeLoad3d, waitForLoad3d, onLoad3dReady, handleMouseEnter, handleMouseLeave, handleStartRecording, handleStopRecording, handleExportRecording, handleClearRecording, handleSeek, handleBackgroundImageUpdate, handleHDRIFileUpdate, handleExportModel, handleModelDrop, handleToggleGizmo, handleSetGizmoMode, handleResetGizmoTransform, handleFitToViewer, cleanup } }