Spaces:
Running
Running
| <template> | |
| <div class="timeline-section"> | |
| <div class="timeline-container"> | |
| <div class="controls"> | |
| <button class="play-pause-btn" @click="togglePlayPause"> | |
| <!-- Triangle creux pour le bouton play --> | |
| <svg v-if="!isPlaying" viewBox="0 0 24 24" width="20" height="20"> | |
| <polygon points="5,3 19,12 5,21" fill="none" stroke="white" stroke-width="1.5"/> | |
| </svg> | |
| <!-- Icône pause --> | |
| <svg v-else viewBox="0 0 24 24" width="20" height="20"> | |
| <rect x="6" y="4" width="4" height="16" fill="white"/> | |
| <rect x="14" y="4" width="4" height="16" fill="white"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="video-timeline"> | |
| <div class="timeline-track"> | |
| <div class="time-marker" :style="{ left: progressPercentage + '%' }"> | |
| <div class="time-indicator">{{ formatTimeSimple(preciseTime) }}</div> | |
| <div class="marker-head"></div> | |
| <div class="marker-line"></div> | |
| </div> | |
| <!-- Suppression des marqueurs d'annotation --> | |
| <div class="frames-container"> | |
| <div | |
| class="frame" | |
| v-for="(thumbnail, index) in thumbnails" | |
| :key="index" | |
| :style="{ backgroundImage: `url(${thumbnail})` }" | |
| ></div> | |
| </div> | |
| <input | |
| type="range" | |
| min="0" | |
| :max="duration" | |
| step="0.01" | |
| v-model="currentTime" | |
| @input="seekVideo" | |
| class="timeline-slider" | |
| /> | |
| </div> | |
| </div> | |
| <div class="timeline-tools"></div> | |
| </div> | |
| </div> | |
| </template> | |
| <script> | |
| import { useVideoStore } from '@/stores/videoStore' | |
| import { useAnnotationStore } from '@/stores/annotationStore' | |
| export default { | |
| name: 'TimelineSection', | |
| data() { | |
| return { | |
| videoStore: useVideoStore(), | |
| annotationStore: useAnnotationStore(), | |
| isPlaying: false, | |
| currentTime: 0, | |
| duration: 100, // Valeur par défaut, sera mise à jour quand la vidéo sera chargée | |
| videoElement: null, | |
| thumbnails: [], | |
| thumbnailCount: 6, | |
| timeUpdateInterval: null, | |
| keyboardListener: null, | |
| unsubscribeTimeUpdate: null, | |
| frameRate: 30, // Taux d'images par défaut, à mettre à jour lors du chargement de la vidéo | |
| currentFrame: 0 // Numéro de frame actuel | |
| } | |
| }, | |
| computed: { | |
| progressPercentage() { | |
| // Utiliser directement la valeur du store pour s'assurer que les mises à jour sont reflétées | |
| const storeTime = this.videoStore.currentTime || this.currentTime | |
| return (storeTime / this.duration) * 100 || 0 | |
| }, | |
| // Calculer le temps exact basé sur le numéro de frame | |
| preciseTime() { | |
| return this.currentFrame / this.frameRate | |
| }, | |
| // Récupérer toutes les frames qui ont des annotations | |
| annotationFrames() { | |
| return this.annotationStore.frameAnnotations || {} | |
| } | |
| }, | |
| mounted() { | |
| // Synchroniser le frameRate avec le store au démarrage | |
| this.frameRate = this.videoStore.fps || 25 | |
| // Ajouter un écouteur d'événement pour les touches Entrée et Espace | |
| this.keyboardListener = (event) => { | |
| if (event.key === ' ' || event.code === 'Space') { | |
| // Empêcher le comportement par défaut (comme le défilement de la page avec la barre d'espace) | |
| event.preventDefault(); | |
| this.togglePlayPause(); | |
| } | |
| }; | |
| document.addEventListener('keydown', this.keyboardListener); | |
| // S'abonner aux changements de temps dans le store | |
| this.unsubscribeTimeUpdate = this.videoStore.$subscribe((mutation, state) => { | |
| if (state.currentTime !== this.currentTime) { | |
| this.currentTime = state.currentTime | |
| // Mettre à jour également le numéro de frame | |
| this.currentFrame = this.getCurrentFrame() | |
| } | |
| }) | |
| }, | |
| watch: { | |
| 'videoStore.selectedVideo': { | |
| handler(newVideo) { | |
| if (newVideo) { | |
| console.log('Nouvelle vidéo sélectionnée dans Timeline:', newVideo) | |
| this.resetPlayer() | |
| this.loadVideo(newVideo.path) | |
| // Mettre à jour le frameRate dans le store d'annotation | |
| if (this.annotationStore.currentSession) { | |
| this.annotationStore.currentSession.videoId = newVideo.id || newVideo.path | |
| this.annotationStore.currentSession.frameRate = this.frameRate | |
| } | |
| } | |
| }, | |
| immediate: true | |
| }, | |
| // Watcher pour les changements de FPS | |
| 'videoStore.fps': { | |
| handler(newFps) { | |
| if (newFps && newFps !== this.frameRate) { | |
| console.log('FPS changé de', this.frameRate, 'à', newFps) | |
| // Mettre à jour le frameRate | |
| this.frameRate = newFps | |
| // Recalculer la frame actuelle basée sur le nouveau FPS | |
| this.currentFrame = this.getCurrentFrame() | |
| // Mettre à jour le frameRate dans le store d'annotation | |
| if (this.annotationStore.currentSession) { | |
| this.annotationStore.currentSession.frameRate = this.frameRate | |
| } | |
| console.log('Frame actuelle après changement de FPS:', this.currentFrame) | |
| } | |
| }, | |
| immediate: true | |
| } | |
| }, | |
| methods: { | |
| async loadVideo(videoPath) { | |
| try { | |
| // Utiliser la méthode du store pour charger la vidéo | |
| const { duration, videoElement, frameRate } = await this.videoStore.loadVideoMetadata(videoPath) | |
| // Mettre à jour les propriétés locales | |
| this.duration = duration | |
| this.videoElement = videoElement | |
| // Utiliser le FPS du store plutôt que celui détecté de la vidéo | |
| this.frameRate = this.videoStore.fps || frameRate || 25 | |
| // Mettre à jour le frameRate dans le store d'annotation | |
| if (this.annotationStore.currentSession) { | |
| this.annotationStore.currentSession.frameRate = this.frameRate | |
| } | |
| // Générer les vignettes | |
| this.generateThumbnails(videoElement) | |
| } catch (error) { | |
| console.error('Erreur lors du chargement de la vidéo:', error) | |
| } | |
| }, | |
| // Calculer la position d'une frame sur la timeline (en pourcentage) | |
| getFramePosition(frameNumber) { | |
| const timeInSeconds = frameNumber / this.frameRate | |
| return (timeInSeconds / this.duration) * 100 | |
| }, | |
| // Obtenir la couleur pour un marqueur d'annotation | |
| getAnnotationColor(frameNumber) { | |
| const annotations = this.annotationStore.getAnnotationsForFrame(frameNumber) | |
| if (annotations.length > 0) { | |
| // Utiliser la couleur du premier objet annoté | |
| const objectId = annotations[0].objectId | |
| const object = this.annotationStore.objects[objectId] | |
| return object ? object.color : '#FFFFFF' | |
| } | |
| return '#FFFFFF' // Couleur par défaut | |
| }, | |
| async generateThumbnails(videoEl) { | |
| this.thumbnails = [] | |
| // Créer un canvas pour dessiner les vignettes | |
| const canvas = document.createElement('canvas') | |
| const ctx = canvas.getContext('2d') | |
| // Définir la taille du canvas | |
| canvas.width = 160 // Largeur de la vignette | |
| canvas.height = 90 // Hauteur de la vignette (ratio 16:9) | |
| // Générer les vignettes à intervalles réguliers | |
| for (let i = 0; i < this.thumbnailCount; i++) { | |
| const timePoint = (i / (this.thumbnailCount - 1)) * this.duration | |
| // Positionner la vidéo au point temporel | |
| videoEl.currentTime = timePoint | |
| // Attendre que la vidéo soit positionnée | |
| await new Promise(resolve => { | |
| videoEl.addEventListener('seeked', resolve, { once: true }) | |
| }) | |
| // Dessiner l'image sur le canvas | |
| ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height) | |
| // Convertir le canvas en URL de données | |
| const thumbnailUrl = canvas.toDataURL('image/jpeg') | |
| this.thumbnails.push(thumbnailUrl) | |
| } | |
| }, | |
| // Méthode pour aller à une frame spécifique | |
| goToFrame(frameNumber) { | |
| this.currentFrame = frameNumber | |
| const preciseTime = frameNumber / this.frameRate | |
| // Mettre à jour le temps avec une précision à l'image près | |
| this.currentTime = preciseTime | |
| this.videoStore.currentTime = preciseTime | |
| if (this.videoElement) { | |
| // Utiliser requestAnimationFrame pour s'assurer que le DOM est prêt | |
| requestAnimationFrame(() => { | |
| this.videoElement.currentTime = preciseTime | |
| }) | |
| } | |
| // Mettre à jour l'interface | |
| this.seekVideo() | |
| }, | |
| // Méthode pour obtenir le numéro de frame actuel à partir du temps | |
| getCurrentFrame() { | |
| // Utiliser Math.round au lieu de Math.floor pour une meilleure précision | |
| return Math.round(this.currentTime * this.frameRate) | |
| }, | |
| togglePlayPause() { | |
| this.isPlaying = !this.isPlaying | |
| // Mettre à jour le store pour que VideoSection soit informé | |
| this.videoStore.isPlaying = this.isPlaying | |
| if (this.videoElement) { | |
| if (this.isPlaying) { | |
| // S'assurer que la vidéo commence à la position exacte de la frame actuelle | |
| const preciseTime = this.currentFrame / this.frameRate | |
| this.videoElement.currentTime = preciseTime | |
| this.videoElement.play() | |
| this.startTimeUpdate() | |
| } else { | |
| this.videoElement.pause() | |
| this.stopTimeUpdate() | |
| // Capturer le numéro de frame exact lors de la pause | |
| this.currentFrame = this.getCurrentFrame() | |
| } | |
| } else { | |
| // Mode simulation | |
| if (this.isPlaying) { | |
| // Commencer la simulation à partir de la frame actuelle | |
| this.startTimeUpdate() | |
| } else { | |
| this.stopTimeUpdate() | |
| // Capturer le numéro de frame exact lors de la pause | |
| this.currentFrame = this.getCurrentFrame() | |
| } | |
| } | |
| }, | |
| seekVideo() { | |
| // Calculer le numéro de frame exact | |
| this.currentFrame = this.getCurrentFrame() | |
| // Calculer le temps précis basé sur le numéro de frame | |
| const preciseTime = this.currentFrame / this.frameRate | |
| if (this.videoElement) { | |
| this.videoElement.currentTime = preciseTime | |
| } | |
| // Mettre à jour le store avec le temps précis | |
| this.videoStore.currentTime = preciseTime | |
| }, | |
| startTimeUpdate() { | |
| // Utiliser requestAnimationFrame pour une meilleure fluidité | |
| const updateTime = () => { | |
| if (this.videoElement) { | |
| this.currentTime = this.videoElement.currentTime | |
| // Mettre à jour le numéro de frame actuel | |
| this.currentFrame = this.getCurrentFrame() | |
| // Mettre à jour le store à chaque frame | |
| this.videoStore.currentTime = this.currentTime | |
| } else { | |
| // Simulation pour test - avancer d'une frame à la fois | |
| this.currentFrame += 1 | |
| this.currentTime = this.currentFrame / this.frameRate | |
| // Vérifier si on a atteint la fin | |
| if (this.currentTime >= this.duration) { | |
| this.isPlaying = false | |
| this.videoStore.isPlaying = false | |
| this.stopTimeUpdate() | |
| return | |
| } | |
| // Mettre à jour le store même en mode simulation | |
| this.videoStore.currentTime = this.currentTime | |
| } | |
| if (this.isPlaying) { | |
| this.animationFrameId = requestAnimationFrame(updateTime) | |
| } | |
| } | |
| this.animationFrameId = requestAnimationFrame(updateTime) | |
| }, | |
| stopTimeUpdate() { | |
| if (this.animationFrameId) { | |
| cancelAnimationFrame(this.animationFrameId) | |
| this.animationFrameId = null | |
| } | |
| }, | |
| resetPlayer() { | |
| this.isPlaying = false | |
| this.currentTime = 0 | |
| this.stopTimeUpdate() | |
| this.thumbnails = [] | |
| }, | |
| formatTimeSimple(seconds) { | |
| const secs = Math.floor(seconds) | |
| const cs = Math.floor((seconds - secs) * 100) | |
| // Ajouter le numéro de frame pour plus de précision | |
| const frame = this.getCurrentFrame() | |
| return `${secs}:${cs < 10 ? '0' : ''}${cs} (f:${frame})` | |
| } | |
| }, | |
| beforeUnmount() { | |
| this.stopTimeUpdate() | |
| if (this.videoElement) { | |
| this.videoElement.pause() | |
| this.videoElement.src = '' | |
| this.videoElement = null | |
| } | |
| // Supprimer l'écouteur d'événement lors du démontage du composant | |
| if (this.keyboardListener) { | |
| document.removeEventListener('keydown', this.keyboardListener); | |
| } | |
| // Désabonner de l'écoute des changements dans le store | |
| if (this.unsubscribeTimeUpdate) { | |
| this.unsubscribeTimeUpdate() | |
| } | |
| } | |
| } | |
| </script> | |
| <style scoped> | |
| .timeline-section { | |
| padding-top: 25px; | |
| width: 100%; | |
| } | |
| .timeline-container { | |
| display: flex; | |
| align-items: center; | |
| gap: 20px; | |
| height: 100%; | |
| } | |
| .controls { | |
| display: flex; | |
| align-items: center; | |
| } | |
| .play-pause-btn { | |
| width: 40px; | |
| height: 40px; | |
| background: transparent; | |
| border: none; | |
| color: white; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 0; | |
| } | |
| .video-timeline { | |
| flex-grow: 1; | |
| position: relative; | |
| } | |
| .timeline-track { | |
| position: relative; | |
| height: 50px; | |
| } | |
| .frames-container { | |
| display: flex; | |
| width: 100%; | |
| height: 100%; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| } | |
| .frame { | |
| flex: 1; | |
| height: 100%; | |
| background-size: cover; | |
| background-position: center; | |
| border-right: 2px solid black; | |
| } | |
| .frame:last-child { | |
| border-right: none; | |
| } | |
| .time-marker { | |
| position: absolute; | |
| bottom: 0; | |
| transform: translateX(-50%); | |
| z-index: 10; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| .time-indicator { | |
| color: white; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| margin-bottom: 4px; | |
| white-space: nowrap; | |
| } | |
| .marker-head { | |
| width: 6px; | |
| height: 3px; | |
| background: white; | |
| } | |
| .marker-line { | |
| width: 2px; | |
| height: 50px; | |
| background: white; | |
| } | |
| .timeline-slider { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| opacity: 0; | |
| cursor: pointer; | |
| z-index: 5; | |
| } | |
| .timeline-tools { | |
| width: 50px; | |
| height: 50px; | |
| } | |
| .annotation-marker { | |
| position: absolute; | |
| bottom: 0; | |
| transform: translateX(-50%); | |
| z-index: 8; | |
| cursor: pointer; | |
| } | |
| .annotation-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background-color: white; | |
| margin-bottom: 2px; | |
| } | |
| </style> |