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

@@ -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"
/>
</div>
<div
@@ -119,6 +122,8 @@ const {
playing,
selectedSpeed,
selectedAnimation,
animationProgress,
animationDuration,
loading,
loadingMessage,
@@ -130,6 +135,7 @@ const {
handleStopRecording,
handleExportRecording,
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleExportModel,
handleModelDrop,

View File

@@ -15,6 +15,16 @@
@dragleave.stop="handleDragLeave"
@drop.prevent.stop="handleDrop"
/>
<AnimationControls
v-if="viewer.animations.value && viewer.animations.value.length > 0"
v-model:animations="viewer.animations.value"
v-model:playing="viewer.playing.value"
v-model:selected-speed="viewer.selectedSpeed.value"
v-model:selected-animation="viewer.selectedAnimation.value"
v-model:animation-progress="viewer.animationProgress.value"
v-model:animation-duration="viewer.animationDuration.value"
@seek="viewer.handleSeek"
/>
<div
v-if="isDragging"
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -85,6 +95,7 @@
<script setup lang="ts">
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'

View File

@@ -1,42 +1,64 @@
<template>
<div
v-if="animations && animations.length > 0"
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full items-center justify-center gap-2 pt-2"
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full flex-col items-center gap-2 pt-2"
>
<Button
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('g.playPause')"
@click="togglePlay"
>
<i
:class="['pi', playing ? 'pi-pause' : 'pi-play', 'text-lg text-white']"
<div class="flex items-center justify-center gap-2">
<Button
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('g.playPause')"
@click="togglePlay"
>
<i
:class="[
'pi',
playing ? 'pi-pause' : 'pi-play',
'text-lg text-white'
]"
/>
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
/>
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
/>
<Select
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
</div>
<Select
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
<div class="flex w-full max-w-xs items-center gap-2 px-4">
<Slider
:model-value="[animationProgress]"
:min="0"
:max="100"
:step="0.1"
class="flex-1"
@update:model-value="handleSliderChange"
/>
<span class="min-w-16 text-xs text-white">
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
type Animation = { name: string; index: number }
@@ -44,6 +66,16 @@ const animations = defineModel<Animation[]>('animations')
const playing = defineModel<boolean>('playing')
const selectedSpeed = defineModel<number>('selectedSpeed')
const selectedAnimation = defineModel<number>('selectedAnimation')
const animationProgress = defineModel<number>('animationProgress', {
default: 0
})
const animationDuration = defineModel<number>('animationDuration', {
default: 0
})
const emit = defineEmits<{
seek: [progress: number]
}>()
const speedOptions = [
{ name: '0.1x', value: 0.1 },
@@ -53,7 +85,25 @@ const speedOptions = [
{ name: '2x', value: 2 }
]
const togglePlay = () => {
const currentTime = computed(() => {
if (!animationDuration.value) return 0
return (animationProgress.value / 100) * animationDuration.value
})
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = (seconds % 60).toFixed(1)
return mins > 0 ? `${mins}:${secs.padStart(4, '0')}` : `${secs}s`
}
function togglePlay() {
playing.value = !playing.value
}
function handleSliderChange(value: number[] | undefined) {
if (!value) return
const progress = value[0]
animationProgress.value = progress
emit('seek', progress)
}
</script>

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
}
}

View File

@@ -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 {}
}

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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
)
}
}
}