refactor(vwb): refonte complète capture écran — stable définitivement
Some checks failed
security-audit / Bandit (scan statique) (push) Successful in 13s
security-audit / pip-audit (CVE dépendances) (push) Successful in 11s
security-audit / Scan secrets (grep) (push) Successful in 8s
tests / Lint (ruff + black) (push) Successful in 13s
tests / Tests unitaires (sans GPU) (push) Failing after 15s
tests / Tests sécurité (critique) (push) Has been skipped

FullscreenSelector réécrit :
- Overlay unique positionné via getBoundingClientRect()
- Recalcul auto au resize
- Coordonnées souris relatives à l'image
- Plus de décalage bboxes/sélection

Capture backend :
- mss.monitors[0] (écran composite) au lieu de pyautogui.screenshot()
- Capture la VM en plein écran correctement

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-21 09:03:19 +02:00
parent 5da4581e76
commit 14a9442343
2 changed files with 185 additions and 99 deletions

View File

@@ -52,10 +52,24 @@ def capture_screen():
}
"""
try:
# Utiliser mss pour capturer TOUS les moniteurs (ecran compose).
# pyautogui.screenshot() capture uniquement le premier moniteur,
# ce qui rate la VM en plein ecran sur un second ecran ou via QEMU/spice.
# mss.monitors[0] = ecran compose (tous les moniteurs), ce qui capture
# exactement ce que l'utilisateur voit quel que soit le setup.
try:
import mss
with mss.mss() as sct:
# monitors[0] = ecran virtuel englobant tous les moniteurs
monitor = sct.monitors[0]
sct_img = sct.grab(monitor)
# Convertir mss ScreenShot (BGRA) en PIL Image RGB
screenshot = Image.frombytes('RGB', sct_img.size, sct_img.rgb)
except ImportError:
# Fallback pyautogui si mss n'est pas installe
import pyautogui
# Capture écran
screenshot = pyautogui.screenshot()
width, height = screenshot.size
# Convertir en base64

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import type { Capture, ExecutionMode } from '../types';
import type { UIElement } from '../services/uiDetection';
import {
@@ -7,6 +7,10 @@ import {
compressThumbnail,
} from '../services/captureLibraryStorage';
/**
* Auto-detecte PNG vs JPEG depuis le contenu base64 et retourne un data: URL.
* GARDER CETTE FONCTION : elle evite les bugs d'affichage PNG/JPEG.
*/
function b64ImgSrc(base64: string): string {
if (base64.startsWith('data:')) return base64;
const mime = base64.startsWith('/9j/') ? 'image/jpeg' : 'image/png';
@@ -53,7 +57,7 @@ export default function CapturePanel({
const [currentCapture, setCurrentCapture] = useState<Capture | null>(null);
const [timerSeconds, setTimerSeconds] = useState(0);
const [countdown, setCountdown] = useState<number | null>(null);
// Éléments détectés sur l'aperçu miniature
// Elements detectes sur l'apercu miniature
const [previewElements, setPreviewElements] = useState<UIElement[]>([]);
const [isDetectingPreview, setIsDetectingPreview] = useState(false);
const previewImgRef = useRef<HTMLImageElement>(null);
@@ -61,8 +65,7 @@ export default function CapturePanel({
const isDebugMode = executionMode === 'debug';
// Charger la bibliothèque depuis le backend (prioritaire), fallback localStorage.
// Le helper loadLibraryAsync() migre aussi les données de l'ancienne clé 'captureLibrary'.
// Charger la bibliotheque depuis le backend (prioritaire), fallback localStorage.
useEffect(() => {
loadLibraryAsync().then((loaded) => {
setLibrary(
@@ -77,12 +80,12 @@ export default function CapturePanel({
});
}, []);
// Sauvegarder la bibliothèque (localStorage + gestion de quota)
// Sauvegarder la bibliotheque (localStorage + backend)
useEffect(() => {
saveLibrary(library);
}, [library]);
// Ajouter capture à la bibliothèque (thumbnail compressé JPEG 320x240)
// Ajouter capture a la bibliotheque (thumbnail compresse JPEG 320x240)
useEffect(() => {
if (!capture) return;
setCurrentCapture(capture);
@@ -101,7 +104,7 @@ export default function CapturePanel({
return () => { cancelled = true; };
}, [capture]);
// Détecter les éléments UI quand une capture arrive
// Detecter les elements UI quand une capture arrive
useEffect(() => {
if (!currentCapture) {
setPreviewElements([]);
@@ -118,7 +121,7 @@ export default function CapturePanel({
);
setPreviewElements(result.elements);
} catch (err) {
console.error('Détection aperçu échouée:', err);
console.error('Detection apercu echouee:', err);
setPreviewElements([]);
} finally {
setIsDetectingPreview(false);
@@ -128,7 +131,7 @@ export default function CapturePanel({
runDetection();
}, [currentCapture]);
// Calculer le scale de l'aperçu quand l'image est chargée
// Calculer le scale de l'apercu quand l'image est chargee
const handlePreviewImageLoad = () => {
if (previewImgRef.current) {
setPreviewScale({
@@ -153,7 +156,6 @@ export default function CapturePanel({
} as any);
return;
}
// Agent indisponible — fallback capture locale
console.warn('Agent Windows indisponible, fallback local:', data.error);
} catch (err) {
console.warn('Erreur capture Windows, fallback local:', err);
@@ -185,7 +187,7 @@ export default function CapturePanel({
const handleLibrarySelect = (item: LibraryItem) => {
setCurrentCapture(item.capture);
setIsFullscreen(true); // Ouvrir en plein écran pour sélectionner
setIsFullscreen(true);
};
const handleDeleteLibraryItem = (id: string) => {
@@ -196,13 +198,13 @@ export default function CapturePanel({
<div className="capture-panel">
<h3>Capture</h3>
{/* Capture — auto-détection OS navigateur */}
{/* Capture — auto-detection OS navigateur */}
<div className="capture-controls">
<button disabled={countdown !== null} onClick={doSmartCapture}>
📸 Capturer
Capturer
</button>
<select value={timerSeconds} onChange={(e) => setTimerSeconds(Number(e.target.value))}>
<option value="0">Immédiat</option>
<option value="0">Immediat</option>
<option value="3">3 sec</option>
<option value="5">5 sec</option>
<option value="10">10 sec</option>
@@ -212,7 +214,7 @@ export default function CapturePanel({
</button>
</div>
{/* Aperçu de la capture avec détection */}
{/* Apercu de la capture avec detection */}
{currentCapture && (
<div className="capture-preview">
<div className="capture-preview-container">
@@ -223,7 +225,7 @@ export default function CapturePanel({
onClick={() => setIsFullscreen(true)}
onLoad={handlePreviewImageLoad}
/>
{/* Bounding boxes des éléments détectés sur l'aperçu miniature */}
{/* Bounding boxes des elements detectes sur l'apercu miniature */}
{previewElements.map((elem) => (
<div
key={elem.id}
@@ -239,43 +241,43 @@ export default function CapturePanel({
/>
))}
{isDetectingPreview && (
<div className="capture-preview-detecting">Détection...</div>
<div className="capture-preview-detecting">Detection...</div>
)}
</div>
<p className="capture-info">
{currentCapture.width}x{currentCapture.height}
{previewElements.length > 0 && (
<span className="capture-elements-count">
{previewElements.length} éments
{previewElements.length} elements
</span>
)}
<button onClick={() => setIsFullscreen(true)}>Plein écran</button>
<button onClick={() => setIsFullscreen(true)}>Plein ecran</button>
</p>
</div>
)}
{!hasSelectedStep && currentCapture && (
<p className="capture-hint">Sélectionnez une étape pour définir l'ancre</p>
<p className="capture-hint">Selectionnez une etape pour definir l'ancre</p>
)}
{/* Zone de détection */}
{/* Zone de detection */}
{detectionZone && (
<div className="detection-zone-info">
<span>Zone de détection: {detectionZone.width}x{detectionZone.height}</span>
<span>Zone de detection: {detectionZone.width}x{detectionZone.height}</span>
<button onClick={() => onSetDetectionZone?.(null)}>Effacer</button>
</div>
)}
{!detectionZone && currentCapture && (executionMode === 'intelligent' || executionMode === 'debug') && (
<p className="capture-hint zone-hint">
Cliquez sur "Plein écran" puis "Zone de détection" pour cibler une zone
Cliquez sur "Plein ecran" puis "Zone de detection" pour cibler une zone
</p>
)}
{/* Bibliothèque */}
{/* Bibliotheque */}
<div className="capture-library">
<h4 style={{ cursor: 'pointer' }} onClick={() => library.length > 0 && setShowLibraryGallery(true)}>
Bibliothèque ({library.length})
{library.length > 0 && <span style={{ fontSize: '11px', marginLeft: '8px', color: 'var(--primary)' }}>Ouvrir</span>}
Bibliotheque ({library.length})
{library.length > 0 && <span style={{ fontSize: '11px', marginLeft: '8px', color: 'var(--primary)' }}>Ouvrir</span>}
</h4>
<div className="library-grid">
{library.slice(0, 4).map(item => (
@@ -289,7 +291,7 @@ export default function CapturePanel({
className="delete-btn"
onClick={(e) => { e.stopPropagation(); handleDeleteLibraryItem(item.id); }}
>
×
x
</button>
</div>
))}
@@ -304,12 +306,12 @@ export default function CapturePanel({
</div>
</div>
{/* Galerie plein écran de la bibliothèque */}
{/* Galerie plein ecran de la bibliotheque */}
{showLibraryGallery && (
<div className="fullscreen-modal">
<div className="fullscreen-header">
<span>Bibliothèque {library.length} captures Cliquez pour sélectionner</span>
<button onClick={() => setShowLibraryGallery(false)}>Fermer (Échap)</button>
<span>Bibliotheque -- {library.length} captures -- Cliquez pour selectionner</span>
<button onClick={() => setShowLibraryGallery(false)}>Fermer (Echap)</button>
</div>
<div className="library-gallery-grid">
{library.map(item => (
@@ -334,7 +336,7 @@ export default function CapturePanel({
</div>
)}
{/* Modal plein écran */}
{/* Modal plein ecran */}
{isFullscreen && currentCapture && (
<FullscreenSelector
capture={currentCapture}
@@ -353,7 +355,24 @@ export default function CapturePanel({
);
}
// Composant sélecteur plein écran
// ============================================================================
// FullscreenSelector — composant refonde pour stabilite definitive
//
// Architecture :
// .fullscreen-modal (fixed, flex column)
// .fullscreen-header (bar d'outils)
// .fullscreen-content (flex: 1, flex center, overflow hidden, position relative)
// <img> (max-width/max-height 100%, object-fit contain)
// <div overlay> (position absolute, memes dimensions que l'image via imageRect)
// bboxes detection (position absolute, coords = naturel * scale)
// zone detection (position absolute, coords = naturel * scale)
// selection utilisateur (position absolute, coords relatives a l'image affichee)
//
// PRINCIPE CLE : l'overlay est positionne exactement sur l'image via imageRect.
// Tous les overlays sont enfants de cet overlay, positionnes relativement a lui.
// Pas de calcul d'offset disperse — tout passe par imageRect.
// ============================================================================
function FullscreenSelector({
capture,
onClose,
@@ -372,17 +391,24 @@ function FullscreenSelector({
onSetDetectionZone?: (zone: DetectionZone | null) => void;
}) {
const imgRef = useRef<HTMLImageElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [isSelecting, setIsSelecting] = useState(false);
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
const [selection, setSelection] = useState({ x: 0, y: 0, width: 0, height: 0 });
const [detectedElements, setDetectedElements] = useState<UIElement[]>([]);
const [isDetecting, setIsDetecting] = useState(false);
const [imageScale, setImageScale] = useState({ x: 1, y: 1 });
const [selectionMode, setSelectionMode] = useState<'anchor' | 'zone'>('anchor');
const [showDetection, setShowDetection] = useState(true);
// Lancer la détection automatiquement (tous modes)
// imageRect: position et dimensions de l'image AFFICHEE dans le conteneur
// Calcule par getBoundingClientRect() de l'image relativement au conteneur parent
const [imageRect, setImageRect] = useState({ left: 0, top: 0, width: 0, height: 0 });
// Scale de l'image affichee par rapport a l'image naturelle
const [imageScale, setImageScale] = useState({ x: 1, y: 1 });
// ── Detection automatique des elements UI ──
useEffect(() => {
const runDetection = async () => {
setIsDetecting(true);
@@ -394,7 +420,7 @@ function FullscreenSelector({
);
setDetectedElements(result.elements);
} catch (err) {
console.error('Erreur tection:', err);
console.error('Erreur detection:', err);
} finally {
setIsDetecting(false);
}
@@ -403,17 +429,38 @@ function FullscreenSelector({
runDetection();
}, [capture.screenshot_base64]);
// Calculer le scale quand l'image est chargée
const handleImageLoad = () => {
if (imgRef.current) {
setImageScale({
x: imgRef.current.width / imgRef.current.naturalWidth,
y: imgRef.current.height / imgRef.current.naturalHeight
// ── Calcul de imageRect et imageScale ──
// Appele au chargement de l'image ET au redimensionnement de la fenetre.
const recalcImageRect = useCallback(() => {
if (!imgRef.current || !contentRef.current) return;
const containerRect = contentRef.current.getBoundingClientRect();
const imgBounds = imgRef.current.getBoundingClientRect();
setImageRect({
left: imgBounds.left - containerRect.left,
top: imgBounds.top - containerRect.top,
width: imgBounds.width,
height: imgBounds.height,
});
}
setImageScale({
x: imgBounds.width / imgRef.current.naturalWidth,
y: imgBounds.height / imgRef.current.naturalHeight,
});
}, []);
const handleImageLoad = () => {
recalcImageRect();
};
// Cliquer sur un élément détecté
// Recalculer sur resize
useEffect(() => {
window.addEventListener('resize', recalcImageRect);
return () => window.removeEventListener('resize', recalcImageRect);
}, [recalcImageRect]);
// ── Clic sur un element detecte ──
const handleElementClick = (elem: UIElement) => {
if (!enabled) return;
const bbox = {
@@ -425,6 +472,7 @@ function FullscreenSelector({
onSelect(bbox);
};
// ── Fermeture par Echap ──
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
@@ -433,35 +481,48 @@ function FullscreenSelector({
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const handleMouseDown = (e: React.MouseEvent) => {
// Permettre le dessin en mode zone même sans étape sélectionnée
const canDraw = selectionMode === 'zone' || enabled;
if (!canDraw || !imgRef.current) return;
// ── Gestion de la selection par dessin (souris) ──
// Toutes les coordonnees sont relatives a l'image AFFICHEE (pas au conteneur).
// On les convertit en coordonnees image naturelle au mouseUp.
const rect = imgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const getImageRelativePos = (e: React.MouseEvent): { x: number; y: number } | null => {
if (!imgRef.current) return null;
const imgBounds = imgRef.current.getBoundingClientRect();
const x = e.clientX - imgBounds.left;
const y = e.clientY - imgBounds.top;
// Limiter aux bornes de l'image
return {
x: Math.max(0, Math.min(x, imgBounds.width)),
y: Math.max(0, Math.min(y, imgBounds.height)),
};
};
const handleMouseDown = (e: React.MouseEvent) => {
const canDraw = selectionMode === 'zone' || enabled;
if (!canDraw) return;
const pos = getImageRelativePos(e);
if (!pos) return;
setIsSelecting(true);
setStartPos({ x, y });
setSelection({ x, y, width: 0, height: 0 });
setStartPos(pos);
setSelection({ x: pos.x, y: pos.y, width: 0, height: 0 });
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isSelecting || !imgRef.current) return;
if (!isSelecting) return;
const rect = imgRef.current.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const pos = getImageRelativePos(e);
if (!pos) return;
const width = currentX - startPos.x;
const height = currentY - startPos.y;
const width = pos.x - startPos.x;
const height = pos.y - startPos.y;
setSelection({
x: width < 0 ? currentX : startPos.x,
y: height < 0 ? currentY : startPos.y,
x: width < 0 ? pos.x : startPos.x,
y: height < 0 ? pos.y : startPos.y,
width: Math.abs(width),
height: Math.abs(height)
height: Math.abs(height),
});
};
@@ -474,24 +535,22 @@ function FullscreenSelector({
return;
}
// Convertir en coordonnées réelles de l'image
const scaleX = imgRef.current.naturalWidth / imgRef.current.width;
const scaleY = imgRef.current.naturalHeight / imgRef.current.height;
// Convertir en coordonnees naturelles de l'image
const scaleX = imgRef.current.naturalWidth / imgRef.current.getBoundingClientRect().width;
const scaleY = imgRef.current.naturalHeight / imgRef.current.getBoundingClientRect().height;
const realBbox = {
x: Math.round(selection.x * scaleX),
y: Math.round(selection.y * scaleY),
width: Math.round(selection.width * scaleX),
height: Math.round(selection.height * scaleY)
height: Math.round(selection.height * scaleY),
};
if (selectionMode === 'zone' && onSetDetectionZone) {
// Mode zone de détection - fonctionne même sans étape sélectionnée
onSetDetectionZone(realBbox);
setSelectionMode('anchor'); // Revenir au mode ancre
setSelectionMode('anchor');
setSelection({ x: 0, y: 0, width: 0, height: 0 });
} else if (enabled) {
// Mode ancre normal - nécessite une étape sélectionnée
onSelect(realBbox);
}
};
@@ -501,21 +560,20 @@ function FullscreenSelector({
<div className="fullscreen-header">
<div className="header-left">
<span>
{isDetecting && 'Détection en cours... '}
{!isDetecting && detectedElements.length > 0 && `${detectedElements.length} éléments - `}
{isDetecting && 'Detection en cours... '}
{!isDetecting && detectedElements.length > 0 && `${detectedElements.length} elements - `}
{selectionMode === 'zone'
? 'Dessinez la zone de détection'
: (enabled ? 'Dessinez un rectangle pour l\'ancre ou cliquez sur un élément détecté' : 'Sélectionnez d\'abord une étape')}
? 'Dessinez la zone de detection'
: (enabled ? 'Dessinez un rectangle pour l\'ancre ou cliquez sur un element detecte' : 'Selectionnez d\'abord une etape')}
</span>
</div>
<div className="header-center">
{/* Bouton afficher/masquer la détection */}
<button
className={`zone-select-btn ${showDetection ? 'active' : ''}`}
onClick={() => setShowDetection(!showDetection)}
style={showDetection ? { background: 'var(--secondary)', borderColor: 'var(--secondary)', color: 'white' } : {}}
>
{showDetection ? 'Masquer détection' : 'Afficher détection'}
{showDetection ? 'Masquer detection' : 'Afficher detection'}
</button>
{onSetDetectionZone && (
<>
@@ -523,7 +581,7 @@ function FullscreenSelector({
className={`zone-select-btn ${selectionMode === 'zone' ? 'active' : ''}`}
onClick={() => setSelectionMode(selectionMode === 'zone' ? 'anchor' : 'zone')}
>
{selectionMode === 'zone' ? 'Annuler' : 'Zone de détection'}
{selectionMode === 'zone' ? 'Annuler' : 'Zone de detection'}
</button>
{detectionZone && (
<button
@@ -536,26 +594,40 @@ function FullscreenSelector({
</>
)}
</div>
<button onClick={onClose}>Fermer (Échap)</button>
<button onClick={onClose}>Fermer (Echap)</button>
</div>
{/* Zone de contenu : flex center, l'image se dimensionne via max-width/max-height */}
<div
className="fullscreen-content"
ref={contentRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{/* Conteneur relatif pour positionner les bboxes et la sélection par rapport à l'image */}
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
<img
ref={imgRef}
src={b64ImgSrc(capture.screenshot_base64)}
alt="Capture plein écran"
alt="Capture plein ecran"
draggable={false}
onLoad={handleImageLoad}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
{/* Overlay des éléments détectés — visible dans tous les modes */}
{/* Overlay : positionne exactement sur l'image via imageRect.
Tous les enfants sont positionnes relativement a ce div,
donc les coordonnees = coords_naturelles * imageScale. */}
<div
style={{
position: 'absolute',
left: imageRect.left,
top: imageRect.top,
width: imageRect.width,
height: imageRect.height,
pointerEvents: 'none',
}}
>
{/* Bboxes des elements detectes */}
{showDetection && detectedElements.map((elem) => (
<div
key={elem.id}
@@ -569,6 +641,7 @@ function FullscreenSelector({
border: '2px solid #e94560',
background: 'rgba(233, 69, 96, 0.15)',
cursor: enabled ? 'pointer' : 'default',
pointerEvents: 'auto',
zIndex: 10,
}}
onClick={(e) => {
@@ -593,8 +666,8 @@ function FullscreenSelector({
</div>
))}
{/* Zone de détection existante */}
{detectionZone && imgRef.current && (
{/* Zone de detection existante */}
{detectionZone && (
<div
className="detection-zone-overlay"
style={{
@@ -606,7 +679,7 @@ function FullscreenSelector({
border: '3px solid #4caf50',
background: 'rgba(76, 175, 80, 0.15)',
pointerEvents: 'none',
zIndex: 5
zIndex: 5,
}}
>
<span style={{
@@ -618,24 +691,23 @@ function FullscreenSelector({
padding: '2px 8px',
borderRadius: '3px',
fontSize: '11px',
fontWeight: 'bold'
fontWeight: 'bold',
}}>
Zone de détection
Zone de detection
</span>
</div>
)}
{/* Rectangle de sélection manuelle */}
{/* Rectangle de selection manuelle */}
{(isSelecting || selection.width > 0) && (
<div
ref={overlayRef}
className={`selection-overlay ${selectionMode === 'zone' ? 'zone-mode' : ''}`}
style={{
position: 'absolute',
left: selection.x,
top: selection.y,
width: selection.width,
height: selection.height
height: selection.height,
}}
/>
)}