feat: add animation progress bar for 3D nodes and viewer (#7839)

## 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)
This commit is contained in:
Terry Jia
2026-01-04 08:47:30 -05:00
committed by GitHub
parent 8d1f8edc5a
commit 7fcfa4c201
9 changed files with 280 additions and 27 deletions

View File

@@ -60,6 +60,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
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<LGraphNode | null>) => {
}
}
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<LGraphNode | null>) => {
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<LGraphNode | null>) => {
playing,
selectedSpeed,
selectedAnimation,
animationProgress,
animationDuration,
loading,
loadingMessage,
@@ -585,6 +604,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleStopRecording,
handleExportRecording,
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleExportModel,
handleModelDrop,

View File

@@ -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<AnimationItem[]>([])
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
}
}