mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 16:40:05 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user