From 7fcfa4c2018f5b4a7cfda90e4f88c66832546f61 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sun, 4 Jan 2026 08:47:30 -0500 Subject: [PATCH] feat: add animation progress bar for 3D nodes and viewer (#7839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add draggable progress bar to AnimationControls component - Display current time / total duration - Allow seeking through animations when paused or playing - Add animation controls to 3D Viewer fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/7830 and https://github.com/Comfy-Org/ComfyUI_frontend/issues/7831 ## Screenshots (if applicable) https://github.com/user-attachments/assets/f6d0668c-c7a4-497e-8345-9ef6e47a41c6 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7839-feat-add-animation-progress-bar-for-3D-nodes-and-viewer-2de6d73d36508101ac98f673206b30d9) by [Unito](https://www.unito.io) --- src/components/load3d/Load3D.vue | 6 ++ src/components/load3d/Load3dViewerContent.vue | 11 ++ .../load3d/controls/AnimationControls.vue | 102 +++++++++++++----- src/composables/useLoad3d.ts | 20 ++++ src/composables/useLoad3dViewer.ts | 77 +++++++++++++ .../core/load3d/AnimationManager.ts | 57 ++++++++++ src/extensions/core/load3d/Load3d.ts | 13 +++ src/extensions/core/load3d/interfaces.ts | 3 + src/services/load3dService.ts | 18 +++- 9 files changed, 280 insertions(+), 27 deletions(-) diff --git a/src/components/load3d/Load3D.vue b/src/components/load3d/Load3D.vue index befc1f322..b69eab4aa 100644 --- a/src/components/load3d/Load3D.vue +++ b/src/components/load3d/Load3D.vue @@ -33,6 +33,9 @@ v-model:playing="playing" v-model:selected-speed="selectedSpeed" v-model:selected-animation="selectedAnimation" + v-model:animation-progress="animationProgress" + v-model:animation-duration="animationDuration" + @seek="handleSeek" />
+
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue' +import AnimationControls from '@/components/load3d/controls/AnimationControls.vue' import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue' import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue' import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue' diff --git a/src/components/load3d/controls/AnimationControls.vue b/src/components/load3d/controls/AnimationControls.vue index a1e2ae96c..16df631e5 100644 --- a/src/components/load3d/controls/AnimationControls.vue +++ b/src/components/load3d/controls/AnimationControls.vue @@ -1,42 +1,64 @@ diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index 045e8c572..8b589fc2b 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -60,6 +60,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { 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) @@ -357,6 +359,13 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { } } + const handleSeek = (progress: number) => { + if (load3d && animationDuration.value > 0) { + const time = (progress / 100) * animationDuration.value + load3d.setAnimationTime(time) + } + } + const handleBackgroundImageUpdate = async (file: File | null) => { if (!file) { sceneConfig.value.backgroundImage = '' @@ -514,6 +523,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { 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) { @@ -573,6 +590,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { playing, selectedSpeed, selectedAnimation, + animationProgress, + animationDuration, loading, loadingMessage, @@ -585,6 +604,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { handleStopRecording, handleExportRecording, handleClearRecording, + handleSeek, handleBackgroundImageUpdate, handleExportModel, handleModelDrop, diff --git a/src/composables/useLoad3dViewer.ts b/src/composables/useLoad3dViewer.ts index cd9acf8d4..9022c2d2d 100644 --- a/src/composables/useLoad3dViewer.ts +++ b/src/composables/useLoad3dViewer.ts @@ -3,6 +3,7 @@ import { ref, toRaw, watch } from 'vue' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import type { + AnimationItem, BackgroundRenderModeType, CameraState, CameraType, @@ -49,6 +50,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => { const isSplatModel = ref(false) const isPlyModel = ref(false) + // Animation state + const animations = ref([]) + const playing = ref(false) + const selectedSpeed = ref(1) + const selectedAnimation = ref(0) + const animationProgress = ref(0) + const animationDuration = ref(0) + let load3d: Load3d | null = null let sourceLoad3d: Load3d | null = null @@ -174,6 +183,61 @@ export const useLoad3dViewer = (node?: LGraphNode) => { } }) + // Animation watches + 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 handleSeek = (progress: number) => { + if (load3d && animationDuration.value > 0) { + const time = (progress / 100) * animationDuration.value + load3d.setAnimationTime(time) + } + } + + const setupAnimationEvents = () => { + if (!load3d) return + + load3d.addEventListener( + 'animationListChange', + (newValue: AnimationItem[]) => { + animations.value = newValue + } + ) + + load3d.addEventListener( + 'animationProgressChange', + (data: { progress: number; currentTime: number; duration: number }) => { + animationProgress.value = data.progress + animationDuration.value = data.duration + } + ) + + // Initialize animation list if animations already exist + if (load3d.hasAnimations()) { + const clips = load3d.animationManager.animationClips + animations.value = clips.map((clip, index) => ({ + name: clip.name || `Animation ${index + 1}`, + index + })) + animationDuration.value = load3d.getAnimationDuration() + } + } + /** * Initialize viewer in node mode (with source Load3d) */ @@ -270,6 +334,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => { upDirection: upDirection.value, materialMode: materialMode.value } + + setupAnimationEvents() } catch (error) { console.error('Error initializing Load3d viewer:', error) useToastStore().addAlert( @@ -310,6 +376,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => { isPlyModel.value = load3d.isPlyModel() isPreview.value = true + + setupAnimationEvents() } catch (error) { console.error('Error initializing standalone 3D viewer:', error) useToastStore().addAlert('Failed to load 3D model') @@ -527,6 +595,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => { isSplatModel, isPlyModel, + // Animation state + animations, + playing, + selectedSpeed, + selectedAnimation, + animationProgress, + animationDuration, + // Methods initializeViewer, initializeStandaloneViewer, @@ -539,6 +615,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => { refreshViewport, handleBackgroundImageUpdate, handleModelDrop, + handleSeek, cleanup } } diff --git a/src/extensions/core/load3d/AnimationManager.ts b/src/extensions/core/load3d/AnimationManager.ts index a451da8cd..80fc6f153 100644 --- a/src/extensions/core/load3d/AnimationManager.ts +++ b/src/extensions/core/load3d/AnimationManager.ts @@ -125,6 +125,13 @@ export class AnimationManager implements AnimationManagerInterface { } this.animationActions = [action] + + // Emit initial progress to set duration + this.eventManager.emitEvent('animationProgressChange', { + progress: 0, + currentTime: 0, + duration: clip.duration + }) } toggleAnimation(play?: boolean): void { @@ -150,8 +157,58 @@ export class AnimationManager implements AnimationManagerInterface { update(delta: number): void { if (this.currentAnimation && this.isAnimationPlaying) { this.currentAnimation.update(delta) + + if (this.animationActions.length > 0) { + const action = this.animationActions[0] + const clip = action.getClip() + const progress = (action.time / clip.duration) * 100 + this.eventManager.emitEvent('animationProgressChange', { + progress, + currentTime: action.time, + duration: clip.duration + }) + } } } + getAnimationTime(): number { + if (this.animationActions.length === 0) return 0 + return this.animationActions[0].time + } + + getAnimationDuration(): number { + if (this.animationActions.length === 0) return 0 + return this.animationActions[0].getClip().duration + } + + setAnimationTime(time: number): void { + if (this.animationActions.length === 0) return + const duration = this.getAnimationDuration() + const clampedTime = Math.max(0, Math.min(time, duration)) + + // Temporarily unpause to allow time update, then restore + const wasPaused = this.animationActions.map((action) => action.paused) + this.animationActions.forEach((action) => { + action.paused = false + action.time = clampedTime + }) + + if (this.currentAnimation) { + this.currentAnimation.setTime(clampedTime) + this.currentAnimation.update(0) + } + + // Restore paused state + this.animationActions.forEach((action, i) => { + action.paused = wasPaused[i] + }) + + this.eventManager.emitEvent('animationProgressChange', { + progress: (clampedTime / duration) * 100, + currentTime: clampedTime, + duration + }) + } + reset(): void {} } diff --git a/src/extensions/core/load3d/Load3d.ts b/src/extensions/core/load3d/Load3d.ts index 2fb8ff6bb..410887afc 100644 --- a/src/extensions/core/load3d/Load3d.ts +++ b/src/extensions/core/load3d/Load3d.ts @@ -726,6 +726,19 @@ class Load3d { return this.animationManager.animationClips.length > 0 } + public getAnimationTime(): number { + return this.animationManager.getAnimationTime() + } + + public getAnimationDuration(): number { + return this.animationManager.getAnimationDuration() + } + + public setAnimationTime(time: number): void { + this.animationManager.setAnimationTime(time) + this.forceRender() + } + public remove(): void { if (this.contextMenuAbortController) { this.contextMenuAbortController.abort() diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts index 2b043fdb1..8d32c54a7 100644 --- a/src/extensions/core/load3d/interfaces.ts +++ b/src/extensions/core/load3d/interfaces.ts @@ -146,6 +146,9 @@ export interface AnimationManagerInterface extends BaseManager { updateSelectedAnimation(index: number): void toggleAnimation(play?: boolean): void update(delta: number): void + getAnimationTime(): number + getAnimationDuration(): number + setAnimationTime(time: number): void } export interface ModelManagerInterface { diff --git a/src/services/load3dService.ts b/src/services/load3dService.ts index bc2585727..6e569ab85 100644 --- a/src/services/load3dService.ts +++ b/src/services/load3dService.ts @@ -1,4 +1,5 @@ import { toRaw } from 'vue' +import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils' import { nodeToLoad3dMap } from '@/composables/useLoad3d' import { useLoad3dViewer } from '@/composables/useLoad3dViewer' @@ -75,13 +76,20 @@ export class Load3dService { const sourceModel = source.modelManager.currentModel if (sourceModel) { + // Remove existing model from target scene before adding new one + const existingModel = target.getModelManager().currentModel + if (existingModel) { + target.getSceneManager().scene.remove(existingModel) + } + if (source.isSplatModel()) { const originalURL = source.modelManager.originalURL if (originalURL) { await target.loadModel(originalURL) } } else { - const modelClone = sourceModel.clone() + // Use SkeletonUtils.clone for proper skeletal animation support + const modelClone = SkeletonUtils.clone(sourceModel) target.getModelManager().currentModel = modelClone target.getSceneManager().scene.add(modelClone) @@ -105,6 +113,14 @@ export class Load3dService { target.getModelManager().appliedTexture = source.getModelManager().appliedTexture } + + // Copy animation state + if (source.hasAnimations()) { + target.animationManager.setupModelAnimations( + modelClone, + sourceOriginalModel + ) + } } }