Spaces:
Running
Running
| <template> | |
| <div class="video-section"> | |
| <tool-bar | |
| :current-tool="currentTool" | |
| @tool-selected="selectTool" | |
| /> | |
| <div class="video-container" ref="container"> | |
| <div class="video-wrapper"> | |
| <video | |
| ref="videoRef" | |
| class="video-element" | |
| crossorigin="anonymous" | |
| muted | |
| ></video> | |
| </div> | |
| <div class="canvas-wrapper"> | |
| <v-stage | |
| ref="stage" | |
| :config="stageConfig" | |
| @mousedown="handleMouseDown" | |
| @mousemove="handleMouseMove" | |
| @mouseup="handleMouseUp" | |
| class="canvas-overlay" | |
| > | |
| <v-layer ref="layer"> | |
| <!-- Fond transparent explicite --> | |
| <v-rect | |
| :config="{ | |
| x: position.x, | |
| y: position.y, | |
| width: imageWidth, | |
| height: imageHeight, | |
| fill: 'transparent' | |
| }" | |
| /> | |
| <!-- Lignes de guidage --> | |
| <v-line | |
| v-if="mousePosition.x !== null && isInsideImage(mousePosition)" | |
| :config="{ | |
| points: [ | |
| position.x, mousePosition.y, | |
| position.x + imageWidth, mousePosition.y | |
| ], | |
| stroke: '#ffffff', | |
| strokeWidth: 1, | |
| dash: [5, 5], | |
| opacity: 0.5 | |
| }" | |
| /> | |
| <v-line | |
| v-if="mousePosition.y !== null && isInsideImage(mousePosition)" | |
| :config="{ | |
| points: [ | |
| mousePosition.x, position.y, | |
| mousePosition.x, position.y + imageHeight | |
| ], | |
| stroke: '#ffffff', | |
| strokeWidth: 1, | |
| dash: [5, 5], | |
| opacity: 0.5 | |
| }" | |
| /> | |
| <!-- Rectangle en cours de dessin --> | |
| <v-rect | |
| v-if="isDrawing && currentTool === 'rectangle'" | |
| :config="{ | |
| x: rectangleStart.x, | |
| y: rectangleStart.y, | |
| width: rectangleSize.width, | |
| height: rectangleSize.height, | |
| stroke: getObjectColor(annotationStore.selectedObjectId), | |
| strokeWidth: 1, | |
| fill: null, | |
| dash: [] | |
| }" | |
| /> | |
| <!-- Rectangles sauvegardés --> | |
| <v-rect | |
| v-for="rect in rectangles" | |
| :key="rect.id" | |
| :config="{ | |
| x: rect.x, | |
| y: rect.y, | |
| width: rect.width, | |
| height: rect.height, | |
| stroke: selectedId === rect.id ? '#FFD700' : rect.color, | |
| strokeWidth: selectedId === rect.id ? 3 : (rect.objectId === annotationStore.selectedObjectId ? 2 : 1), | |
| fill: null, | |
| dash: rect.type === 'proxy' ? [5, 5] : [], | |
| id: rect.id, | |
| objectId: rect.objectId | |
| }" | |
| @mousedown="handleShapeMouseDown($event, rect.id)" | |
| /> | |
| <!-- Halo de sélection pour le rectangle sélectionné --> | |
| <v-rect | |
| v-if="selectedId && rectangles.find(r => r.id === selectedId)" | |
| :key="`halo-${selectedId}`" | |
| :config="{ | |
| x: rectangles.find(r => r.id === selectedId).x - 3, | |
| y: rectangles.find(r => r.id === selectedId).y - 3, | |
| width: rectangles.find(r => r.id === selectedId).width + 6, | |
| height: rectangles.find(r => r.id === selectedId).height + 6, | |
| stroke: '#FFD700', | |
| strokeWidth: 1, | |
| fill: null, | |
| dash: [6, 6], | |
| opacity: 0.6, | |
| listening: false | |
| }" | |
| /> | |
| <!-- Poignées de redimensionnement pour le rectangle sélectionné --> | |
| <template v-if="selectedId && currentTool === 'arrow'"> | |
| <v-circle | |
| v-for="handle in getResizeHandles()" | |
| :key="handle.position" | |
| :config="{ | |
| x: handle.x, | |
| y: handle.y, | |
| radius: 4, | |
| fill: 'white', | |
| stroke: '#4CAF50', | |
| strokeWidth: 1, | |
| draggable: true | |
| }" | |
| @dragmove="handleResize($event, handle.position)" | |
| /> | |
| </template> | |
| <!-- Points existants --> | |
| <v-group | |
| v-for="point in points" | |
| :key="point.id" | |
| :config="{ | |
| x: point.x, | |
| y: point.y, | |
| objectId: point.objectId, | |
| listening: true, | |
| id: point.id | |
| }" | |
| @mousedown="handlePointClick(point.id, $event)" | |
| > | |
| <!-- Halo de sélection pour le point sélectionné --> | |
| <v-circle | |
| v-if="selectedId === point.id" | |
| :config="{ | |
| radius: 12, | |
| fill: 'transparent', | |
| stroke: '#FFD700', | |
| strokeWidth: 2, | |
| dash: [4, 4], | |
| opacity: 0.8 | |
| }" | |
| /> | |
| <v-circle | |
| :config="{ | |
| radius: selectedId === point.id ? 7 : (point.objectId === annotationStore.selectedObjectId ? 6 : 5), | |
| fill: point.color, | |
| stroke: selectedId === point.id ? '#FFD700' : 'white', | |
| strokeWidth: selectedId === point.id ? 3 : (point.objectId === annotationStore.selectedObjectId ? 2 : 1) | |
| }" | |
| /> | |
| <v-line | |
| :config="{ | |
| points: [-2, 0, 2, 0], | |
| stroke: selectedId === point.id ? '#FFD700' : 'white', | |
| strokeWidth: selectedId === point.id ? 2 : 1 | |
| }" | |
| /> | |
| <v-line | |
| v-if="point.type === 'positive'" | |
| :config="{ | |
| points: [0, -2, 0, 2], | |
| stroke: selectedId === point.id ? '#FFD700' : 'white', | |
| strokeWidth: selectedId === point.id ? 2 : 1 | |
| }" | |
| /> | |
| </v-group> | |
| <!-- Masques de segmentation --> | |
| <v-shape | |
| v-for="annotation in maskedAnnotations" | |
| :key="`mask-${annotation.id}`" | |
| :config="{ | |
| sceneFunc: (context, shape) => drawMask(context, shape, annotation), | |
| fill: annotation.objectId === annotationStore.selectedObjectId ? | |
| `${getObjectColor(annotation.objectId)}88` : | |
| `${getObjectColor(annotation.objectId)}44`, | |
| stroke: getObjectColor(annotation.objectId), | |
| strokeWidth: annotation.objectId === annotationStore.selectedObjectId ? 2 : 1, | |
| opacity: 0.8, | |
| listening: true, | |
| id: annotation.id | |
| }" | |
| @mousedown="handleMaskClick(annotation.id)" | |
| /> | |
| <!-- Bounding boxes de tous les objets --> | |
| <v-rect | |
| v-for="bbox in allBoundingBoxes" | |
| v-show="showAllBoundingBoxes && bbox" | |
| :key="bbox.id" | |
| :config="{ | |
| x: bbox.x, | |
| y: bbox.y, | |
| width: bbox.width, | |
| height: bbox.height, | |
| stroke: bbox.color, | |
| strokeWidth: bbox.objectId === annotationStore.selectedObjectId ? 2 : 1, | |
| fill: null, | |
| dash: [4, 2], // Trait pointillé uniforme pour tous les rectangles | |
| opacity: 0.8, | |
| listening: false // Ne pas intercepter les clics sur les bbox d'affichage | |
| }" | |
| /> | |
| </v-layer> | |
| </v-stage> | |
| </div> | |
| </div> | |
| </div> | |
| </template> | |
| <script> | |
| import { useVideoStore } from '@/stores/videoStore' | |
| import { useAnnotationStore } from '@/stores/annotationStore' | |
| import ToolBar from './tools/ToolBar.vue' | |
| /* | |
| FONCTIONNALITÉS DES BOUNDING BOXES : | |
| 1. Affichage automatique des bounding boxes pour tous les objets sur chaque frame : | |
| - Annotations en cours : rectangles et masques créés dans cette session | |
| - Objet sélectionné : trait plus épais pour mise en évidence | |
| 2. Différenciation visuelle : | |
| - Annotations rectangles : trait continu [] | |
| - Annotations masques : trait pointillé court [- - -] | |
| - Couleurs : selon la couleur de l'objet définie | |
| 3. Contrôles clavier : | |
| - Touche 'b' : Basculer l'affichage des bounding boxes (on/off) | |
| - Touche 'd' : Afficher les informations de débogage dans la console | |
| - Par défaut : affichage activé | |
| 4. Sources de données : | |
| - Store d'annotations (this.annotationStore) : annotations créées en temps réel | |
| 5. Stockage des bounding boxes : | |
| - Rectangles : coordonnées directes (x, y, width, height) | |
| - Masques de segmentation : bbox calculée à partir des points ou du masque | |
| 6. Débogage : | |
| - Appel automatique de debugBoundingBoxes() à chaque changement de frame | |
| - Informations détaillées sur les annotations trouvées | |
| */ | |
| export default { | |
| name: 'VideoSection', | |
| components: { | |
| ToolBar | |
| }, | |
| props: { | |
| selectedObjectId: { | |
| type: String, | |
| default: null | |
| } | |
| }, | |
| setup() { | |
| const videoStore = useVideoStore() | |
| const annotationStore = useAnnotationStore() | |
| return { videoStore, annotationStore } | |
| }, | |
| data() { | |
| return { | |
| videoElement: null, | |
| imageWidth: 0, | |
| imageHeight: 0, | |
| position: { x: 0, y: 0 }, | |
| stageConfig: { | |
| width: 0, | |
| height: 0 | |
| }, | |
| currentTool: 'arrow', | |
| isDrawing: false, | |
| rectangleStart: { x: 0, y: 0 }, | |
| rectangleSize: { width: 0, height: 0 }, | |
| mousePosition: { x: null, y: null }, | |
| selectedId: null, | |
| isDragging: false, | |
| dragStartPos: { x: 0, y: 0 }, | |
| resizing: false, | |
| resizeTimeout: null, | |
| animationId: null, | |
| currentFrameNumber: 0, | |
| originalVideoPath: null, | |
| proxyVideoPath: null, | |
| isUsingProxy: true, | |
| originalVideoDimensions: { width: 0, height: 0 }, | |
| maskCache: {}, | |
| showAllBoundingBoxes: true, // Nouvelle propriété pour contrôler l'affichage des bbox | |
| } | |
| }, | |
| mounted() { | |
| // Réactiver l'élément vidéo | |
| this.videoElement = this.$refs.videoRef | |
| this.videoElement.muted = true | |
| this.videoElement.addEventListener('loadedmetadata', this.handleVideoLoaded) | |
| this.videoElement.addEventListener('timeupdate', this.updateCurrentFrame) | |
| // S'abonner aux changements de vidéo dans le store | |
| this.subscribeToVideoStore() | |
| window.addEventListener('resize', this.handleWindowResize) | |
| window.addEventListener('keydown', this.handleKeyDown) | |
| }, | |
| beforeUnmount() { | |
| window.removeEventListener('resize', this.handleWindowResize) | |
| window.removeEventListener('keydown', this.handleKeyDown) | |
| if (this.videoElement) { | |
| this.videoElement.removeEventListener('loadedmetadata', this.handleVideoLoaded) | |
| this.videoElement.pause() | |
| } | |
| this.stopAnimation() | |
| // Supprimer l'écouteur d'événement | |
| this.videoElement.removeEventListener('timeupdate', this.updateCurrentFrame) | |
| }, | |
| computed: { | |
| availableObjects() { | |
| return Object.values(this.annotationStore.objects) | |
| }, | |
| hasSelectedObject() { | |
| return !!this.annotationStore.selectedObjectId | |
| }, | |
| rectangles() { | |
| const frameAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) || [] | |
| // Convertir les annotations en rectangles pour l'affichage | |
| return frameAnnotations | |
| .filter(annotation => annotation && annotation.type === 'rectangle') | |
| .map(annotation => { | |
| const object = this.annotationStore.objects[annotation.objectId] | |
| const color = object ? object.color : '#4CAF50' | |
| // Convertir les coordonnées originales en coordonnées d'affichage | |
| const displayX = this.position.x + (annotation.x / this.scaleX) | |
| const displayY = this.position.y + (annotation.y / this.scaleY) | |
| const displayWidth = annotation.width / this.scaleX | |
| const displayHeight = annotation.height / this.scaleY | |
| return { | |
| id: annotation.id, | |
| objectId: annotation.objectId, | |
| x: displayX, | |
| y: displayY, | |
| width: displayWidth, | |
| height: displayHeight, | |
| color: color | |
| } | |
| }) | |
| }, | |
| points() { | |
| const frameAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) || [] | |
| // Points directs de type "point" (nouveau système) | |
| const directPoints = frameAnnotations | |
| .filter(annotation => annotation && annotation.type === 'point') | |
| .map(annotation => ({ | |
| id: annotation.id, | |
| objectId: annotation.objectId, | |
| x: this.position.x + (annotation.x / this.scaleX), | |
| y: this.position.y + (annotation.y / this.scaleY), | |
| type: annotation.pointType, | |
| color: this.getObjectColor(annotation.objectId), | |
| isDirect: true | |
| })) | |
| // Points des annotations de type "mask" qui contiennent des points (ancien système) | |
| const annotationPoints = frameAnnotations | |
| .filter(annotation => annotation && annotation.type === 'mask' && annotation.points) | |
| .flatMap(annotation => annotation.points.map(point => ({ | |
| id: `${annotation.id}-point-${point.x}-${point.y}`, | |
| objectId: annotation.objectId, | |
| x: this.position.x + (point.x / this.scaleX), | |
| y: this.position.y + (point.y / this.scaleY), | |
| type: point.type, | |
| color: this.getObjectColor(annotation.objectId), | |
| fromAnnotation: annotation.id | |
| }))) | |
| // Points temporaires (ne devrait plus être utilisé mais gardé pour sécurité) | |
| const tempPoints = (this.annotationStore.temporaryPoints || []).map(point => ({ | |
| id: `temp-point-${point.id}`, | |
| objectId: point.objectId, | |
| x: this.position.x + (point.x / this.scaleX), | |
| y: this.position.y + (point.y / this.scaleY), | |
| type: point.pointType, | |
| color: this.getObjectColor(point.objectId), | |
| isTemporary: true | |
| })) | |
| return [...directPoints, ...annotationPoints, ...tempPoints] | |
| }, | |
| scaleX() { | |
| if (this.originalVideoDimensions.width && this.imageWidth) { | |
| return this.originalVideoDimensions.width / this.imageWidth | |
| } | |
| if (!this.videoElement || !this.imageWidth) return 1 | |
| return this.videoElement.videoWidth / this.imageWidth | |
| }, | |
| scaleY() { | |
| if (this.originalVideoDimensions.height && this.imageHeight) { | |
| return this.originalVideoDimensions.height / this.imageHeight | |
| } | |
| if (!this.videoElement || !this.imageHeight) return 1 | |
| return this.videoElement.videoHeight / this.imageHeight | |
| }, | |
| maskedAnnotations() { | |
| const frameAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) || []; | |
| return frameAnnotations.filter(annotation => annotation && annotation.mask && annotation.maskImageSize); | |
| }, | |
| // Nouvelle computed property pour toutes les bounding boxes | |
| allBoundingBoxes() { | |
| // Temporairement désactivé pour isoler l'erreur | |
| return [] | |
| // Le code original sera restauré une fois l'erreur identifiée | |
| /* | |
| const frameAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) || [] | |
| const boundingBoxes = [] | |
| // 1. BOUNDING BOXES DES ANNOTATIONS EN COURS (STORE D'ANNOTATIONS) | |
| frameAnnotations.forEach(annotation => { | |
| if (!annotation) return // Vérifier que l'annotation existe | |
| const object = this.annotationStore.objects[annotation.objectId] | |
| const color = object ? object.color : '#CCCCCC' | |
| const objectName = object ? object.name : `Objet ${annotation.objectId}` | |
| if (annotation.type === 'rectangle') { | |
| // Pour les rectangles, utiliser directement leurs coordonnées | |
| const displayX = this.position.x + (annotation.x / this.scaleX) | |
| const displayY = this.position.y + (annotation.y / this.scaleY) | |
| const displayWidth = annotation.width / this.scaleX | |
| const displayHeight = annotation.height / this.scaleY | |
| boundingBoxes.push({ | |
| id: `bbox-${annotation.id}`, | |
| objectId: annotation.objectId, | |
| x: displayX, | |
| y: displayY, | |
| width: displayWidth, | |
| height: displayHeight, | |
| color: color, | |
| name: objectName, | |
| type: 'rectangle', | |
| annotationId: annotation.id, | |
| source: 'current' // Annotations en cours | |
| }) | |
| } else if (annotation.type === 'mask' && annotation.mask) { | |
| // Pour les masques, calculer la bounding box ou utiliser celle stockée | |
| let bbox = null | |
| if (annotation.bbox && annotation.bbox.output) { | |
| // Si la bbox est déjà stockée, l'utiliser | |
| bbox = annotation.bbox.output | |
| } else if (annotation.bbox) { | |
| // Si c'est juste bbox sans output | |
| bbox = annotation.bbox | |
| } else { | |
| // Sinon, essayer de calculer une bbox approximative à partir des dimensions du masque | |
| // Cette méthode n'est pas parfaite mais donne une approximation | |
| const maskSize = annotation.maskImageSize || { width: this.originalVideoDimensions.width, height: this.originalVideoDimensions.height } | |
| // Pour une approximation, on peut supposer que le masque couvre une région significative | |
| // En attendant une vraie bbox calculée côté serveur | |
| bbox = { | |
| x: Math.round(maskSize.width * 0.1), // 10% du bord gauche | |
| y: Math.round(maskSize.height * 0.1), // 10% du bord haut | |
| width: Math.round(maskSize.width * 0.8), // 80% de la largeur | |
| height: Math.round(maskSize.height * 0.8) // 80% de la hauteur | |
| } | |
| } | |
| if (bbox) { | |
| // Convertir les coordonnées originales en coordonnées d'affichage | |
| const displayX = this.position.x + (bbox.x / this.scaleX) | |
| const displayY = this.position.y + (bbox.y / this.scaleY) | |
| const displayWidth = bbox.width / this.scaleX | |
| const displayHeight = bbox.height / this.scaleY | |
| boundingBoxes.push({ | |
| id: `bbox-${annotation.id}`, | |
| objectId: annotation.objectId, | |
| x: displayX, | |
| y: displayY, | |
| width: displayWidth, | |
| height: displayHeight, | |
| color: color, | |
| name: objectName, | |
| type: 'mask', | |
| annotationId: annotation.id, | |
| source: 'current' // Annotations en cours | |
| }) | |
| } | |
| } | |
| }) | |
| // Filtrer les bounding boxes invalides avant de retourner | |
| return boundingBoxes.filter(bbox => bbox && bbox.type && bbox.id) | |
| */ | |
| }, | |
| }, | |
| methods: { | |
| subscribeToVideoStore() { | |
| const videoStore = useVideoStore() | |
| // Observer les changements dans le store | |
| this.$watch( | |
| () => videoStore.selectedVideo, | |
| (newVideo) => { | |
| if (newVideo) { | |
| console.log('Nouvelle vidéo sélectionnée dans VideoSection:', newVideo) | |
| this.loadVideo(newVideo.path) | |
| } | |
| }, | |
| { immediate: true } | |
| ) | |
| // Observer les changements de temps dans la timeline | |
| this.$watch( | |
| () => videoStore.currentTime, | |
| (newTime) => { | |
| if (this.videoElement && newTime !== undefined) { | |
| // Seulement mettre à jour si la différence est significative | |
| if (Math.abs(this.videoElement.currentTime - newTime) > 0.05) { | |
| this.videoElement.currentTime = newTime | |
| } | |
| } | |
| } | |
| ) | |
| // Observer l'état de lecture (play/pause) | |
| this.$watch( | |
| () => videoStore.isPlaying, | |
| (isPlaying) => { | |
| // console.log('État de lecture changé dans VideoSection:', isPlaying) | |
| if (isPlaying) { | |
| this.playVideo() | |
| } else { | |
| this.pauseVideo() | |
| } | |
| } | |
| ) | |
| }, | |
| loadVideo(videoPath) { | |
| if (!videoPath) return | |
| // Arrêter toute animation en cours | |
| this.stopAnimation() | |
| this.originalVideoPath = videoPath | |
| // Si l'élément vidéo est commenté, ne pas essayer de charger la vidéo | |
| if (!this.videoElement) { | |
| console.log("Mode test: élément vidéo non disponible, simulation uniquement") | |
| // Simuler des dimensions pour le test | |
| this.imageWidth = 640 | |
| this.imageHeight = 360 | |
| this.updateDimensions() | |
| return | |
| } | |
| // Si c'est une URL blob (vidéo uploadée), charger directement sans proxy | |
| if (videoPath.startsWith('blob:')) { | |
| console.log("Chargement direct d'une vidéo uploadée (blob URL)") | |
| this.videoElement.src = videoPath | |
| this.videoElement.load() | |
| return | |
| } | |
| // Si c'est un fichier média par défaut depuis les assets, le charger directement | |
| if (videoPath.includes('/assets/') || videoPath.includes('assets/') || | |
| videoPath.includes('.jpg') || videoPath.includes('.png') || | |
| videoPath.includes('.jpeg') || videoPath.includes('.mp4') || | |
| videoPath.includes('.webm') || videoPath.includes('.mov')) { | |
| console.log("Chargement direct d'un fichier média par défaut") | |
| this.videoElement.src = videoPath | |
| this.videoElement.load() | |
| return | |
| } | |
| // Pour les fichiers locaux, utiliser le système de proxy existant | |
| this.getOriginalVideoDimensions(videoPath) | |
| .then(dimensions => { | |
| console.log("Dimensions de la vidéo originale:", dimensions.width, "x", dimensions.height) | |
| // Stocker les dimensions originales pour les utiliser dans les calculs de coordonnées | |
| this.originalVideoDimensions = dimensions | |
| // Vérifier si un proxy existe déjà ou en créer un | |
| this.createOrLoadProxy(videoPath) | |
| .then(proxyPath => { | |
| this.proxyVideoPath = proxyPath | |
| // Charger le proxy si l'option est activée, sinon charger l'original | |
| const sourceToLoad = this.isUsingProxy ? proxyPath : videoPath | |
| this.videoElement.src = sourceToLoad | |
| this.videoElement.load() | |
| }) | |
| .catch(err => { | |
| console.error("Erreur lors de la création du proxy:", err) | |
| // En cas d'erreur, charger la vidéo originale | |
| this.videoElement.src = videoPath | |
| this.videoElement.load() | |
| }) | |
| }) | |
| .catch(err => { | |
| console.error("Erreur lors de l'obtention des dimensions de la vidéo originale:", err) | |
| // Continuer avec le chargement normal | |
| this.videoElement.src = videoPath | |
| this.videoElement.load() | |
| }) | |
| }, | |
| async getOriginalVideoDimensions(videoPath) { | |
| return new Promise((resolve, reject) => { | |
| // Créer un élément vidéo temporaire pour obtenir les dimensions | |
| const tempVideo = document.createElement('video') | |
| tempVideo.style.display = 'none' | |
| // Configurer les gestionnaires d'événements | |
| tempVideo.onloadedmetadata = () => { | |
| const dimensions = { | |
| width: tempVideo.videoWidth, | |
| height: tempVideo.videoHeight | |
| } | |
| // Nettoyer | |
| document.body.removeChild(tempVideo) | |
| resolve(dimensions) | |
| } | |
| tempVideo.onerror = (error) => { | |
| // Nettoyer | |
| if (document.body.contains(tempVideo)) { | |
| document.body.removeChild(tempVideo) | |
| } | |
| reject(error) | |
| } | |
| // Ajouter l'élément au DOM et charger la vidéo | |
| document.body.appendChild(tempVideo) | |
| tempVideo.src = videoPath | |
| }) | |
| }, | |
| async createOrLoadProxy(originalPath) { | |
| // Générer un nom de fichier pour le proxy | |
| const proxyPath = this.generateProxyPath(originalPath) | |
| // Vérifier si le proxy existe déjà | |
| const proxyExists = await this.checkIfFileExists(proxyPath) | |
| if (proxyExists) { | |
| console.log("Proxy vidéo existant trouvé:", proxyPath) | |
| return proxyPath | |
| } | |
| // Créer un nouveau proxy | |
| console.log("Création d'un nouveau proxy vidéo...") | |
| return this.createVideoProxy(originalPath, proxyPath) | |
| }, | |
| generateProxyPath(originalPath) { | |
| // Exemple: transformer "/videos/original.mp4" en "/videos/original_proxy.mp4" | |
| const pathParts = originalPath.split('.') | |
| const extension = pathParts.pop() | |
| return `${pathParts.join('.')}_proxy.${extension}` | |
| }, | |
| async checkIfFileExists() { | |
| // DÉSACTIVÉ - utilisation de vidéo statique dans assets | |
| console.log("Vérification de fichier désactivée - utilisation de vidéo statique"); | |
| return true; // Toujours vrai pour la vidéo statique | |
| }, | |
| async createVideoProxy(originalPath) { | |
| // DÉSACTIVÉ - utilisation de vidéo statique dans assets | |
| console.log("Création de proxy désactivée - utilisation de vidéo statique"); | |
| return originalPath; // Retourner le chemin original | |
| }, | |
| toggleProxyMode() { | |
| this.isUsingProxy = !this.isUsingProxy | |
| // Sauvegarder la position actuelle | |
| const currentTime = this.videoElement.currentTime | |
| // Charger la vidéo appropriée | |
| this.videoElement.src = this.isUsingProxy ? this.proxyVideoPath : this.originalVideoPath | |
| this.videoElement.load() | |
| // Restaurer la position après le chargement | |
| this.videoElement.addEventListener('loadedmetadata', () => { | |
| this.videoElement.currentTime = currentTime | |
| }, { once: true }) | |
| }, | |
| handleVideoLoaded() { | |
| console.log('Vidéo chargée, dimensions:', this.videoElement.videoWidth, 'x', this.videoElement.videoHeight) | |
| // Vérifier que les dimensions sont valides | |
| if (!this.videoElement.videoWidth || !this.videoElement.videoHeight) { | |
| console.error('Dimensions de vidéo invalides après chargement') | |
| return | |
| } | |
| // Stocker les dimensions si elles ne sont pas déjà définies | |
| if (!this.originalVideoDimensions.width || !this.originalVideoDimensions.height) { | |
| this.originalVideoDimensions = { | |
| width: this.videoElement.videoWidth, | |
| height: this.videoElement.videoHeight | |
| } | |
| } | |
| // Ajouter un log pour indiquer si c'est le proxy ou l'original | |
| const sourceType = this.isUsingProxy ? "proxy" : "originale" | |
| console.log(`Vidéo ${sourceType} chargée. Dimensions d'affichage:`, | |
| this.videoElement.videoWidth, 'x', this.videoElement.videoHeight) | |
| this.initializeView() | |
| // Démarrer l'animation pour le rendu fluide | |
| this.startAnimation() | |
| }, | |
| playVideo() { | |
| if (!this.videoElement) { | |
| console.log("Mode test: élément vidéo non disponible") | |
| return | |
| } | |
| this.videoElement.play() | |
| this.startAnimation() | |
| }, | |
| pauseVideo() { | |
| if (!this.videoElement) { | |
| console.log("Mode test: élément vidéo non disponible") | |
| return | |
| } | |
| this.videoElement.pause() | |
| }, | |
| startAnimation() { | |
| // Arrêter l'animation existante si elle existe | |
| this.stopAnimation() | |
| // Démarrer l'animation | |
| this.animationId = requestAnimationFrame(this.animate) | |
| }, | |
| stopAnimation() { | |
| if (this.animationId) { | |
| cancelAnimationFrame(this.animationId) | |
| this.animationId = null | |
| } | |
| }, | |
| initializeView() { | |
| this.$nextTick(() => { | |
| this.updateDimensions() | |
| }) | |
| }, | |
| handleWindowResize() { | |
| if (this.resizeTimeout) { | |
| clearTimeout(this.resizeTimeout) | |
| } | |
| this.resizeTimeout = setTimeout(() => { | |
| this.updateDimensions() | |
| }, 100) | |
| }, | |
| updateDimensions() { | |
| const container = this.$refs.container | |
| if (!container) return | |
| const containerWidth = container.clientWidth | |
| const containerHeight = container.clientHeight | |
| // Obtenir les dimensions de la vidéo avec des valeurs par défaut | |
| let videoWidth = 0 | |
| let videoHeight = 0 | |
| if (this.videoElement && this.videoElement.videoWidth && this.videoElement.videoHeight) { | |
| videoWidth = this.videoElement.videoWidth | |
| videoHeight = this.videoElement.videoHeight | |
| } else if (this.originalVideoDimensions.width && this.originalVideoDimensions.height) { | |
| videoWidth = this.originalVideoDimensions.width | |
| videoHeight = this.originalVideoDimensions.height | |
| } else if (this.imageWidth && this.imageHeight) { | |
| videoWidth = this.imageWidth | |
| videoHeight = this.imageHeight | |
| } else { | |
| // Valeurs par défaut si aucune dimension n'est disponible | |
| videoWidth = 640 | |
| videoHeight = 480 | |
| } | |
| // Vérifier que les dimensions sont valides | |
| if (!videoWidth || !videoHeight || videoWidth <= 0 || videoHeight <= 0) { | |
| console.warn('Dimensions vidéo invalides, utilisation de valeurs par défaut') | |
| videoWidth = 640 | |
| videoHeight = 480 | |
| } | |
| const videoRatio = videoWidth / videoHeight | |
| let width = containerWidth | |
| let height = width / videoRatio | |
| if (height > containerHeight) { | |
| height = containerHeight | |
| width = height * videoRatio | |
| } | |
| // S'assurer que les dimensions finales sont valides | |
| if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) { | |
| console.error('Dimensions calculées invalides, utilisation des dimensions du conteneur') | |
| width = containerWidth || 640 | |
| height = containerHeight || 480 | |
| } | |
| this.stageConfig.width = containerWidth | |
| this.stageConfig.height = containerHeight | |
| this.imageWidth = width | |
| this.imageHeight = height | |
| this.position = { | |
| x: Math.floor((containerWidth - width) / 2), | |
| y: Math.floor((containerHeight - height) / 2) | |
| } | |
| console.log('Dimensions mises à jour:', { videoWidth, videoHeight, displayWidth: width, displayHeight: height }) | |
| // Forcer une mise à jour du canvas | |
| if (this.$refs.layer) { | |
| const layer = this.$refs.layer.getNode(); | |
| layer.batchDraw(); | |
| } | |
| }, | |
| selectTool(tool) { | |
| this.currentTool = tool | |
| }, | |
| handleMouseDown(e) { | |
| if (e.evt.button !== 0) return | |
| const stage = this.$refs.stage.getStage() | |
| const pointerPos = stage.getPointerPosition() | |
| if (!this.isInsideImage(pointerPos)) return | |
| if (this.currentTool === 'arrow' && this.selectedId) { | |
| const handles = this.getResizeHandles() | |
| const clickedHandle = handles.find(handle => { | |
| const dx = handle.x - pointerPos.x | |
| const dy = handle.y - pointerPos.y | |
| return Math.sqrt(dx * dx + dy * dy) <= 5 | |
| }) | |
| if (clickedHandle) { | |
| this.resizing = true | |
| return | |
| } | |
| } | |
| // Sélection des rectangles (fonctionne avec tous les outils) | |
| const clickedRect = this.rectangles.find(rect => | |
| pointerPos.x >= rect.x && | |
| pointerPos.x <= rect.x + rect.width && | |
| pointerPos.y >= rect.y && | |
| pointerPos.y <= rect.y + rect.height | |
| ) | |
| if (clickedRect) { | |
| this.selectedId = clickedRect.id | |
| if (this.currentTool === 'arrow') { | |
| this.isDragging = true | |
| this.dragStartPos = pointerPos | |
| } | |
| console.log('Selected rectangle:', this.selectedId) | |
| return | |
| } | |
| // Sélection des points (fonctionne avec tous les outils) | |
| const clickedPoint = this.points.find(point => { | |
| const dx = point.x - pointerPos.x | |
| const dy = point.y - pointerPos.y | |
| return Math.sqrt(dx * dx + dy * dy) <= 8 // Augmenter la zone de détection | |
| }) | |
| if (clickedPoint) { | |
| this.selectedId = clickedPoint.id | |
| if (this.currentTool === 'arrow') { | |
| this.isDragging = true | |
| this.dragStartPos = pointerPos | |
| } | |
| console.log('Selected point:', this.selectedId) | |
| return | |
| } | |
| // Déselectionner si aucun élément n'est cliqué | |
| this.selectedId = null | |
| switch(this.currentTool) { | |
| case 'rectangle': | |
| if (!this.annotationStore.selectedObjectId) { | |
| this.annotationStore.addObject() | |
| } | |
| this.isDrawing = true | |
| this.rectangleStart = { | |
| x: pointerPos.x, | |
| y: pointerPos.y | |
| } | |
| this.rectangleSize = { width: 0, height: 0 } | |
| break | |
| case 'positive': | |
| this.addPoint(pointerPos, 'positive') | |
| break | |
| case 'negative': | |
| this.addPoint(pointerPos, 'negative') | |
| break | |
| } | |
| }, | |
| handleMouseMove() { | |
| const stage = this.$refs.stage.getStage(); | |
| const pointerPos = stage.getPointerPosition(); | |
| this.mousePosition = pointerPos; | |
| if (this.isDragging && this.selectedId && this.currentTool === 'arrow') { | |
| const dx = pointerPos.x - this.dragStartPos.x | |
| const dy = pointerPos.y - this.dragStartPos.y | |
| const selectedRect = this.rectangles.find(r => r.id === this.selectedId) | |
| if (selectedRect) { | |
| selectedRect.x += dx | |
| selectedRect.y += dy | |
| } | |
| const selectedPoint = this.points.find(p => p.id === this.selectedId) | |
| if (selectedPoint) { | |
| selectedPoint.x += dx | |
| selectedPoint.y += dy | |
| } | |
| this.dragStartPos = pointerPos | |
| return | |
| } | |
| if (!this.isDrawing || this.currentTool !== 'rectangle') return; | |
| this.rectangleSize = { | |
| width: pointerPos.x - this.rectangleStart.x, | |
| height: pointerPos.y - this.rectangleStart.y | |
| }; | |
| }, | |
| async handleMouseUp() { | |
| if (this.resizing) { | |
| this.resizing = false; | |
| return; | |
| } | |
| if (this.isDragging) { | |
| this.isDragging = false; | |
| // Mettre à jour la position dans le store après le drag | |
| if (this.selectedId) { | |
| const selectedRect = this.rectangles.find(r => r.id === this.selectedId) | |
| if (selectedRect) { | |
| // Convertir les coordonnées d'affichage en coordonnées réelles | |
| const realX = Math.round((selectedRect.x - this.position.x) * this.scaleX) | |
| const realY = Math.round((selectedRect.y - this.position.y) * this.scaleY) | |
| const realWidth = Math.round(selectedRect.width * this.scaleX) | |
| const realHeight = Math.round(selectedRect.height * this.scaleY) | |
| // Mettre à jour l'annotation dans le store | |
| this.annotationStore.updateAnnotation(this.currentFrameNumber, this.selectedId, { | |
| x: realX, | |
| y: realY, | |
| width: realWidth, | |
| height: realHeight | |
| }) | |
| } | |
| const selectedPoint = this.points.find(p => p.id === this.selectedId) | |
| if (selectedPoint) { | |
| // Convertir les coordonnées d'affichage en coordonnées réelles | |
| const realX = Math.round((selectedPoint.x - this.position.x) * this.scaleX) | |
| const realY = Math.round((selectedPoint.y - this.position.y) * this.scaleY) | |
| // Mettre à jour l'annotation dans le store | |
| this.annotationStore.updateAnnotation(this.currentFrameNumber, this.selectedId, { | |
| x: realX, | |
| y: realY | |
| }) | |
| } | |
| } | |
| return; | |
| } | |
| if (!this.isDrawing || this.currentTool !== 'rectangle') return; | |
| // Normaliser les coordonnées du rectangle pour s'assurer que x, y est le coin supérieur gauche | |
| // et que width, height sont positifs | |
| let normalizedRect = this.normalizeRectangle( | |
| this.rectangleStart.x, | |
| this.rectangleStart.y, | |
| this.rectangleSize.width, | |
| this.rectangleSize.height | |
| ); | |
| const relativeStart = { | |
| x: normalizedRect.x - this.position.x, | |
| y: normalizedRect.y - this.position.y | |
| }; | |
| // Utiliser les dimensions réelles de la vidéo originale, pas du proxy | |
| const originalRect = { | |
| x: Math.round(relativeStart.x * this.scaleX), | |
| y: Math.round(relativeStart.y * this.scaleY), | |
| width: Math.round(normalizedRect.width * this.scaleX), | |
| height: Math.round(normalizedRect.height * this.scaleY) | |
| }; | |
| // Vérifier s'il existe déjà des annotations pour cet objet sur cette frame | |
| const existingAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) | |
| .filter(annotation => annotation.objectId === this.annotationStore.selectedObjectId); | |
| // Si des annotations existent déjà, les supprimer avant d'ajouter la nouvelle | |
| if (existingAnnotations.length > 0) { | |
| console.log(`Suppression de ${existingAnnotations.length} annotations existantes pour l'objet ${this.annotationStore.selectedObjectId}`); | |
| existingAnnotations.forEach(annotation => { | |
| this.annotationStore.removeAnnotation(this.currentFrameNumber, annotation.id); | |
| }); | |
| } | |
| // Créer l'annotation avec les coordonnées réelles | |
| const annotation = { | |
| objectId: this.annotationStore.selectedObjectId, | |
| type: 'rectangle', | |
| x: originalRect.x, | |
| y: originalRect.y, | |
| width: originalRect.width, | |
| height: originalRect.height | |
| }; | |
| // Ajouter l'annotation au store | |
| const annotationId = this.annotationStore.addAnnotation(this.currentFrameNumber, annotation); | |
| // Log détaillé pour le mode annotation uniquement | |
| console.log('Rectangle ajouté à la frame', this.currentFrameNumber, 'avec ID:', annotationId, ':', annotation); | |
| console.log('État actuel des annotations:', JSON.parse(JSON.stringify(this.annotationStore.frameAnnotations))); | |
| this.isDrawing = false; | |
| this.rectangleSize = { width: 0, height: 0 }; | |
| }, | |
| // Ajouter cette nouvelle méthode pour normaliser les coordonnées du rectangle | |
| normalizeRectangle(x, y, width, height) { | |
| // Si la largeur est négative, ajuster x et width | |
| let newX = x; | |
| let newWidth = width; | |
| if (width < 0) { | |
| newX = x + width; | |
| newWidth = Math.abs(width); | |
| } | |
| // Si la hauteur est négative, ajuster y et height | |
| let newY = y; | |
| let newHeight = height; | |
| if (height < 0) { | |
| newY = y + height; | |
| newHeight = Math.abs(height); | |
| } | |
| return { | |
| x: newX, | |
| y: newY, | |
| width: newWidth, | |
| height: newHeight | |
| }; | |
| }, | |
| isInsideImage(point) { | |
| return point.x >= this.position.x && | |
| point.x <= this.position.x + this.imageWidth && | |
| point.y >= this.position.y && | |
| point.y <= this.position.y + this.imageHeight | |
| }, | |
| addPoint(pos, type) { | |
| if (!this.annotationStore.selectedObjectId) { | |
| this.annotationStore.addObject() | |
| } | |
| const relativeX = pos.x - this.position.x | |
| const relativeY = pos.y - this.position.y | |
| // Utiliser les dimensions réelles de la vidéo originale, pas du proxy | |
| const imageX = Math.round(relativeX * this.scaleX) | |
| const imageY = Math.round(relativeY * this.scaleY) | |
| // Vérifier s'il existe des rectangles pour cet objet sur cette frame | |
| const existingRectangles = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) | |
| .filter(annotation => | |
| annotation.objectId === this.annotationStore.selectedObjectId && | |
| annotation.type === 'rectangle' | |
| ); | |
| // Si des rectangles existent, les supprimer avant d'ajouter le point | |
| if (existingRectangles.length > 0) { | |
| console.log(`Suppression de ${existingRectangles.length} rectangles existants pour l'objet ${this.annotationStore.selectedObjectId}`); | |
| existingRectangles.forEach(rectangle => { | |
| this.annotationStore.removeAnnotation(this.currentFrameNumber, rectangle.id); | |
| }); | |
| // Supprimer également les masques associés à ces rectangles | |
| const associatedMasks = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) | |
| .filter(annotation => | |
| annotation.objectId === this.annotationStore.selectedObjectId && | |
| annotation.type === 'mask' && | |
| (!annotation.points || annotation.points.length === 0) | |
| ); | |
| associatedMasks.forEach(mask => { | |
| this.annotationStore.removeAnnotation(this.currentFrameNumber, mask.id); | |
| }); | |
| } | |
| // Créer directement une annotation pour le point (comme pour les rectangles) | |
| const annotation = { | |
| objectId: this.annotationStore.selectedObjectId, | |
| type: 'point', | |
| x: imageX, | |
| y: imageY, | |
| pointType: type | |
| }; | |
| // Ajouter l'annotation au store | |
| const annotationId = this.annotationStore.addAnnotation(this.currentFrameNumber, annotation); | |
| // Log détaillé | |
| console.log('Point ajouté à la frame', this.currentFrameNumber, 'avec ID:', annotationId, ':', annotation); | |
| console.log('État des frameAnnotations après ajout:', JSON.parse(JSON.stringify(this.annotationStore.frameAnnotations))); | |
| }, | |
| handleKeyDown(e) { | |
| // Raccourci pour supprimer un élément sélectionné (annotations uniquement) | |
| // Note: Pour supprimer un objet, utiliser Ctrl+Suppr dans la liste des objets | |
| if (e.key === 'Delete' && this.selectedId) { | |
| console.log('Tentative de suppression de l\'élément:', this.selectedId) | |
| // Déterminer le type d'élément sélectionné | |
| const selectedRect = this.rectangles.find(r => r.id === this.selectedId); | |
| const selectedPoint = this.points.find(p => p.id === this.selectedId); | |
| if (selectedRect) { | |
| console.log('Suppression du rectangle:', this.selectedId) | |
| // Supprimer le rectangle | |
| this.annotationStore.removeAnnotation(this.currentFrameNumber, this.selectedId); | |
| // Vérifier s'il reste des annotations pour cet objet sur cette frame | |
| this.checkAndCleanupMasks(selectedRect.objectId); | |
| } | |
| else if (selectedPoint) { | |
| console.log('Suppression du point:', this.selectedId, 'Type:', selectedPoint.isDirect ? 'direct' : 'depuis annotation') | |
| if (selectedPoint.isTemporary) { | |
| // Supprimer un point temporaire | |
| this.annotationStore.removeTemporaryPoint(selectedPoint.id.replace('temp-point-', '')); | |
| } else if (selectedPoint.isDirect) { | |
| // Nouveau système : point direct - supprimer directement l'annotation | |
| this.annotationStore.removeAnnotation(this.currentFrameNumber, this.selectedId); | |
| console.log('Point direct supprimé') | |
| } else { | |
| // Ancien système : point d'une annotation existante | |
| const annotationId = selectedPoint.fromAnnotation; | |
| const annotation = this.annotationStore.getAnnotation(this.currentFrameNumber, annotationId); | |
| if (annotation && annotation.points) { | |
| // Filtrer les points pour retirer celui qui est sélectionné | |
| const pointKey = selectedPoint.id.split('-point-')[1]; // Récupérer les coordonnées du point | |
| const [pointX, pointY] = pointKey.split('-').map(Number); | |
| const updatedPoints = annotation.points.filter(p => | |
| !(p.x === pointX && p.y === pointY) | |
| ); | |
| if (updatedPoints.length > 0) { | |
| // Mettre à jour l'annotation avec les points restants | |
| this.annotationStore.updateAnnotation(this.currentFrameNumber, annotationId, { | |
| points: updatedPoints | |
| }); | |
| console.log('Point retiré de l\'annotation, points restants:', updatedPoints.length) | |
| } else { | |
| // Si plus aucun point, supprimer l'annotation complètement | |
| this.annotationStore.removeAnnotation(this.currentFrameNumber, annotationId); | |
| console.log('Annotation complètement supprimée (plus de points)') | |
| } | |
| // Vérifier s'il reste des annotations pour cet objet sur cette frame | |
| this.checkAndCleanupMasks(selectedPoint.objectId); | |
| } | |
| } | |
| } else { | |
| console.log('Aucun élément trouvé avec l\'ID:', this.selectedId) | |
| } | |
| this.selectedId = null; | |
| console.log('Element deleted'); | |
| } | |
| // Raccourci "v" supprimé - Les points sont maintenant sauvegardés directement | |
| // Raccourci Escape supprimé - Plus de points temporaires à annuler | |
| // Raccourci pour basculer l'affichage des bounding boxes (touche 'b') | |
| if (e.key === 'b' || e.key === 'B') { | |
| e.preventDefault(); | |
| this.toggleBoundingBoxes(); | |
| } | |
| // Raccourci pour déboguer les bounding boxes (touche 'd') | |
| if (e.key === 'd' || e.key === 'D') { | |
| e.preventDefault(); | |
| this.debugBoundingBoxes(); | |
| } | |
| // Navigation frame par frame avec les flèches | |
| if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { | |
| e.preventDefault() // Empêcher le défilement de la page | |
| // Calculer la nouvelle frame | |
| const frameRate = this.annotationStore.currentSession.frameRate || 30 | |
| const currentFrame = this.currentFrameNumber | |
| const newFrame = e.key === 'ArrowLeft' ? Math.max(0, currentFrame - 1) : currentFrame + 1 | |
| // Calculer le nouveau temps basé sur la frame | |
| const newTime = newFrame / frameRate | |
| // Mettre à jour le temps dans le store et la vidéo | |
| this.videoStore.setCurrentTime(newTime) | |
| if (this.videoElement) { | |
| this.videoElement.currentTime = newTime | |
| } | |
| // console.log(`Navigation: Frame ${currentFrame} -> ${newFrame}, Temps: ${newTime.toFixed(3)}s`) | |
| } | |
| }, | |
| // Nouvelle méthode pour vérifier et nettoyer les masques orphelins | |
| checkAndCleanupMasks(objectId) { | |
| const frameAnnotations = this.annotationStore.getAnnotationsForFrame(this.currentFrameNumber) || []; | |
| // Vérifier s'il reste des annotations (rectangles ou points) pour cet objet sur cette frame | |
| const hasRemainingElements = frameAnnotations.some(annotation => | |
| annotation.objectId === objectId && | |
| (annotation.type === 'rectangle' || | |
| (annotation.type === 'mask' && annotation.points && annotation.points.length > 0)) | |
| ); | |
| if (!hasRemainingElements) { | |
| // Si aucun élément ne reste, supprimer tous les masques associés à cet objet sur cette frame | |
| const masksToRemove = frameAnnotations | |
| .filter(annotation => | |
| annotation.objectId === objectId && | |
| annotation.type === 'mask' && | |
| (!annotation.points || annotation.points.length === 0) | |
| ) | |
| .map(annotation => annotation.id); | |
| masksToRemove.forEach(maskId => { | |
| this.annotationStore.removeAnnotation(this.currentFrameNumber, maskId); | |
| }); | |
| if (masksToRemove.length > 0) { | |
| console.log(`Suppression de ${masksToRemove.length} masques orphelins pour l'objet ${objectId}`); | |
| } | |
| } | |
| }, | |
| getResizeHandles() { | |
| const rect = this.rectangles.find(r => r.id === this.selectedId) | |
| if (!rect) return [] | |
| return [ | |
| { position: 'nw', x: rect.x, y: rect.y }, | |
| { position: 'ne', x: rect.x + rect.width, y: rect.y }, | |
| { position: 'se', x: rect.x + rect.width, y: rect.y + rect.height }, | |
| { position: 'sw', x: rect.x, y: rect.y + rect.height }, | |
| { position: 'n', x: rect.x + rect.width/2, y: rect.y }, | |
| { position: 'e', x: rect.x + rect.width, y: rect.y + rect.height/2 }, | |
| { position: 's', x: rect.x + rect.width/2, y: rect.y + rect.height }, | |
| { position: 'w', x: rect.x, y: rect.y + rect.height/2 } | |
| ] | |
| }, | |
| handleResize(e, position) { | |
| const rect = this.rectangles.find(r => r.id === this.selectedId) | |
| if (!rect) return | |
| const stage = this.$refs.stage.getStage() | |
| const pos = stage.getPointerPosition() | |
| const originalX = rect.x | |
| const originalY = rect.y | |
| const originalWidth = rect.width | |
| const originalHeight = rect.height | |
| let newWidth, newHeight | |
| // Appliquer le redimensionnement selon la poignée utilisée | |
| switch (position) { | |
| case 'e': | |
| rect.width = Math.max(10, pos.x - rect.x) | |
| break | |
| case 'w': | |
| newWidth = originalWidth + (originalX - pos.x) | |
| if (newWidth >= 10) { | |
| rect.x = pos.x | |
| rect.width = newWidth | |
| } | |
| break | |
| case 'n': | |
| newHeight = originalHeight + (originalY - pos.y) | |
| if (newHeight >= 10) { | |
| rect.y = pos.y | |
| rect.height = newHeight | |
| } | |
| break | |
| case 's': | |
| rect.height = Math.max(10, pos.y - rect.y) | |
| break | |
| case 'nw': | |
| if (originalWidth + (originalX - pos.x) >= 10) { | |
| rect.x = pos.x | |
| rect.width = originalWidth + (originalX - pos.x) | |
| } | |
| if (originalHeight + (originalY - pos.y) >= 10) { | |
| rect.y = pos.y | |
| rect.height = originalHeight + (originalY - pos.y) | |
| } | |
| break | |
| case 'ne': | |
| rect.width = Math.max(10, pos.x - rect.x) | |
| if (originalHeight + (originalY - pos.y) >= 10) { | |
| rect.y = pos.y | |
| rect.height = originalHeight + (originalY - pos.y) | |
| } | |
| break | |
| case 'se': | |
| rect.width = Math.max(10, pos.x - rect.x) | |
| rect.height = Math.max(10, pos.y - rect.y) | |
| break | |
| case 'sw': | |
| if (originalWidth + (originalX - pos.x) >= 10) { | |
| rect.x = pos.x | |
| rect.width = originalWidth + (originalX - pos.x) | |
| } | |
| rect.height = Math.max(10, pos.y - rect.y) | |
| break | |
| } | |
| // Convertir les coordonnées d'affichage en coordonnées réelles | |
| const realX = Math.round((rect.x - this.position.x) * this.scaleX) | |
| const realY = Math.round((rect.y - this.position.y) * this.scaleY) | |
| const realWidth = Math.round(rect.width * this.scaleX) | |
| const realHeight = Math.round(rect.height * this.scaleY) | |
| // Mettre à jour l'annotation dans le store avec les coordonnées réelles | |
| this.annotationStore.updateAnnotation(this.currentFrameNumber, this.selectedId, { | |
| x: realX, | |
| y: realY, | |
| width: realWidth, | |
| height: realHeight | |
| }) | |
| }, | |
| updateCurrentFrame() { | |
| if (!this.videoElement) return | |
| const frameRate = this.annotationStore.currentSession.frameRate || 30 | |
| // Utiliser Math.round au lieu de Math.floor pour une meilleure précision | |
| const newFrameNumber = Math.round(this.videoElement.currentTime * frameRate) | |
| // Ne mettre à jour que si la frame a changé | |
| if (newFrameNumber !== this.currentFrameNumber) { | |
| this.currentFrameNumber = newFrameNumber | |
| // Forcer le rafraîchissement du canvas pour s'assurer que seules les annotations | |
| // de la frame actuelle sont affichées | |
| if (this.$refs.layer) { | |
| const layer = this.$refs.layer.getNode() | |
| layer.batchDraw() | |
| } | |
| // Debug des bounding boxes pour la nouvelle frame | |
| this.$nextTick(() => { | |
| this.debugBoundingBoxes() | |
| }) | |
| // Log pour débogage | |
| // console.log(`Temps: ${this.videoElement.currentTime.toFixed(3)}s, Frame: ${this.currentFrameNumber}`) | |
| } | |
| }, | |
| selectObject(objectId) { | |
| this.annotationStore.selectObject(objectId) | |
| this.$emit('object-selected', objectId) | |
| }, | |
| createNewObject() { | |
| this.annotationStore.addObject() | |
| }, | |
| animate() { | |
| // Récupérer les éléments sélectionnés | |
| if (this.$refs.layer && this.annotationStore.selectedObjectId) { | |
| const layer = this.$refs.layer.getNode(); | |
| // Trouver tous les éléments de l'objet sélectionné | |
| const selectedRects = layer.find('Rect').filter(rect => { | |
| return rect.attrs.objectId === this.annotationStore.selectedObjectId; | |
| }); | |
| const selectedPoints = layer.find('Group').filter(group => { | |
| return group.attrs.objectId === this.annotationStore.selectedObjectId; | |
| }); | |
| // Appliquer l'animation | |
| [...selectedRects, ...selectedPoints].forEach(shape => { | |
| // Animation de pulsation | |
| const scale = 1 + Math.sin(Date.now() / 300) * 0.05; // Pulsation subtile | |
| shape.scale({ x: scale, y: scale }); | |
| }); | |
| layer.batchDraw(); | |
| } | |
| // Continuer l'animation | |
| this.animationId = requestAnimationFrame(this.animate); | |
| }, | |
| getObjectColor(objectId) { | |
| const object = this.annotationStore.objects[objectId]; | |
| return object ? object.color : '#CCCCCC'; | |
| }, | |
| handleMaskClick(maskId, e) { | |
| // Empêcher la propagation pour éviter que handleMouseDown ne soit aussi appelé | |
| if (e && e.evt) { | |
| e.evt.stopPropagation(); // Remplacer cancelBubble par stopPropagation | |
| } | |
| // Sélectionner le masque | |
| this.selectedId = maskId; | |
| console.log('Selected mask:', maskId); | |
| }, | |
| drawMask(context, shape, annotation) { | |
| if (!annotation.mask || !annotation.maskImageSize) { | |
| console.warn('Annotation sans masque ou dimensions:', annotation); | |
| return; | |
| } | |
| // Vérifier si le masque est déjà dans le cache | |
| const cacheKey = `${annotation.id}-${annotation.mask.substring(0, 20)}`; | |
| let maskImage = this.maskCache[cacheKey]; | |
| if (!maskImage) { | |
| try { | |
| console.log(`Décodage du masque pour l'annotation ${annotation.id}`); | |
| console.log('Début du masque:', annotation.mask.substring(0, 50) + '...'); | |
| // Créer une nouvelle image pour charger le masque base64 | |
| maskImage = new Image(); | |
| // Attendre que l'image soit chargée avant de continuer | |
| const loadPromise = new Promise((resolve, reject) => { | |
| maskImage.onload = () => resolve(); | |
| maskImage.onerror = (e) => reject(new Error(`Erreur de chargement de l'image: ${e}`)); | |
| }); | |
| // Définir la source de l'image (base64) | |
| if (annotation.mask.startsWith('data:')) { | |
| // Si c'est déjà un data URL | |
| maskImage.src = annotation.mask; | |
| } else { | |
| // Sinon, supposer que c'est un base64 brut et créer un data URL | |
| maskImage.src = `data:image/png;base64,${annotation.mask}`; | |
| } | |
| // Attendre que l'image soit chargée | |
| loadPromise.then(() => { | |
| console.log(`Image du masque chargée: ${maskImage.width}x${maskImage.height}`); | |
| // Créer un canvas temporaire pour traiter l'image | |
| const tempCanvas = document.createElement('canvas'); | |
| tempCanvas.width = maskImage.width; | |
| tempCanvas.height = maskImage.height; | |
| const tempCtx = tempCanvas.getContext('2d'); | |
| // Dessiner l'image sur le canvas temporaire | |
| tempCtx.drawImage(maskImage, 0, 0); | |
| // Obtenir les données de l'image | |
| const imageData = tempCtx.getImageData(0, 0, maskImage.width, maskImage.height); | |
| const data = imageData.data; | |
| // Obtenir la couleur de l'objet | |
| const objectColor = this.getObjectColor(annotation.objectId); | |
| const r = parseInt(objectColor.slice(1, 3), 16); | |
| const g = parseInt(objectColor.slice(3, 5), 16); | |
| const b = parseInt(objectColor.slice(5, 7), 16); | |
| // Parcourir tous les pixels | |
| for (let i = 0; i < data.length; i += 4) { | |
| // Si le pixel est blanc (ou presque blanc) | |
| if (data[i] > 200 && data[i+1] > 200 && data[i+2] > 200) { | |
| // Remplacer par la couleur de l'objet avec une transparence | |
| data[i] = r; | |
| data[i+1] = g; | |
| data[i+2] = b; | |
| data[i+3] = 180; // Semi-transparent | |
| } else { | |
| // Rendre le pixel complètement transparent | |
| data[i+3] = 0; | |
| } | |
| } | |
| // Remettre les données modifiées dans le canvas | |
| tempCtx.putImageData(imageData, 0, 0); | |
| // Créer une nouvelle image à partir du canvas modifié | |
| const coloredMaskImage = new Image(); | |
| coloredMaskImage.src = tempCanvas.toDataURL(); | |
| // Mettre en cache l'image colorée | |
| this.maskCache[cacheKey] = coloredMaskImage; | |
| // Forcer un nouveau rendu | |
| this.$nextTick(() => { | |
| if (this.$refs.layer) { | |
| this.$refs.layer.getNode().batchDraw(); | |
| } | |
| }); | |
| }).catch(error => { | |
| console.error('Erreur lors du traitement de l\'image du masque:', error); | |
| }); | |
| // Retourner tôt car l'image n'est pas encore chargée | |
| return; | |
| } catch (error) { | |
| console.error('Erreur lors de la création de l\'image du masque:', error); | |
| return; | |
| } | |
| } | |
| // Si l'image n'est pas encore complètement chargée, retourner | |
| if (!maskImage.complete) { | |
| return; | |
| } | |
| // Calculer l'échelle pour adapter le masque à la taille d'affichage | |
| const scaleX = this.imageWidth / annotation.maskImageSize.width; | |
| const scaleY = this.imageHeight / annotation.maskImageSize.height; | |
| // Dessiner le masque sur le canvas principal | |
| const ctx = context._context; | |
| ctx.save(); | |
| // Appliquer la transformation pour positionner correctement le masque | |
| ctx.translate(this.position.x, this.position.y); | |
| ctx.scale(scaleX, scaleY); | |
| // Dessiner l'image du masque coloré | |
| ctx.drawImage(maskImage, 0, 0); | |
| // Restaurer le contexte | |
| ctx.restore(); | |
| // Indiquer à Konva que le dessin est terminé | |
| shape.strokeEnabled(false); // Désactiver le contour automatique | |
| }, | |
| handleShapeMouseDown(e, shapeId) { | |
| // Empêcher la propagation pour éviter que handleMouseDown ne soit aussi appelé | |
| e.evt.stopPropagation(); // Remplacer cancelBubble par stopPropagation | |
| // Sélectionner la forme | |
| this.selectedId = shapeId; | |
| // Si l'outil actuel est la flèche, activer le mode de déplacement | |
| if (this.currentTool === 'arrow') { | |
| this.isDragging = true; | |
| const stage = this.$refs.stage.getStage(); | |
| this.dragStartPos = stage.getPointerPosition(); | |
| console.log('Selected shape:', shapeId); | |
| } | |
| }, | |
| // Ajouter cette méthode pour gérer l'ajout de points à un masque existant | |
| addPointToExistingMask(pos, type) { | |
| if (!this.annotationStore.selectedObjectId) { | |
| this.annotationStore.addObject(); | |
| } | |
| const relativeX = pos.x - this.position.x; | |
| const relativeY = pos.y - this.position.y; | |
| // Utiliser les dimensions réelles de la vidéo originale | |
| const imageX = Math.round(relativeX * this.scaleX); | |
| const imageY = Math.round(relativeY * this.scaleY); | |
| // Ajouter le point à la collection temporaire | |
| this.annotationStore.addTemporaryPoint({ | |
| objectId: this.annotationStore.selectedObjectId, | |
| x: imageX, | |
| y: imageY, | |
| pointType: type | |
| }); | |
| console.log('Point temporaire ajouté:', { x: imageX, y: imageY, type }); | |
| }, | |
| // Nouvelle méthode pour basculer l'affichage des bounding boxes | |
| toggleBoundingBoxes() { | |
| this.showAllBoundingBoxes = !this.showAllBoundingBoxes; | |
| console.log('Affichage des bounding boxes:', this.showAllBoundingBoxes ? 'activé' : 'désactivé'); | |
| }, | |
| // Méthode de débogage pour afficher les informations des bounding boxes | |
| debugBoundingBoxes() { | |
| const boxes = this.allBoundingBoxes; | |
| if (boxes.length > 0) { | |
| console.log(`🔍 Frame ${this.currentFrameNumber}: ${boxes.length} bounding box(es) trouvée(s)`); | |
| const currentBoxes = boxes.filter(b => b.source === 'current'); | |
| if (currentBoxes.length > 0) { | |
| console.log(` ✏️ Annotations en cours: ${currentBoxes.length}`); | |
| currentBoxes.forEach(box => { | |
| console.log(` - ${box.name} (${box.type}): x=${Math.round(box.x)}, y=${Math.round(box.y)}, w=${Math.round(box.width)}, h=${Math.round(box.height)}`); | |
| }); | |
| } | |
| } | |
| }, | |
| // Nouvelle méthode pour gérer les clics sur les points | |
| handlePointClick(pointId, e) { | |
| // Empêcher la propagation pour éviter que handleMouseDown ne soit aussi appelé | |
| e.evt.stopPropagation() | |
| // Sélectionner le point | |
| this.selectedId = pointId | |
| // Si l'outil actuel est la flèche, activer le mode de déplacement | |
| if (this.currentTool === 'arrow') { | |
| this.isDragging = true | |
| const stage = this.$refs.stage.getStage() | |
| this.dragStartPos = stage.getPointerPosition() | |
| } | |
| console.log('Selected point via direct click:', pointId) | |
| }, | |
| }, | |
| watch: { | |
| 'stageConfig.width'() { | |
| this.$nextTick(() => { | |
| this.updateDimensions() | |
| }) | |
| }, | |
| 'stageConfig.height'() { | |
| this.$nextTick(() => { | |
| this.updateDimensions() | |
| }) | |
| }, | |
| 'videoStore.currentTime'() { | |
| this.updateCurrentFrame() | |
| }, | |
| 'annotationStore.selectedObjectId'(newId) { | |
| console.log('Objet sélectionné changé:', newId) | |
| // Redémarrer l'animation quand l'objet sélectionné change | |
| this.startAnimation() | |
| }, | |
| currentFrameNumber() { | |
| // Forcer le rafraîchissement du canvas quand la frame change | |
| this.$nextTick(() => { | |
| if (this.$refs.layer) { | |
| const layer = this.$refs.layer.getNode() | |
| layer.batchDraw() | |
| } | |
| }) | |
| } | |
| }, | |
| } | |
| </script> | |
| <style scoped> | |
| .video-section { | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| gap: 8px; | |
| } | |
| .video-container { | |
| flex: 1; | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; | |
| } | |
| .video-wrapper, .canvas-wrapper { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .video-wrapper { | |
| z-index: 1; | |
| } | |
| .canvas-wrapper { | |
| z-index: 999; | |
| } | |
| .video-element { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| z-index: 1; | |
| } | |
| .canvas-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 10; | |
| background: transparent; | |
| pointer-events: auto; | |
| } | |
| .tool-btn { | |
| width: 34px; | |
| height: 34px; | |
| border: none; | |
| border-radius: 4px; | |
| background: transparent; | |
| color: #fff; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s ease; | |
| } | |
| .tool-btn:hover { | |
| background: #4a4a4a; | |
| } | |
| .tool-btn.active { | |
| background: #3a3a3a; | |
| color: white; | |
| } | |
| .tool-btn svg { | |
| width: 20px; | |
| height: 20px; | |
| stroke-width: 2; | |
| } | |
| .tool-btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| background: transparent; | |
| } | |
| .tool-btn:not(:disabled):hover { | |
| background: #4a4a4a; | |
| } | |
| .pulse-animation { | |
| animation: pulse 1.5s infinite ease-in-out; | |
| } | |
| .spinner { | |
| width: 20px; | |
| height: 20px; | |
| border: 3px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 50%; | |
| border-top-color: white; | |
| animation: spin 1s ease-in-out infinite; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| </style> |