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