PageSourceSearch

https://teslacamviewer.com/js/plateEnhancer.js?v=93

js teslacamviewer.com collected 2026-09-25 22:34:56 UTC 198,524 bytes, 4,555 lines download raw bytes

1/**
2 * PlateEnhancer - Multi-frame license plate enhancement
3 * Combines frames from multiple cameras and time points to enhance license plate readability
4 */
5class PlateEnhancer {
6    constructor() {
7        // Initialize enhancement pipeline - tcv.0x504C45
8        this.isOpenCVLoaded = false;
9        this.isLoadingOpenCV = false;
10        this.selections = new Map(); // cameraId -> { x, y, width, height }
11        this.activeCamera = null;
12        this.isSelecting = false;
13        this.contextMenu = null;
14        this.overlays = new Map();
15
16        // Processing state
17        this.isProcessing = false;
18        this.shouldCancel = false;
19
20        // Interaction state
21        this.isDraggingOrResizing = false;
22
23        // OCR state
24        this.isTesseractLoaded = false;
25        this.isLoadingTesseract = false;
26        this.tesseractWorker = null;
27
28        // Re-processing state (stores last extraction for manual frame selection/region adjustment)
29        this.lastExtraction = null; // { regions, frames, settings }
30        this.lastEnhanced = null;   // Result from last processing
31        this.originalEnhanced = null; // Original result before any reprocessing (never overwritten)
32
33        // Plate cycling state (for multi-camera detection workflow)
34        this.cyclingState = {
35            isActive: false,
36            detections: [],      // All detected plates: { cameraId, bbox, confidence, videoDims }
37            currentIndex: 0,     // Which plate is currently shown
38            adjustedBbox: null   // User's adjusted bounding box
39        };
40        this.cyclingOverlay = null;
41        this.handleCyclingKeyDown = this.handleCyclingKeyDown.bind(this);
42
43        // Timeframe preference
44        this.preferredTimeframe = parseInt(localStorage.getItem('tcv_plate_timeframe') || '5');
45
46        // Apply video enhancements to source frames before processing
47        this.applyVideoEnhancementsToSource = localStorage.getItem('tcv_plate_apply_video_enhancements') === 'true';
48
49        // Bounding box padding (percentage of bbox size to add on each side)
50        // Lower = tighter crop = better OCR, but less margin for tracking errors
51        this.bboxPadding = 0.10; // 10% padding on each side = 120% total size
52
53        // Bind methods
54        this.handleContextMenu = this.handleContextMenu.bind(this);
55        this.handleDocumentClick = this.handleDocumentClick.bind(this);
56        this.handleKeyDown = this.handleKeyDown.bind(this);
57    }
58
59    /**
60     * Initialize the plate enhancer - call after DOM ready
61     */
62    init() {
63        this.attachContextMenuListeners();
64        console.log('[PlateEnhancer] Initialized');
65    }
66
67    /**
68     * Update the enhance region button active state
69     * @param {boolean} active - Whether the mode is active
70     */
71    setButtonActive(active) {
72        const btn = document.getElementById('enhanceRegionBtn');
73        if (btn) {
74            if (active) {
75                btn.classList.add('active');
76            } else {
77                btn.classList.remove('active');
78            }
79        }
80    }
81
82    /**
83     * Start enhance region mode from button click
84     * Goes directly to auto-detect like 'D' key (no camera selection prompt)
85     */
86    startEnhanceRegionMode() {
87        // Don't start if already processing or selecting
88        if (this.isProcessing) {
89            this.showToast('Enhancement in progress - please wait or cancel first');
90            return;
91        }
92        if (this.isSelecting) {
93            this.showToast('Finish or cancel current selection first (Enter/Escape)');
94            return;
95        }
96        if (this.selections.size > 0) {
97            this.showToast('Process or clear existing selection first');
98            return;
99        }
100
101        // Go directly to auto-detect (same as 'D' key)
102        this.autoDetectPlates();
103    }
104
105    /**
106     * Show overlay prompting user to click on a camera
107     */
108    showCameraSelectionPrompt() {
109        // Create overlay
110        const overlay = document.createElement('div');
111        overlay.className = 'enhance-region-camera-prompt';
112        overlay.innerHTML = `
113            <div class="enhance-region-prompt-message">
114                Click on a camera to start region selection
115                <div class="enhance-region-prompt-hint">Press Escape to cancel</div>
116            </div>
117        `;
118
119        // Add click handlers to video containers
120        const videoContainers = document.querySelectorAll('.video-container');
121        const clickHandlers = [];
122
123        videoContainers.forEach(container => {
124            // Skip hidden cameras
125            if (container.classList.contains('camera-hidden')) return;
126            const computedStyle = window.getComputedStyle(container);
127            if (computedStyle.display === 'none') return;
128
129            // Get camera ID
130            const cameraId = this.getCameraIdFromContainer(container);
131            if (!cameraId) return;
132
133            // Check if video is loaded
134            const video = container.querySelector('video, canvas.video-player');
135            if (!video || !video.src || video.readyState < 2) return;
136
137            // Add highlight on hover
138            container.classList.add('enhance-region-camera-selectable');
139
140            const handler = (e) => {
141                e.preventDefault();
142                e.stopPropagation();
143                this.removeCameraSelectionPrompt(overlay, videoContainers, clickHandlers, escHandler);
144                this.startSelection(cameraId);
145            };
146            container.addEventListener('click', handler);
147            clickHandlers.push({ container, handler });
148        });
149
150        // Escape key handler
151        const escHandler = (e) => {
152            if (e.key === 'Escape') {
153                this.removeCameraSelectionPrompt(overlay, videoContainers, clickHandlers, escHandler);
154            }
155        };
156        document.addEventListener('keydown', escHandler);
157
158        document.body.appendChild(overlay);
159    }
160
161    /**
162     * Remove camera selection prompt
163     */
164    removeCameraSelectionPrompt(overlay, containers, handlers, escHandler) {
165        overlay.remove();
166        handlers.forEach(({ container, handler }) => {
167            container.removeEventListener('click', handler);
168            container.classList.remove('enhance-region-camera-selectable');
169        });
170        containers.forEach(c => c.classList.remove('enhance-region-camera-selectable'));
171        document.removeEventListener('keydown', escHandler);
172    }
173
174    /**
175     * Attach right-click context menu to video containers and video elements
176     */
177    attachContextMenuListeners() {
178        const videoContainers = document.querySelectorAll('.video-container');
179        videoContainers.forEach(container => {
180            container.addEventListener('contextmenu', this.handleContextMenu);
181
182            // Also attach to the video element itself to override browser's default menu
183            const video = container.querySelector('video, canvas.video-player');
184            if (video) {
185                video.addEventListener('contextmenu', (e) => {
186                    e.preventDefault();
187                    e.stopPropagation();
188                    // Create a synthetic event targeting the container
189                    this.handleContextMenu.call(this, {
190                        preventDefault: () => {},
191                        currentTarget: container,
192                        clientX: e.clientX,
193                        clientY: e.clientY
194                    });
195                });
196            }
197        });
198    }
199
200    /**
201     * Handle right-click on video container
202     */
203    handleContextMenu(e) {
204        e.preventDefault();
205
206        // Don't show context menu while processing is active
207        if (this.isProcessing) {
208            this.showToast('Enhancement in progress - please wait or cancel first');
209            return;
210        }
211
212        // Don't show context menu while selecting a region
213        if (this.isSelecting) {
214            this.showToast('Finish or cancel current selection first (Enter/Escape)');
215            return;
216        }
217
218        // Don't show context menu if there's already a selection - must process or clear it first
219        if (this.selections.size > 0) {
220            this.showToast('Process or clear existing selection first');
221            return;
222        }
223
224        // Don't show if no video is loaded
225        const video = e.currentTarget.querySelector('video, canvas.video-player');
226        if (!video || !video.src || video.readyState < 2) {
227            return;
228        }
229
230        // Don't show for hidden cameras (hidden by layout manager or CSS)
231        const container = e.currentTarget;
232        if (container.classList.contains('camera-hidden')) {
233            return;
234        }
235        const computedStyle = window.getComputedStyle(container);
236        if (computedStyle.display === 'none') {
237            return;
238        }
239
240        // Remove any existing context menu
241        this.removeContextMenu();
242
243        // Get camera ID from the container
244        const cameraId = this.getCameraIdFromContainer(e.currentTarget);
245
246        // Create and show context menu
247        this.contextMenu = this.createContextMenu(e.clientX, e.clientY, cameraId);
248        document.body.appendChild(this.contextMenu);
249
250        // Listen for clicks outside to close
251        setTimeout(() => {
252            document.addEventListener('click', this.handleDocumentClick);
253        }, 0);
254    }
255
256    /**
257     * Get camera ID from video container
258     */
259    getCameraIdFromContainer(container) {
260        const video = container.querySelector('video, canvas.video-player');
261        if (!video) return null;
262
263        const id = video.id;
264        const mapping = {
265            'videoFront': 'front',
266            'videoBack': 'back',
267            'videoLeft': 'left_repeater',
268            'videoRight': 'right_repeater',
269            'videoLeftPillar': 'left_pillar',
270            'videoRightPillar': 'right_pillar'
271        };
272        return mapping[id] || null;
273    }
274
275    /**
276     * Create context menu element
277     */
278    createContextMenu(x, y, cameraId) {
279        const menu = document.createElement('div');
280        menu.className = 'plate-enhancer-context-menu';
281        // Build menu items conditionally — AI Search option a
281ppears only if
282        // the user has enabled AI Search for the current library.
283        const aiEnabled = !!window.aiSearch?.state?.enabled;
284        const currentEvent = window.app?.eventBrowser?.selectedEvent;
285        const aiHtml = (aiEnabled && currentEvent)
286            ? `<div class="plate-enhancer-context-menu-item" data-action="ai-deep-index">
287                   <svg viewBox="0 0 24 24"><path d="M13 3L4 14h7l-1 7 9-11h-7l1-7z"/></svg>
288                   <span>Deep-index for AI Search</span>
289               </div>`
290            : '';
291        menu.innerHTML = `
292            <div class="plate-enhancer-context-menu-item" data-action="enhance">
293                <svg viewBox="0 0 24 24">
294                    <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
295                    <path d="M12 10h-2v2H9v-2H7V9h2V7h1v2h2v1z"/>
296                </svg>
297                <span>Enhance Region...</span>
298            </div>
299            ${aiHtml}
300        `;
301
302        // Position menu
303        menu.style.left = `${x}px`;
304        menu.style.top = `${y}px`;
305
306        // Adjust if off-screen
307        setTimeout(() => {
308            const rect = menu.getBoundingClientRect();
309            if (rect.right > window.innerWidth) {
310                menu.style.left = `${window.innerWidth - rect.width - 8}px`;
311            }
312            if (rect.bottom > window.innerHeight) {
313                menu.style.top = `${window.innerHeight - rect.height - 8}px`;
314            }
315        }, 0);
316
317        // Handle menu item click
318        menu.querySelector('[data-action="enhance"]').addEventListener('click', () => {
319            this.removeContextMenu();
320            this.startSelection(cameraId);
321        });
322        // Wire AI deep-index if present
323        const aiItem = menu.querySelector('[data-action="ai-deep-index"]');
324        if (aiItem) {
325            aiItem.addEventListener('click', async () => {
326                this.removeContextMenu();
327                const ev = window.app?.eventBrowser?.selectedEvent;
328                if (!ev || !window.aiSearchUI) return;
329                await window.aiSearchUI.runDeepIndex(ev.name, null);
330            });
331        }
332
333        return menu;
334    }
335
336    /**
337     * Remove context menu
338     */
339    removeContextMenu() {
340        if (this.contextMenu) {
341            this.contextMenu.remove();
342            this.contextMenu = null;
343        }
344        document.removeEventListener('click', this.handleDocumentClick);
345    }
346
347    /**
348     * Handle clicks outside context menu
349     */
350    handleDocumentClick(e) {
351        if (this.contextMenu && !this.contextMenu.contains(e.target)) {
352            this.removeContextMenu();
353        }
354    }
355
356    /**
357     * Start region selection mode
358     */
359    async startSelection(cameraId) {
360        // Pause video if playing
361        const videoPlayer = window.app?.videoPlayer;
362        if (videoPlayer && videoPlayer.isPlaying) {
363            await videoPlayer.pause();
364        }
365
366        // Load OpenCV if needed
367        if (!this.isOpenCVLoaded) {
368            const loaded = await this.loadOpenCV();
369            if (!loaded) {
370                this.showError('Failed to load image processing library. Please check your internet connection.');
371                return;
372            }
373        }
374
375        this.isSelecting = true;
376        this.activeCamera = cameraId;
377        this.selections.clear();
378
379        // Highlight button to show mode is active
380        this.setButtonActive(true);
381
382        // Add selection overlay to all video containers
383        this.createSelectionOverlays();
384
385        // Listen for keyboard
386        document.addEventListener('keydown', this.handleKeyDown);
387    }
388
389    /**
390     * Create selection overlays on all visible video containers
391     */
392    createSelectionOverlays() {
393        const videoContainers = document.querySelectorAll('.video-container');
394
395        videoContainers.forEach(container => {
396            const video = container.querySelector('video, canvas.video-player');
397            if (!video || !video.src) return;
398
399            // Skip cameras without loaded video (e.g., pillar cameras when event doesn't have them)
400            // readyState < 2 means video metadata not loaded yet
401            if (video.readyState < 2 || video.videoWidth === 0) return;
402
403            // Skip hidden cameras (hidden by layout manager class)
404            if (container.classList.contains('camera-hidden')) return;
405
406            // Check computed display style (catches CSS display: none from layout rules)
407            const computedStyle = window.getComputedStyle(container);
408            if (computedStyle.display === 'none') return;
409
410            // Also check if container is actually visible (has dimensions)
411            const rect = container.getBoundingClientRect();
412            if (rect.width === 0 || rect.height === 0) return;
413
414            const cameraId = this.getCameraIdFromContainer(container);
415            if (!cameraId) return;
416
417            const overlay = document.createElement('div');
418            overlay.className = 'plate-enhancer-overlay';
419            overlay.dataset.camera = cameraId;
420
421            // Add instruction panel on first (active) camera - append to body to avoid overflow clipping
422            if (cameraId === this.activeCamera) {
423                const instruction = document.createElement('div');
424                instruction.className = 'plate-enhancer-instruction';
425                instruction.innerHTML = `
426                    <div class="instruction-icon">🎯</div>
427                    <div class="instruction-title">Select License Plate Region</div>
428                    <div class="instruction-steps">
429                        <div class="instruction-step"><span class="step-num">1</span> Click on the license plate to place selection box</div>
430                        <div class="instruction-step"><span class="step-num">2</span> Drag corners/edges to resize the selection</div>
431                        <div class="instruction-step"><span class="step-num">3</span> Mark same plate on other camera angles (optional)</div>
432                    </div>
433                    <div class="instruction-auto-detect">
434                        <button class="auto-detect-btn" id="autoDetectPlatesBtn">
435                            <svg viewBox="0 0 24 24" width="16" height="16">
436                                <path fill="currentColor" d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z"/>
437                            </svg>
438                            Auto-Detect Plates
439                        </button>
440                        <span class="auto-detect-hint">AI finds plates automatically</span>
441                    </div>
442                    <div class="instruction-keys">
443                        <span><kbd>Enter</kbd> to continue</span>
444                        <span class="key-separator">•</span>
445                        <span><kbd>Esc</kbd> to cancel</span>
446                    </div>
447                `;
448
449                // Bind auto-detect button click
450                setTimeout(() => {
451                    const btn = document.getElementById('autoDetectPlatesBtn');
452                    if (btn) {
453                        btn.addEventListener('click', () => this.autoDetectPlates());
454                    }
455                }, 0);
456                // Append to sidebar for proper positioning within sidebar bounds
457                const sidebar = document.querySelector('.sidebar');
458                if (sidebar) {
459                    sidebar.appendChild(instruction);
460                } else {
461                    document.body.appendChild(instruction);
462                }
463                this.instructionElement = instruction;
464            }
465
466            // Handle click to create selection
467            overlay.addEventListener('click', (e) => this.handleOverlayClick(e, cameraId, container));
468
469            // Note: Don't set container.style.position - the CSS already handles this
470            // Base CSS has position: relative, and some layouts use position: absolute
471            container.appendChild(overlay);
472            this.overlays.set(cameraId, overlay);
473        });
474    }
475
476    /**
477     * Handle click on overlay to create/update selection
478     */
479    handleOverlayClick(e, cameraId, container) {
480        // Ignore if we just finished dragging/resizing
481        if (this.isDraggingOrResizing) {
482            return;
483        }
484
485        const overlay = this.overlays.get(cameraId);
486        if (!overlay) return;
487
488        const rect = container.getBoundingClientRect();
489        const video = container.querySelector('video, canvas.video-player');
490
491        // Calculate where the video actually displays within the container
492        // (accounting for object-fit: contain letterboxing)
493        const videoDisplay = this.getVideoDisplayRect(video, rect);
494
495        // Calculate click position relative to container
496        const clickX = e.clientX - rect.left;
497        const clickY = e.clientY - rect.top;
498
499        // Default selection size (will be resizable)
500        const defaultWidth = 120;
501        const defaultHeight = 50;
502
503        // Center selection on click
504        const selection = {
505            x: Math.max(0, clickX - defaultWidth / 2),
506            y: Math.max(0, clickY - defaultHeight / 2),
507            width: defaultWidth,
508            height: defaultHeight,
509            containerWidth: rect.width,
510            containerHeight: rect.height,
511            videoWidth: video.videoWidth,
512            videoHeight: video.videoHeight,
513            // Store video display info for accurate coordinate mapping
514            videoDisplayX: videoDisplay.x,
515            videoDisplayY: videoDisplay.y,
516            videoDisplayWidth: videoDisplay.width,
517            videoDisplayHeight: videoDisplay.height
518        };
519
520        // Constrain to container
521        if (selection.x + selection.width > rect.width) {
522            selection.x = rect.width - selection.width;
523        }
524        if (selection.y + selection.height > rect.height) {
525            selection.y = rect.height - selection.height;
526        }
527
528        console.log(`[PlateEnhancer] Selection created: click=(${clickX.toFixed(0)},${clickY.toFixed(0)}) box=(${selection.x.toFixed(0)},${selection.y.toFixed(0)} ${selection.width}x${selection.height}) container=${rect.width.toFixed(0)}x${rect.height.toFixed(0)} videoDisplay=(${videoDisplay.x.toFixed(0)},${videoDisplay.y.toFixed(0)} ${videoDisplay.width.toFixed(0)}x${videoDisplay.height.toFixed(0)}) video=${video.videoWidth}x${video.videoHeight}`);
529        this.selections.set(cameraId, selection);
530        this.renderSelection(cameraId, overlay, selection);
531    }
532
533    /**
534     * Calculate where the video actually displays within its container
535     * Accounts for object-fit: contain letterboxing
536     */
537    getVideoDisplayRect(video, containerRect) {
538        const videoAspect = video.videoWidth / video.videoHeight;
539        const containerAspect = containerRect.width / containerRect.height;
540
541        let displayWidth, displayHeight, displayX, displayY;
542
543        if (videoAspect > containerAspect) {
544            // Video is wider - letterbox top/bottom
545            displayWidth = containerRect.width;
546            displayHeight = containerRect.width / videoAspect;
547            displayX = 0;
548            displayY = (containerRect.height - displayHeight) / 2;
549        } else {
550            // Video is taller - letterbox left/right
551            displayHeight = containerRect.height;
552            displayWidth = containerRect.height * videoAspect;
553            displayX = (containerRect.width - displayWidth) / 2;
554            displayY = 0;
555        }
556
557        return {
558            x: displayX,
559            y: displayY,
560            width: displayWidth,
561            height: displayHeight
562        };
563    }
564
565    /**
566     * Auto-detect license plates using AI and enter cycling mode
567     * Scans ALL cameras and collects all detected plates
568     */
569    async autoDetectPlates() {
570        const btn = document.getElementById('autoDetectPlatesBtn');
571        if (btn) {
572            btn.disabled = true;
573            btn.innerHTML = `
574                <svg class="spinner" viewBox="0 0 24 24" width="16" height="16">
575                    <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="31.4 31.4" transform="rotate(-90 12 12)"/>
576                </svg>
577                Detecting...
578            `;
579        }
580
581        try {
582            // Guard: Don't start detection if cycling mode is active
583            if (this.cyclingState.isActive) {
584                console.log('[PlateEnhancer] Already in cycling mode');
585                return;
586            }
587
588            // Guard: Don't start if already processing
589            if (this.isProcessing) {
590                this.showToast('Enhancement in progress - please wait', 'info');
591                return;
592            }
593
594            // Ensure overlays exist (needed when called directly via 'D' key)
595            // Also set proper state when entering via 'D' key
596            if (!this.overlays || this.overlays.size === 0) {
597                // Set active camera before creating overlays (overlays check this)
598                if (!this.activeCamera) {
599                    this.activeCamera = 'front';
600                }
601                this.isSelecting = true;
602                this.selections.clear();
603                this.createSelectionOverlays();
604                // Add keyboard listener for Escape to cancel
605                document.addEventListener('keydown', this.handleKeyDown);
606                // Highlight button to show mode is active
607                this.setButtonActive(true);
608            }
609
610            // Initialize plate detector if not available
611            if (!window.plateDetector) {
612                window.plateDetector = new PlateDetector();
613            }
614
615            const detector = window.plateDetector;
616
617            // Load model if needed
618            if (!detector.isReady()) {
619                console.log('[PlateEnhancer] Loading plate detection model...');
620                const loaded = await detector.loadModel((progress) => {
621                    if (btn && progress.percent >= 0) {
622                        btn.innerHTML = `
623                            <svg class="spinner" viewBox="0 0 24 24" width="16" height="16">
624                                <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="31.4 31.4" transform="rotate(-90 12 12)"/>
625                            </svg>
626                            Loading AI... ${progress.percent}%
627                        `;
628                    }
629                });
630
631                if (!loaded) {
632                    this.showToast('Failed to load plate detection model', 'error');
633                    return;
634                }
635            }
636
637            // Run detection on ALL visible cameras and collect ALL detections
638            const allDetections = [];
639
640            for (const [cameraId, overlay] of this.overlays) {
641                const video = this.getVideoElement(cameraId);
642                if (!video || video.readyState < 2) continue;
643
644                // Run detection
645                const detections = await detector.detect(video);
646                console.log(`[PlateEnhancer] ${cameraId}: Found ${detections.length} plates`);
647
648                // Store each detection with camera info
649                for (const detection of detections) {
650                    const container = overlay.parentElement;
651                    const rect = container.getBoundingClientRect();
652                    const videoDisplay = this.getVideoDisplayRect(video, rect);
653
654                    allDetections.push({
655                        cameraId,
656                        bbox: {
657                            x: detection.x,
658                            y: detection.y,
659                            width: detection.width,
660                            height: detection.height
661                        },
662                        confidence: detection.confidence,
663                        videoDims: {
664                            width: video.videoWidth,
665                            height: video.videoHeight
666                        },
667                        containerDims: {
668                            width: rect.width,
669                            height: rect.height
670                        },
671                        videoDisplay: {
672                            x: videoDisplay.x,
673                            y: videoDisplay.y,
674                            width: videoDisplay.width,
675                            height: videoDisplay.height
676                        }
677                    });
678                }
679            }
680
681            // Sort by confidence (highest first)
682            allDetections.sort((a, b) => b.confidence - a.confidence);
683
684            if (allDetections.length === 0) {
685                // No plates detected - offer manual selection
686                this.showToast('No plates auto-detected. Click and drag to select a regi
686on manually.', 'info');
687
688                // If we're already in selection mode (overlays exist), just let user continue manually
689                // Don't create duplicate overlays or enter selection mode again
690                if (this.isSelecting && this.overlays && this.overlays.size > 0) {
691                    console.log('[PlateEnhancer] Already in selection mode, user can select manually');
692                    return;
693                }
694
695                // If called via 'D' key without being in selection mode, enter selection mode properly
696                // Get focused camera or default to front
697                const vp = window.app?.videoPlayer;
698                let focusedCameraId = null;
699
700                // Try to get focused video's camera ID
701                const focusedVideo = vp?.getFocusedVideo?.();
702                if (focusedVideo) {
703                    focusedCameraId = this.getCameraIdFromContainer(focusedVideo.parentElement);
704                }
705
706                // Default to front camera if no focus
707                if (!focusedCameraId) {
708                    focusedCameraId = 'front';
709                }
710
711                // Clean up any partial state before entering selection mode
712                this.removeOverlays();
713                this.selections.clear();
714                this.isSelecting = false;
715                this.activeCamera = null;
716                document.removeEventListener('keydown', this.handleKeyDown);
717
718                // Enter proper selection mode
719                await this.startSelection(focusedCameraId);
720                return;
721            }
722
723            // Remove instruction panel and overlays, reset selection state
724            if (this.instructionElement) {
725                this.instructionElement.remove();
726                this.instructionElement = null;
727            }
728            this.removeOverlays();
729            this.isSelecting = false;
730            this.activeCamera = null;
731            document.removeEventListener('keydown', this.handleKeyDown);
732
733            // Enter cycling mode - capture time NOW when plate was detected
734            const vp = window.app?.videoPlayer;
735            const detectionTime = vp?.getCurrentAbsoluteTime?.() ?? vp?.getCurrentTime?.() ?? 0;
736
737            this.cyclingState = {
738                isActive: true,
739                detections: allDetections,
740                currentIndex: 0,
741                adjustedBbox: null,
742                detectionTime: detectionTime  // Time when plate was detected
743            };
744
745            this.showPlateCyclingUI();
746            this.showToast(`Found ${allDetections.length} plate${allDetections.length > 1 ? 's' : ''} - use ← → to cycle`, 'success');
747
748        } catch (error) {
749            console.error('[PlateEnhancer] Auto-detect error:', error);
750            this.showToast('Detection failed: ' + error.message, 'error');
751        } finally {
752            // Reset button if still visible
753            if (btn) {
754                btn.disabled = false;
755                btn.innerHTML = `
756                    <svg viewBox="0 0 24 24" width="16" height="16">
757                        <path fill="currentColor" d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z"/>
758                    </svg>
759                    Auto-Detect Plates
760                `;
761            }
762        }
763    }
764
765    /**
766     * Show the plate cycling UI overlay
767     * Displays one plate at a time with navigation, adjustable bbox, and timeframe selection
768     */
769    showPlateCyclingUI() {
770        const { detections, currentIndex } = this.cyclingState;
771        if (detections.length === 0) return;
772
773        // Pause video to freeze the frame for accurate preview
774        const videoPlayer = window.app?.videoPlayer;
775        if (videoPlayer && videoPlayer.isPlaying) {
776            videoPlayer.pause();
777        }
778
779        // Remove existing overlay
780        if (this.cyclingOverlay) {
781            this.cyclingOverlay.remove();
782        }
783
784        const detection = detections[currentIndex];
785        const video = this.getVideoElement(detection.cameraId);
786        const container = video?.parentElement;
787        if (!container) return;
788
789        // Get available footage time
790        const vp = window.app?.videoPlayer;
791        const currentTime = vp?.getCurrentAbsoluteTime?.() ?? vp?.getCurrentTime?.() ?? 0;
792        // Use cached duration for synchronous UI (getTotalDuration is async)
793        let totalDuration = 600; // Default fallback
794        if (vp?.cachedClipDurations?.length > 0) {
795            const cached = vp.cachedClipDurations.reduce((a, b) => a + b, 0);
796            if (cached > 0) totalDuration = cached;
797        }
798        const maxBefore = Math.min(currentTime, 10);
799        const maxAfter = Math.min(totalDuration - currentTime, 10);
800
801        // Create the cycling overlay
802        this.cyclingOverlay = document.createElement('div');
803        this.cyclingOverlay.className = 'plate-cycling-overlay';
804        this.cyclingOverlay.innerHTML = `
805            <div class="plate-cycling-panel">
806                <div class="plate-cycling-header">
807                    ${detections.length > 1 ? `
808                    <button class="plate-cycling-nav-btn prev-btn" ${currentIndex === 0 ? 'disabled' : ''} title="Previous plate (←)">
809                        <svg viewBox="0 0 24 24" width="20" height="20">
809<path fill="currentColor" d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
810                    </button>` : ''}
811                    <div class="plate-cycling-info">
812                        <span class="plate-cycling-counter">${detection.isManual ? 'Manual Selection' : `Plate ${currentIndex + 1} of ${detections.length}`}</span>
813                        <span class="plate-cycling-camera">${this.getCameraDisplayName(detection.cameraId)}</span>
814                        <span class="plate-cycling-confidence">${detection.isManual ? '' : `Confidence: ${Math.round(detection.confidence * 100)}%`}</span>
815                    </div>
816                    ${detections.length > 1 ? `
817                    <button class="plate-cycling-nav-btn next-btn" ${currentIndex === detections.length - 1 ? 'disabled' : ''} title="Next plate (→)">
818                        <svg viewBox="0 0 24 24" width="20" height="20"><path fill="currentColor" d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z"/></svg>
819                    </button>` : ''}
820                </div>
821
822                <div class="plate-cycling-video-container" data-camera="${detection.cameraId}">
823                    <!-- Video preview with bounding box will be inserted here -->
824                </div>
825
826                <div class="plate-cycling-timeframe">
827                    <div class="plate-cycling-timeframe-label">Analysis Timeframe:</div>
828                    <div class="plate-cycling-timeframe-btns">
829                        <button class="plate-cycling-timeframe-btn ${this.preferredTimeframe === 2 ? 'selected' : ''}" data-seconds="2" ${maxBefore < 1 && maxAfter < 1 ? 'disabled' : ''}>
830                            ±2s
831                            <span class="timeframe-hint">Quick</span>
832                        </button>
833                        <button class="plate-cycling-timeframe-btn ${this.preferredTimeframe === 5 ? 'selected' : ''}" data-seconds="5" ${maxBefore < 2.5 && maxAfter < 2.5 ? 'disabled' : ''}>
834                            ±5s
835                            <span class="timeframe-hint">Recommended</span>
836                        </button>
837                        <button class="plate-cycling-timeframe-btn ${this.preferredTimeframe === 10 ? 'selected' : ''}" data-seconds="10" ${maxBefore < 5 && maxAfter < 5 ? 'disabled' : ''}>
838                            ±10s
839                            <span class="timeframe-hint">Thorough</span>
840                        </button>
841                    </div>
842                    <div class="plate-cycling-available">Available: ${maxBefore.toFixed(1)}s before, ${maxAfter.toFixed(1)}s after</div>
843                </div>
844
845                <div class="plate-cycling-option">
846                    <label class="plate-cycling-checkbox">
847                        <input type="checkbox" id="apply-video-enhancements" ${this.applyVideoEnhancementsToSource ? 'checked' : ''}>
848                        <span class="checkmark"></span>
849                        Apply Video Enhancements to source frames
850                    </label>
851                    <span class="plate-cycling-option-hint">Use your brightness/contrast/saturation settings</span>
852                </div>
853
854                <div class="plate-cycling-size-warning" style="display: none;">
855                    <!-- Size warning will be inserted here dynamically -->
856                </div>
857
858                <div class="plate-cycling-actions">
859                    <button class="plate-cycling-btn cancel-btn">Cancel</button>
860                    <button class="plate-cycling-btn enhance-btn primary">
861                        <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z M12 10h-2v2H9v-2H7V9h2V7h1v2h2v1z"/></svg>
862                        Enhance This Plate
863                    </button>
864                </div>
865
866                <div class="plate-cycling-shortcuts">
867                    ${detections.length > 1 ? '<kbd>←</kbd> <kbd>→</kbd> Navigate &nbsp;•&nbsp; ' : ''}<kbd>Enter</kbd> Select &nbsp;•&nbsp; <kbd>Esc</kbd> Cancel
868                </div>
869            </div>
870        `;
871
872        document.body.appendChild(this.cyclingOverlay);
873
874        // Render the video preview with bounding box
875        this.renderCyclingPreview(detection);
876
877        // Bind event handlers
878        this.bindCyclingEvents();
879
880        // Add keyboard listener
881        document.addEventListener('keydown', this.handleCyclingKeyDown);
882    }
883
884    /**
885     * Render the video preview with adjustable bounding box in cycling mode
886     */
887    renderCyclingPreview(detection) {
888        const previewContainer = this.cyclingOverlay.querySelector('.plate-cycling-video-container');
889        if (!previewContainer) return;
890
891        const video = this.getVideoElement(detection.cameraId);
892        if (!video || video.readyState < 2) return;
893
894        // Create a canvas to show the video frame with bounding box
895        const canvas = document.createElement('canvas');
896        const previewWidth = 600;
897        const previewHeight = Math.round(previewWidth * (video.videoHeight / video.videoWidth));
898        canvas.width = previewWidth;
899        canvas.height = previewHeight;
900        canvas.className = 'plate-cycling-canvas';
901
902        const ctx = canvas.getContext('2d');
903
904        // Apply video enhancements to preview if enabled
905        if (this.applyVideoEnhancementsToSource) {
906            const settings = this.getVideoEnhancementSettings();
907            this.applyVideoEnhancementsToCanvas(ctx, settings);
908        }
909
910        ctx.drawImage(video, 0, 0, previewWidth, previewHeight);
911
912        // Reset filter after drawing
913        ctx.filter = 'none';
914
915        // Calculate scaled bbox position
916        const scaleX = previewWidth / video.videoWidth;
917        const scaleY = previewHeight / video.videoHeight;
918
919        // Use adjusted bbox if available, otherwise use detection bbox
920        const bbox = this.cyclingState.adjustedBbox || detection.bbox;
921        const padding = this.bboxPadding;
922
923        const x = (bbox.x - bbox.width * padding) * scaleX;
924        const y = (bbox.y - bbox.height * padding) * scaleY;
925        const w = bbox.width * (1 + padding * 2) * scaleX;
926        const h = bbox.height * (1 + padding * 2) * scaleY;
927
928        previewContainer.innerHTML = '';
929
930        // Create wrapper to ensure selection box is positioned relative to canvas
931        const canvasWrapper = document.createElement('div');
932        canvasWrapper.className = 'plate-cycling-canvas-wrapper';
933        canvasWrapper.style.position = 'relative';
934        canvasWrapper.style.display = 'inline-block';
935        canvasWrapper.appendChild(canvas);
936
937        // Add selection box overlay using percentages (so it scales with CSS-scaled canvas)
938        const selectionBox = document.createElement('div');
939        selectionBox.className = 'plate-cycling-selection';
940        // Use percentage positioning relative to canvas size
941        const leftPct = (x / previewWidth) * 100;
942        const topPct = (y / previewHeight) * 100;
943        const widthPct = (w / previewWidth) * 100;
944        const heightPct = (h / previewHeight) * 100;
945        selectionBox.style.left = `${leftPct}%`;
946        selectionBox.style.top = `${topPct}%`;
947        selectionBox.style.width = `${widthPct}%`;
948        selectionBox.style.height = `${heightPct}%`;
949
950        // Add resize handles
951        ['nw', 'ne', 'sw', 'se'].forEach(pos => {
952            const handle = document.createElement('div');
953            handle.className = `plate-cycling-handle ${pos}`;
954            handle.addEventListener('mousedown', (e) => this.startCyclingResize(e, pos, detection, scaleX, scaleY));
955            selectionBox.appendChild(handle);
956        });
957
958        // Add size indicator showing video pixel dimensions
959        const sizeIndicator = document.createElement('div');
960        sizeIndicator.className = 'plate-enhancer-size-indicator';
961        selectionBox.appendChild(sizeIndicator);
962        this.updateCyclingSizeIndicator(detection, sizeIndicator);
963
964        // Make box draggable
965        selectionBox.addEventListener('mousedown', (e) => {
966            if (e.target === selectionBox) {
967                this.startCyclingDrag(e, detection, scaleX, scaleY);
968            }
969        });
970
971        // Add selection box to the canvas wrapper (so it's positioned relative to canvas)
972        canvasWrapper.appendChild(selectionBox);
973        previewContainer.appendChild(canvasWrapper);
974
975        // Store canvas and scale for resize/drag handlers
976        this.cyclingCanvas = { canvas, scaleX, scaleY, previewWidth, previewHeight, canvasWrapper };
977
978        // Update size warning if selection is large
979        this.updateCyclingSizeWarning(detection);
980    }
981
982    /**
983     * Update the size warning in cycling mode based on current bbox
984     */
985    updateCyclingSizeWarning(detection) {
986        const warningContainer = this.cyclingOverlay?.querySelector('.plate-cycling-size-warning');
987        if (!warningContainer) return;
988
989        const bbox = this.cyclingState.adjustedBbox || detection.bbox;
990        const area = Math.round(bbox.width * bbox.height);
991
992        // Same thresholds as regular selection
993        const isLarge = area > 40000;
994        const isVeryLarge = area > 100000;
995
996        if (isVeryLarge) {
997            warningContainer.style.display = 'block';
998            warningContainer.innerHTML = `
999                <div class="plate-enhancer-warning plate-enhancer-warning-severe">
1000                    📐 <strong>Very large selection</strong> (${Math.round(bbox.width)}×${Math.round(bbox.height)}px = ${Math.round(area/1000)}k pixels).<br>
1001                    Processing will be significantly slower. Consider resizing to select just the license plate area for faster results.
1002                </div>
1003            `;
1004        } else if (isLarge) {
1005            warningContainer.style.display = 'block';
1006            warningContainer.innerHTML = `
1007                <div class="plate-enhancer-warning">
1008                    📐 Large selection (${Math.round(bbox.width)}×${Math.round(bbox.height)}px). Processing may take longer than usual.
1009                </div>
1010            `;
1011        } else {
1012            warningContainer.style.display = 'none';
1013            warningContainer.innerHTML = '';
1014        }
1015    }
1016
1017    /**
1018     * Start dragging the selection box in cycling mode
1019     */
1020    startCyclingDrag(e, detection, scaleX, scaleY) {
1021        e.preventDefault();
1022        e.stopPropagation();
1023
1024        const selectionBox = e.target;
1025        const startX = e.clientX;
1026        const startY = e.clientY;
1027        // Values are now percentages (0-100)
1028        const origLeftPct = parseFloat(selectionBox.style.left);
1029        const origTopPct = parseFloat(selectionBox.style.top);
1030        const widthPct = parseFloat(selectionBox.style.width);
1031        const heightPct = parseFloat(selectionBox.style.height);
1032
1033        const { previewWidth, previewHeight, canvas } = this.cyclingCanvas;
1034
1035        // Get the actual displayed size of the canvas (may be CSS-scaled)
1036        const canvasRect = canvas.getBoundingClientRect();
1037
1038        const onMove = (moveEvent) => {
1039            // Convert mouse movement to percentage of canvas size
1040            const dxPct = ((moveEvent.clientX - startX) / canvasRect.width) * 100;
1041            const dyPct = ((moveEvent.clientY - startY) / canvasRect.height) * 100;
1042
1043            let newLeftPct = Math.max(0, Math.min(origLeftPct + dxPct, 100 - widthPct));
1044            let newTopPct = Math.max(0, Math.min(origTopPct + dyPct, 100 - heightPct));
1045
1046            selectionBox.style.left = `${newLeftPct}%`;
1047            selectionBox.style.top = `${newTopPct}%`;
1048        };
1049
1050        const onUp = () => {
1051            document.removeEventListener('mousemove', onMove);
1052            document.removeEventListener('mouseup', onUp);
1053
1054            // Update adjusted bbox in video coordinates
1055            const padding = this.bboxPadding;
1056            // Convert from percentage to preview pixel coords, then to video coords
1057            const leftPct = parseFloat(selectionBox.style.left);
1058            const topPct = parseFloat(selectionBox.style.top);
1059            const wPct = parseFloat(selectionBox.style.width);
1060            const hPct = parseFloat(selectionBox.style.height);
1061
1062            // Convert percentages to preview pixels
1063            const displayLeft = (leftPct / 100) * previewWidth;
1064            const displayTop = (topPct / 100) * previewHeight;
1065            const displayW = (wPct / 100) * previewWidth;
1066            const displayH = (hPct / 100) * previewHeight;
1067
1068            // Convert from display (preview) coords to video coords
1069            const left = displayLeft / scaleX;
1070            const top = displayTop / scaleY;
1071            const w = displayW / scaleX / (1 + padding * 2);
1072            const h = displayH / scaleY / (1 + padding * 2);
1073
1074            this.cyclingState.adjustedBbox = {
1075                x: left + w * padding,
1076                y: top + h * padding,
1077                width: w,
1078                height: h
1079            };
1080
1081            console.log(`[PlateEnhancer] Drag end: pct=(${leftPct.toFixed(1)}%,${topPct.toFixed(1)}% ${wPct.toFixed(1)}%x${hPct.toFixed(1)}%) => display=(${displayLeft.toFixed(1)},${displayTop.toFixed(1)} ${displayW.toFixed(1)}x${displayH.toFixed(1)}) scale=(${scaleX.toFixed(4)},${scaleY.toFixed(4)}) => bbox=(${this.cyclingState.adjustedBbox.x.toFixed(1)},${this.cyclingState.adjustedBbox.y.toFixed(1)} ${this.cyclingState.adjustedBbox.width.toFixed(1)}x${this.cyclingState.adjustedBbox.height.toFixed(1)})`);
1082        };
1083
1084        document.addEventListener('mousemove', onMove);
1085        document.addEventListener('mouseup', onUp);
1086    }
1087
1088    /**
1089     * Start resizing the selection box in cycling mode
1090     */
1091    startCyclingResize(e, handle, detection, scaleX, scaleY) {
1092        e.preventDefault();
1093        e.stopPropagation();
1094
1095        const selectionBox = e.target.parentElement;
1096        const startX = e.clientX;
1097        const startY = e.clientY;
1098        // Values are now percentages (0-100)
1099        const origLeftPct = parseFloat(selectionBox.style.left);
1100        const origTopPct = parseFloat(selectionBox.style.top);
1101        const origWidthPct = parseFloat(selectionBox.style.width);
1102        const origHeightPct = parseFloat(selectionBox.style.height);
1103
1104        const { previewWidth, previewHeight, canvas } = this.cyclingCanvas;
1105        const minSizePct = 3; // Minimum size as percentage (about 3% of canvas)
1106        const padding = this.bboxPadding; // Define here so it's accessible in both onMove and onUp
1107
1108        // Get the actual displayed size of the canvas (may be CSS-scaled)
1109        const canvasRect = canvas.getBoundingClientRect();
1110
1111        const onMove = (moveEvent) => {
1112            // Convert mouse movement to percentage of canvas size
1113            const dxPct = ((moveEvent.clientX - startX) / canvasRect.width) * 100;
1114            const dyPct = ((moveEvent.clientY - startY) / canvasRect.height) * 100;
1115
1116            let newLeftPct = origLeftPct;
1117            let newTopPct = origTopPct;
1118            let newWidthPct = origWidthPct;
1119            let newHeightPct = origHeightPct;
1120
1121            if (handle.includes('e')) {
1122                newWidthPct = Math.max(minSizePct, origWidthPct + dxPct);
1123            }
1124            if (handle.includes('w')) {
1125                const proposedW = origWidthPct - dxPct;
1126                if (proposedW >= minSizePct) {
1127                    newWidthPct = proposedW;
1128                    newLeftPct = origLeftPct + dxPct;
1129                }
1130            }
1131            if (handle.includes('s')) {
1132                newHeightPct = Math.max(minSizePct, origHeightPct + dyPct);
1133            }
1134            if (handle.includes('n')) {
1135                const proposedH = origHeightPct - dyPct;
1136                if (proposedH >= minSizePct) {
1137                    newHeightPct = proposedH;
1138                    newTopPct = origTopPct + dyPct;
1139                }
1140            }
1141
1142            // Constrain to bounds (0-100%)
1143            newLeftPct = Math.max(0, newLeftPct);
1144            newTopPct = Math.max(0, newTopPct);
1145            if (newLeftPct + newWidthPct > 100) newWidthPct = 100 - newLeftPct;
1146            if (newTopPct + newHeightPct > 100) newHeightPct = 100 - newTopPct;
1147
1148            selectionBox.style.left = `${newLeftPct}%`;
1149            selectionBox.style.top = `${newTopPct}%`;
1150            selectionBox.style.width = `${newWidthPct}%`;
1151            selectionBox.style.height = `${newHeightPct}%`;
1152
1153            // Update size indicator during resize
1154            const liveDisplayW = (newWidthPct / 100) * previewWidth;
1155            const liveDisplayH = (newHeightPct / 100) * previewHeight;
1156            const liveW = liveDisplayW / scaleX / (1 + padding * 2);
1157            const liveH = liveDisplayH / scaleY / (1 + padding * 2);
1158            const sizeIndicator = selectionBox.querySelector('.plate-enhancer-size-indicator');
1159            if (sizeIndicator) {
1160                const tempBbox = { width: liveW, height: liveH };
1161                this.updateCyclingSizeIndicator({ bbox: tempBbox }, sizeIndicator);
1162            }
1163        };
1164
1165        const onUp = () => {
1166            document.removeEventListener('mousemove', onMove);
1167            document.removeEventListener('mouseup', onUp);
1168
1169            // Update adjusted bbox in video coordinates (padding defined at outer scope)
1170            // Convert from percentage to preview pixel coords, then to video coords
1171            const leftPct = parseFloat(selectionBox.style.left);
1172            const topPct = parseFloat(selectionBox.style.top);
1173            const wPct = parseFloat(selectionBox.style.width);
1174            const hPct = parseFloat(selectionBox.style.height);
1175
1176            // Convert percentages to preview pixels
1177            const displayLeft = (leftPct / 100) * previewWidth;
1178            const displayTop = (topPct / 100) * previewHeight;
1179            const displayW = (wPct / 100) * previewWidth;
1180            const displayH = (hPct / 100) * previewHeight;
1181
1182            // Convert from display (preview) coords to video coords
1183            const left = displayLeft / scaleX;
1184            const top = displayTop / scaleY;
1185            const w = displayW / scaleX / (1 + padding * 2);
1186            const h = displayH / scaleY / (1 + padding * 2);
1187
1188            this.cyclingState.adjustedBbox = {
1189                x: left + w * padding,
1190                y: top + h * padding,
1191                width: w,
1192                height: h
1193            };
1194
1195            // Update size indicator with final bbox
1196            const sizeIndicator = selectionBox.querySelector('.plate-enhancer-size-indicator');
1197            if (sizeIndicator) {
1198                this.updateCyclingSizeIndicator({ bbox: this.cyclingState.adjustedBbox }, sizeIndicator);
1199            }
1200
1201            // Update the size warning in the panel
1202            this.updateCyclingSizeWarning({ bbox: this.cyclingState.adjustedBbox });
1203
1204            console.log(`[PlateEnhancer] Resize end: pct=(${leftPct.toFixed(1)}%,${topPct.toFixed(1)}% ${wPct.toFixed(1)}%x${hPct.toFixed(1)}%) => display=(${displayLeft.toFixed(1)},${displayTop.toFixed(1)} ${displayW.toFixed(1)}x${displayH.toFixed(1)}) scale=(${scaleX.toFixed(4)},${scaleY.toFixed(4)}) => bbox=(${this.cyclingState.adjustedBbox.x.toFixed(1)},${this.cyclingState.adjustedBbox.y.toFixed(1)} ${this.cyclingState.adjustedBbox.width.toFixed(1)}x${this.cyclingState.adjustedBbox.height.toFixed(1)})`);
1205        };
1206
1207        document.addEventListener('mousemove', onMove);
1208        document.addEventListener('mouseup', onUp);
1209    }
1210
1211    /**
1212     * Bind event handlers for cycling UI
1213     */
1214    bindCyclingEvents() {
1215        if (!this.cyclingOverlay) return;
1216
1217        // Navigation buttons
1218        this.cyclingOverlay.querySelector('.prev-btn')?.addEventListener('click', () => this.cyclePlate('prev'));
1219        this.cyclingOverlay.querySelector('.next-btn')?.addEventListener('click', () => this.cyclePlate('next'));
1220
1221        // Timeframe buttons
1222        this.cyclingOverlay.querySelectorAll('.plate-cycling-timeframe-btn').forEach(btn => {
1223            btn.addEventListener('click', () => {
1224                if (btn.disabled) return;
1225                this.cyclingOverlay.querySelectorAll('.plate-cycling-timeframe-btn').forEach(b => b.classList.remove('selected'));
1226                btn.classList.add('selected');
1227                this.preferredTimeframe = parseInt(btn.dataset.seconds);
1228                localStorage.setItem('tcv_plate_timeframe', this.preferredTimeframe.toString());
1229            });
1230        });
1231
1232        // Cancel button
1233        this.cyclingOverlay.querySelector('.cancel-btn')?.addEventListener('click', () => this.exitCyclingMode());
1234
1235        // Enhance button
1236        this.cyclingOverlay.querySelector('.enhance-btn')?.addEventListener('click', () => this.startEnhancementFromCycling());
1237
1238        // Video enhancements checkbox - re-render preview when toggled
1239        const enhancementsCheckbox = this.cyclingOverlay.querySelector('#apply-video-enhancements');
1240        if (enhancementsCheckbox) {
1241            enhancementsCheckbox.addEventListener('change', (e) => {
1242                this.applyVideoEnhancementsToSource = e.target.checked;
1243                localStorage.setItem('tcv_plate_apply_video_enhancements', this.applyVideoEnhancementsToSource.toString());
1244
1245                // Re-render preview with/without video enhancements
1246                const detection = this.cyclingState.detections[this.cyclingState.currentIndex];
1247                if (detection) {
1248                    this.renderCyclingPreview(detection);
1249                }
1250            });
1251        }
1252    }
1253
1254    /**
1255     * Handle keyboard events in cycling mode
1256     */
1257    handleCyclingKeyDown(e) {
1258        if (!this.cyclingState.isActive) return;
1259
1260        if (e.key === 'ArrowLeft') {
1261            e.preventDefault();
1262            this.cyclePlate('prev');
1263        } else if (e.key === 'ArrowRight') {
1264            e.preventDefault();
1265            this.cyclePlate('next');
1266        } else if (e.key === 'Enter') {
1267            e.preventDefault();
1268            this.startEnhancementFromCycling();
1269        } else if (e.key === 'Escape') {
1270            e.preventDefault();
1271            this.exitCyclingMode();
1272        }
1273    }
1274
1275    /**
1276     * Update size indicator in cycling mode
1277     * @param {Object} detection - Detection object with bbox
1278     * @param {HTMLElement} indicator - The size indicator element
1279     */
1280    updateCyclingSizeIndicator(detection, indicator) {
1281        // Get the current bbox (adjusted or original)
1282        const bbox = this.cyclingState.adjustedBbox || detection.bbox;
1283        const area = Math.round(bbox.width * bbox.height);
1284
1285        // Same thresholds as regular selection
1286        const isLarge = area > 40000;
1287        const isVeryLarge = area > 100000;
1288
1289        indicator.textContent = `${Math.round(bbox.width)}×${Math.round(bbox.height)}px`;
1290        indicator.classList.toggle('warning', isLarge && !isVeryLarge);
1291        indicator.classList.toggle('critical', isVeryLarge);
1292
1293        if (isVeryLarge) {
1294            indicator.title = 'Very large selection - processing will be slow. Try selecting just the plate area.';
1295        } else if (isLarge) {
1296            indicator.title = 'Large selection - processing may be slower than usual.';
1297        } else {
1298            indicator.title = 'Selection size in video pixels';
1299        }
1300    }
1301
1302    /**
1303     * Cycle to previous or next plate
1304     */
1305    cyclePlate(direction) {
1306        const { detections, currentIndex } = this.cyclingState;
1307
1308        let newIndex = currentIndex;
1309        if (direction === 'prev' && currentIndex > 0) {
1310            newIndex = currentIndex - 1;
1311        } else if (direction === 'next' && currentIndex < detections.length - 1) {
1312            newIndex = currentIndex + 1;
1313        }
1314
1315        if (newIndex !== currentIndex) {
1316            this.cyclingState.currentIndex = newIndex;
1317            this.cyclingState.adjustedBbox = null; // Reset adjustments for new plate
1318            this.showPlateCyclingUI();
1319        }
1320    }
1321
1322    /**
1323     * Exit cycling mode and clean up
1324     */
1325    exitCyclingMode() {
1326        this.cyclingState.isActive = false;
1327        this.cyclingState.detections = [];
1328        this.cyclingState.currentIndex = 0;
1329        this.cyclingState.adjustedBbox = null;
1330
1331        // Clear any manual selections that led to this cycling UI
1332        this.selections.clear();
1333
1334        if (this.cyclingOverlay) {
1335            this.cyclingOverlay.remove();
1336            this.cyclingOverlay = null;
1337        }
1338
1339        document.removeEventListener('keydown', this.handleCyclingKeyDown);
1340        // Remove button highlight
1341        this.setButtonActive(false);
1342    }
1343
1344    /**
1345     * Start enhancement from cycling mode with selected plate and timeframe
1346     */
1347    async startEnhancementFromCycling() {
1348        const { detections, currentIndex, adjustedBbox, detectionTime } = this.cyclingState;
1349        const detection = detections[currentIndex];
1350
1351        // Get the selected timeframe
1352        const selectedBtn = this.cyclingOverlay?.querySelector('.plate-cycling-timeframe-btn.selected');
1353        const timeframeSeconds = selectedBtn ? parseInt(selectedBtn.dataset.seconds) : this.preferredTimeframe;
1354
1355        // Use the time when plate was DETECTED, not current time (video may have moved)
1356        const vp = window.app?.videoPlayer;
1357        // Get total duration with robust fallback (getTotalDuration is async!)
1358        let totalDuration = 600; // Default fallback
1359        if (vp?.getTotalDuration) {
1360            try {
1361                const duration = await vp.getTotalDuration();
1362                if (duration && Number.isFinite(duration) && duration > 0) {
1363                    totalDuration = duration;
1364                }
1365            } catch (e) {
1366                console.warn('[PlateEnhancer] Failed to get total duration:', e);
1367            }
1368        }
1369        // Use saved detection time - this is when the plate was actually visible
1370        const currentTime = detectionTime ?? 0;
1371        this.referenceTime = currentTime;
1372        console.log(`[PlateEnhancer] Using detection time: ${currentTime.toFixed(2)}s (timeframe: ±${timeframeSeconds/2}s, duration: ${totalDuration.toFixed(1)}s)`);
1373
1374        // Close cycling UI
1375        this.exitCyclingMode();
1376
1377        const halfRange = timeframeSeconds / 2;
1378        const startTime = Math.max(0, currentTime - halfRange);
1379        // Clamp to video end with 0.1s buffer to avoid seek issues at tail
1380        // But ensure endTime is never less than startTime
1381        let endTime = Math.min(currentTime + halfRange, totalDuration - 0.1);
1382        if (endTime < startTime) {
1383            console.warn(`[PlateEnhancer] endTime (${endTime.toFixed(2)}) < startTime (${startTime.toFixed(2)}), adjusting`);
1384            endTime = Math.min(currentTime, totalDuration - 0.1);
1385        }
1386        // Ensure we have at least some range to sample
1387        if (endTime <= startTime) {
1388            console.warn(`[PlateEnhancer] No valid range, using single frame at ${currentTime.toFixed(2)}s`);
1389            endTime = startTime + 0.1; // At least 0.1s range
1390        }
1391        console.log(`[PlateEnhancer] Time range: ${startTime.toFixed(2)}s - ${endTime.toFixed(2)}s (${(endTime - startTime).toFixed(2)}s range, ref: ${currentTime.toFixed(2)}s)`);
1392
1393
1394        // Build selection from the detection
1395        const video = this.getVideoElement(detection.cameraId);
1396        if (!video) {
1397            this.showError('Video not available');
1398            return;
1399        }
1400
1401        const container = video.parentElement;
1402        const rect = container.getBoundingClientRect();
1403        const videoDisplay = this.getVideoDisplayRect(video, rect);
1404
1405        // Use adjusted bbox if available
1406        const bbox = adjustedBbox || detection.bbox;
1407        const padding = this.bboxPadding;
1408
1409        const scaleX = videoDisplay.width / video.videoWidth;
1410        const scaleY = videoDisplay.height / video.videoHeight;
1411
1412        const paddedX = bbox.x - bbox.width * padding;
1413        const paddedY = bbox.y - bbox.height * padding;
1414        const paddedW = bbox.width * (1 + padding * 2);
1415        const paddedH = bbox.height * (1 + padding * 2);
1416
1417        const selection = {
1418            x: videoDisplay.x + paddedX * scaleX,
1419            y: videoDisplay.y + paddedY * scaleY,
1420            width: paddedW * scaleX,
1421            height: paddedH * scaleY,
1422            containerWidth: rect.width,
1423            containerHeight: rect.height,
1424            videoWidth: video.videoWidth,
1425            videoHeight: video.videoHeight,
1426            videoDisplayX: videoDisplay.x,
1427            videoDisplayY: videoDisplay.y,
1428            videoDisplayWidth: videoDisplay.width,
1429            videoDisplayHeight: videoDisplay.height,
1430            confidence: detection.confidence,
1431            autoDetected: true
1432        };
1433
1434        // Set the selection and start processing
1435        this.selections.clear();
1436        this.selections.set(detection.cameraId, selection);
1437
1438        // Use 4x super-resolution upscaling for best OCR results
1439        await this.startProcessing(startTime, endTime, true, 4);
1440    }
1441
1442    /**
1443     * Convert selection coordinates from container space to video pixel space
1444     * Properly handles letterboxing from object-fit: contain
1445     */
1446    containerToVideoCoords(selection) {
1447        // Get the video display area within the container
1448        const displayX = selection.videoDisplayX || 0;
1449        const displayY = selection.videoDisplayY || 0;
1450        const displayWidth = selection.videoDisplayWidth || selection.containerWidth;
1451        const displayHeight = selection.videoDisplayHeight || selection.containerHeight;
1452
1453        // Convert from container coords to video display coords (remove letterbox offset)
1454        const relativeX = selection.x - displayX;
1455        const relativeY = selection.y - displayY;
1456
1457        // Scale from display size to actual video size
1458        const scaleX = selection.videoWidth / displayWidth;
1459        const scaleY = selection.videoHeight / displayHeight;
1460
1461        const videoX = Math.round(relativeX * scaleX);
1462        const videoY = Math.round(relativeY * scaleY);
1463        const videoW = Math.round(selection.width * scaleX);
1464        const videoH = Math.round(selection.height * scaleY);
1465
1466        // Debug logging for coordinate conversion
1467        console.log(`[PlateEnhancer] containerToVideoCoords: input=(${selection.x.toFixed(0)},${selection.y.toFixed(0)} ${selection.width.toFixed(0)}x${selection.height.toFixed(0)}) display=(${displayX.toFixed(0)},${displayY.toFixed(0)} ${displayWidth.toFixed(0)}x${displayHeight.toFixed(0)}) scale=${scaleX.toFixed(3)} relative=(${relativeX.toFixed(0)},${relativeY.toFixed(0)}) => video=(${videoX},${videoY} ${videoW}x${videoH}) videoSize=${selection.videoWidth}x${selection.videoHeight}`);
1468
1469        // Clamp to video bounds
1470        return {
1471            x: Math.max(0, Math.min(videoX, selection.videoWidth - videoW)),
1472            y: Math.max(0, Math.min(videoY, selection.videoHeight - videoH)),
1473            width: Math.min(videoW, selection.videoWidth),
1474            height: Math.min(videoH, selection.videoHeight)
1475        };
1476    }
1477
1478    /**
1479     * Render selection box on overlay
1480     */
1481    renderSelection(cameraId, overlay, selection) {
1482        // Remove existing selection box
1483        const existing = overlay.querySelector('.plate-enhancer-selection');
1484        if (existing) existing.remove();
1485
1486        const box = document.createElement('div');
1487        box.className = 'plate-enhancer-selection';
1488        box.style.left = `${selection.x}px`;
1489        box.style.top = `${selection.y}px`;
1490        box.style.width = `${selection.width}px`;
1491        box.style.height = `${selection.height}px`;
1492
1493        // Add resize handles
1494        ['nw', 'ne', 'sw', 'se'].forEach(pos => {
1495            const handle = document.createElement('div');
1496            handle.className = `plate-enhancer-handle ${pos}`;
1497            handle.addEventListener('mousedown', (e) => this.startResize(e, cameraId, pos));
1498            box.appendChild(handle);
1499        });
1500
1501        // Add camera label
1502        const label = document.createElement('div');
1503        label.className = 'plate-enhancer-camera-label';
1504        label.textContent = this.getCameraDisplayName(cameraId);
1505        box.appendChild(label);
1506
1507        // Add size indicator (shows video pixel dimensions)
1508        const sizeIndicator = document.createElement('div');
1509        sizeIndicator.className = 'plate-enhancer-size-indicator';
1510        box.appendChild(sizeIndicator);
1511        this.updateSizeIndicator(cameraId, selection, sizeIndicator);
1512
1513        // Make box draggable
1514        box.addEventListener('mousedown', (e) => {
1515            if (e.target === box) {
1516                this.startDrag(e, cameraId);
1517            }
1518        });
1519
1520        overlay.appendChild(box);
1521    }
1522
1523    /**
1524     * Get display name for camera
1525     */
1526    getCameraDisplayName(cameraId) {
1527        const names = {
1528            'front': 'Front',
1529            'back': 'Back',
1530            'left_repeater': 'Left',
1531            'right_repeater': 'Right',
1532            'left_pillar': 'Left Pillar',
1533            'right_pillar': 'Right Pillar'
1534        };
1535        return names[cameraId] || cameraId;
1536    }
1537
1538    /**
1539     * Update size indicator to show video pixel dimensions and warn if too large
1540     * @param {string} cameraId - Camera identifier
1541     * @param {Object} selection - Selection object with container dimensions
1542     * @param {HTMLElement} indicator - The size indicator element
1543     */
1544    updateSizeIndicator(cameraId, selection, indicator) {
1545        const videoCoords = this.containerToVideoCoords(selection);
1546        const area = videoCoords.width * videoCoords.height;
1547
1548        // Classification thresholds (based on 4x ESRGAN processing time)
1549        // Optimal: < 40000 pixels (200x200 or smaller) - fast processing
1550        // Large: 40000-100000 pixels - slower, will show warning
1551        // Very large: > 100000 pixels - significantly slower
1552        const isLarge = area > 40000;
1553        const isVeryLarge = area > 100000;
1554
1555        indicator.textContent = `${videoCoords.width}×${videoCoords.height}px`;
1556        indicator.classList.toggle('warning', isLarge && !isVeryLarge);
1557        indicator.classList.toggle('critical', isVeryLarge);
1558
1559        if (isVeryLarge) {
1560            indicator.title = 'Very large selection - processing will be slow. Try selecting just the plate area.';
1561        } else if (isLarge) {
1562            indicator.title = 'Large selection - processing may be slower than usual.';
1563        } else {
1564            indicator.title = 'Selection size in video pixels';
1565        }
1566    }
1567
1568    /**
1569     * Get size classification for a selection (used in enhance dialog)
1570     * @param {Object} selection - Selection object
1571     * @returns {Object} { area, classification, warning }
1572     */
1573    getSelectionSizeInfo(selection) {
1574        const videoCoords = this.containerToVideoCoords(selection);
1575        const area = videoCoords.width * videoCoords.height;
1576
1577        let classification = 'optimal';
1578        let warning = null;
1579
1580        if (area > 100000) {
1581            classification = 'critical';
1582            warning = `Selection is ${videoCoords.width}×${videoCoords.height}px (${Math.round(area/1000)}k pixels). Processing will be significantly slower. Consider selecting just the license plate area for faster results.`;
1583        } else if (area > 40000) {
1584            classification = 'large';
1585            warning = `Selection is ${videoCoords.width}×${videoCoords.height}px. Processing may take longer than usual.`;
1586        }
1587
1588        return { width: videoCoords.width, height: videoCoords.height, area, classification, warning };
1589    }
1590
1591    /**
1592     * Start dragging selection box
1593     */
1594    startDrag(e, cameraId) {
1595        e.preventDefault();
1596        e.stopPropagation();
1597
1598        const selection = this.selections.get(cameraId);
1599        if (!selection) return;
1600
1601        this.isDraggingOrResizing = true;
1602
1603        const startX = e.clientX;
1604        const startY = e.clientY;
1605        const origX = selection.x;
1606        const origY = selection.y;
1607
1608        const onMove = (moveEvent) => {
1609            const dx = moveEvent.clientX - startX;
1610            const dy = moveEvent.clientY - startY;
1611
1612            selection.x = Math.max(0, Math.min(origX + dx, selection.containerWidth - selection.width));
1613            selection.y = Math.max(0, Math.min(origY + dy, selection.containerHeight - selection.height));
1614
1615            const overlay = this.overlays.get(cameraId);
1616            const box = overlay.querySelector('.plate-enhancer-selection');
1617            if (box) {
1618                box.style.left = `${selection.x}px`;
1619                box.style.top = `${selection.y}px`;
1620            }
1621        };
1622
1623        const onUp = () => {
1624            document.removeEventListener('mousemove', onMove);
1625            document.removeEventListener('mouseup', onUp);
1626            // Delay clearing flag so the click event doesn't fire
1627            setTimeout(() => {
1628                this.isDraggingOrResizing = false;
1629            }, 50);
1630        };
1631
1632        document.addEventListener('mousemove', onMove);
1633        document.addEventListener('mouseup', onUp);
1634    }
1635
1636    /**
1637     * Start resizing selection box
1638     */
1639    startResize(e, cameraId, handle) {
1640        e.preventDefault();
1641        e.stopPropagation();
1642
1643        const selection = this.selections.get(cameraId);
1644        if (!selection) return;
1645
1646        const overlay = this.overlays.get(cameraId);
1647        const box = overlay?.querySelector('.plate-enhancer-selection');
1648        if (!box) return;
1649
1650        this.isDraggingOrResizing = true;
1651
1652        // Get the container's position for coordinate conversion
1653        const container = overlay.parentElement;
1654        const containerRect = container.getBoundingClientRect();
1655
1656        // Store initial state
1657        const startMouseX = e.clientX;
1658        const startMouseY = e.clientY;
1659        const origX = selection.x;
1660        const origY = selection.y;
1661        const origW = selection.width;
1662        const origH = selection.height;
1663
1664        const minSize = 20;
1665
1666        const onMove = (moveEvent) => {
1667            const dx = moveEvent.clientX - startMouseX;
1668            const dy = moveEvent.clientY - startMouseY;
1669
1670            let newX = origX;
1671            let newY = origY;
1672            let newW = origW;
1673            let newH = origH;
1674
1675            // East edge (right)
1676            if (handle.includes('e')) {
1677                newW = Math.max(minSize, origW + dx);
1678            }
1679            // West edge (left)
1680            if (handle.includes('w')) {
1681                const proposedW = origW - dx;
1682                if (proposedW >= minSize) {
1683                    newW = proposedW;
1684                    newX = origX + dx;
1685                }
1686            }
1687            // South edge (bottom)
1688            if (handle.includes('s')) {
1689                newH = Math.max(minSize, origH + dy);
1690            }
1691            // North edge (top)
1692            if (handle.includes('n')) {
1693                const proposedH = origH - dy;
1694                if (proposedH >= minSize) {
1695                    newH = proposedH;
1696                    newY = origY + dy;
1697                }
1698            }
1699
1700            // Constrain to container bounds
1701            newX = Math.max(0, newX);
1702            newY = Math.max(0, newY);
1703            if (newX + newW > selection.containerWidth) {
1704                newW = selection.containerWidth - newX;
1705            }
1706            if (newY + newH > selection.containerHeight) {
1707                newH = selection.containerHeight - newY;
1708            }
1709
1710            // Update selection
1711            selection.x = newX;
1712            selection.y = newY;
1713            selection.width = newW;
1714            selection.height = newH;
1715
1716            // Update DOM
1717            box.style.left = `${newX}px`;
1718            box.style.top = `${newY}px`;
1719            box.style.width = `${newW}px`;
1720            box.style.height = `${newH}px`;
1721
1722            // Update size indicator
1723            const sizeIndicator = box.querySelector('.plate-enhancer-size-indicator');
1724            if (sizeIndicator) {
1725                this.updateSizeIndicator(cameraId, selection, sizeIndicator);
1726            }
1727        };
1728
1729        const onUp = () => {
1730            document.removeEventListener('mousemove', onMove);
1731            document.removeEventListener('mouseup', onUp);
1732            // Delay clearing flag so the click event doesn't fire
1733            setTimeout(() => {
1734                this.isDraggingOrResizing = false;
1735            }, 50);
1736        };
1737
1738        document.addEventListener('mousemove', onMove);
1739        document.addEventListener('mouseup', onUp);
1740    }
1741
1742    /**
1743     * Handle keyboard events during selection
1744     */
1745    handleKeyDown(e) {
1746        if (!this.isSelecting) return;
1747
1748        if (e.key === 'Escape') {
1749            this.cancelSelection();
1750        } else if (e.key === 'Enter') {
1751            if (this.selections.size > 0) {
1752                this.confirmSelection();
1753            }
1754        }
1755    }
1756
1757    /**
1758     * Cancel selection mode
1759     */
1760    cancelSelection() {
1761        this.isSelecting = false;
1762        this.isDraggingOrResizing = false;
1763        this.selections.clear();
1764        this.removeOverlays();
1765        document.removeEventListener('keydown', this.handleKeyDown);
1766        // Remove button highlight
1767        this.setButtonActive(false);
1768    }
1769
1770    /**
1771     * Clear all selections and reset state (called when switching events)
1772     */
1773    clearAllSelections() {
1774        // Cancel any active selection
1775        this.isSelecting = false;
1776        this.isDraggingOrResizing = false;
1777        this.selections.clear();
1778        this.removeOverlays();
1779        document.removeEventListener('keydown', this.handleKeyDown);
1780
1781        // Close any open dialogs
1782        const dialogs = document.querySelectorAll('.plate-enhancer-dialog, .plate-enhancer-backdrop, .plate-enhancer-results');
1783        dialogs.forEach(el => el.remove());
1784
1785        // Reset processing state (but don't interrupt active processing - that's blocked separately)
1786        this.shouldCancel = true;
1787
1788        // Remove button highlight
1789        this.setButtonActive(false);
1790
1791        console.log('[PlateEnhancer] Cleared all selections and state');
1792    }
1793
1794    /**
1795     * Confirm selection and show unified enhancement interface
1796     */
1797    confirmSelection() {
1798        this.isSelecting = false;
1799        document.removeEventListener('keydown', this.handleKeyDown);
1800
1801        // Store the reference time (where user drew the box)
1802        const vp = window.app?.videoPlayer;
1803        if (vp && vp.getCurrentAbsoluteTime) {
1804            this.referenceTime = vp.getCurrentAbsoluteTime();
1805        } else {
1806            const clipMarking = window.app?.clipMarking;
1807            if (clipMarking) {
1808                this.referenceTime = clipMarking.getAbsoluteTime();
1809            } else {
1810                this.referenceTime = vp ? vp.getCurrentTime() : 0;
1811            }
1812        }
1813
1814        // Convert manual selections to detection objects for unified interface
1815        const detections = [];
1816        this.selections.forEach((selection, cameraId) => {
1817            const video = this.getVideoElement(cameraId);
1818            if (!video) return;
1819
1820            // Convert display coordinates to video coordinates
1821            const container = video.closest('.video-container');
1822            if (!container) return;
1823
1824            const containerRect = container.getBoundingClientRect();
1825            const videoDisplay = this.getVideoDisplayRect(video, containerRect);
1826
1827            // Scale from display to video coordinates
1828            const scaleX = video.videoWidth / videoDisplay.width;
1829            const scaleY = video.videoHeight / videoDisplay.height;
1830
1831            const bbox = {
1832                x: Math.round((selection.x - videoDisplay.x) * scaleX),
1833                y: Math.round((selection.y - videoDisplay.y) * scaleY),
1834                width: Math.round(selection.width * scaleX),
1835                height: Math.round(selection.height * scaleY)
1836            };
1837
1838            // Clamp to video bounds
1839            bbox.x = Math.max(0, Math.min(bbox.x, video.videoWidth - bbox.width));
1840            bbox.y = Math.max(0, Math.min(bbox.y, video.videoHeight - bbox.height));
1841
1842            detections.push({
1843                cameraId,
1844                bbox,
1845                confidence: 1.0, // Manual selection = 100% confidence
1846                videoDims: { width: video.videoWidth, height: video.videoHeight },
1847                isManual: true,
1848                detectionTime: this.referenceTime
1849            });
1850        });
1851
1852        if (detections.length === 0) {
1853            this.showToast('No valid selections found');
1854            this.cancelSelection();
1855            return;
1856        }
1857
1858        // Remove selection overlays before showing cycling UI
1859        this.removeOverlays();
1860
1861        // Use the unified cycling UI
1862        this.cyclingState = {
1863            isActive: true,
1864            detections,
1865            currentIndex: 0,
1866            adjustedBbox: null,
1867            detectionTime: this.referenceTime  // Time when selection was made
1868        };
1869
1870        this.showPlateCyclingUI();
1871    }
1872
1873    /**
1874     * Show dialog to select time range
1875     * @param {Object|null} marks - IN/OUT marks if set { inPoint, outPoint }
1876     * @param {boolean} selectionOutsideMarks - Whether the selection was drawn outside the mark range
1877     */
1878    showTimeRangeDialog(marks = null, selectionOutsideMarks = false) {
1879        const backdrop = document.createElement('div');
1880        backdrop.className = 'plate-enhancer-dialog-backdrop';
1881
1882        const hasMarks = marks && marks.inPoint !== null && marks.outPoint !== null;
1883        const marksDuration = hasMarks ? (marks.outPoint - marks.inPoint) : null;
1884        const marksDurationStr = marksDuration ? marksDuration.toFixed(1) : null;
1885
1886        // Check for very long durations
1887        const isLongDuration = marksDuration && marksDuration > 15;
1888        const isVeryLongDuration = marksDuration && marksDuration > 30;
1889        const estimatedFrames = marksDuration ? Math.ceil(marksDuration * 30) : 0;
1890
1891        // Check selection sizes and get warnings
1892        let sizeWarningHtml = '';
1893        let hasLargeSelection = false;
1894        for (const [cameraId, selection] of this.selections) {
1895            const sizeInfo = this.getSelectionSizeInfo(selection);
1896            if (sizeInfo.warning) {
1897                hasLargeSelection = true;
1898                const cameraName = this.getCameraDisplayName(cameraId);
1899                const severityClass = sizeInfo.classification === 'critical' ? 'plate-enhancer-warning-severe' : '';
1900                sizeWarningHtml += `
1901                    <div class="plate-enhancer-warning ${severityClass}">
1902                        📐 <strong>${cameraName}:</strong> ${sizeInfo.warning}
1903                    </div>
1904                `;
1905            }
1906        }
1907
1908        const dialog = document.createElement('div');
1909        dialog.className = 'plate-enhancer-dialog';
1910        dialog.innerHTML = `
1911            <h3>🔍 Enhancement Options</h3>
1912            ${sizeWarningHtml}
1913            ${hasMarks ? `
1914                ${selectionOutsideMarks ? `
1915                    <div class="plate-enhancer-warning">
1916                        📍 <strong>Selection outside time range</strong> - You drew the selection at ${this.referenceTime.toFixed(1)}s, which is outside your IN/OUT marks. The plate position may differ. For best results, scrub to within your marked range before selecting.
1917                    </div>
1918                ` : ''}
1919                <div class="plate-enhancer-section">
1920                    <div class="plate-enhancer-section-header">📍 Time Range</div>
1921                    <p class="plate-enhancer-help-text">Using your IN/OUT marks (${marksDurationStr}s → ~${estimatedFrames} frames)</p>
1922                    <div class="plate-enhancer-marks-info">
1923                        <span class="plate-enhancer-mark-badge">IN: ${marks.inPoint.toFixed(1)}s</span>
1924                        <span class="plate-enhancer-mark-badge">OUT: ${marks.outPoint.toFixed(1)}s</span>
1925                    </div>
1926                    ${isVeryLongDuration ? `
1927                        <div class="plate-enhancer-warning plate-enhancer-warning-severe">
1928                            ⚠️ <strong>Very long duration!</strong> Processing ${estimatedFrames} frames may take several minutes and could slow down your browser. Consider using a shorter range (5-10 seconds is usually enough).
1929                        </div>
1930                    ` : isLongDuration ? `
1931                        <div class="plate-enhancer-warning">
1932                            ⏱️ Longer duration means more frames to process. This may take a while. 5-10 seconds is usually sufficient for good results.
1933                        </div>
1934                    ` : ''}
1935                </div>
1936            ` : `
1937                <div class="plate-enhancer-section">
1938                    <div class="plate-enhancer-section-header">⏱️ Analysis Duration</div>
1939                    <p class="plate-enhancer-help-text">More time = more frames to combine, but slower processing</p>
1940                    <div class="plate-enhancer-time-presets">
1941                        <button class="plate-enhancer-preset-btn" data-seconds="2">2 sec<span class="preset-hint">Quick</span></button>
1942                        <button class="plate-enhancer-preset-btn selected" data-seconds="5">5 sec<span class="preset-hint">Recommended</span></button>
1943                        <button class="plate-enhancer-preset-btn" data-seconds="10">10 sec<span class="preset-hint">Thorough</span></button>
1944                    </div>
1945                    <p class="plate-enhancer-tip">💡 Tip: Use IN/OUT marks (I and O keys) for precise control</p>
1946                </div>
1947            `}
1948            <div class="plate-enhancer-dialog-actions">
1949                <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="cancel">Cancel</button>
1950                <button class="plate-enhancer-btn plate-enhancer-btn-primary" data-action="start">🚀 Start Enhancement</button>
1951            </div>
1952        `;
1953
1954        let selectedSeconds = 5;
1955
1956        // Handle preset selection (only if not using marks)
1957        if (!hasMarks) {
1958            dialog.querySelectorAll('.plate-enhancer-preset-btn').forEach(btn => {
1959                btn.addEventListener('click', () => {
1960                    dialog.querySelectorAll('.plate-enhancer-preset-btn').forEach(b => b.classList.remove('selected'));
1961                    btn.classList.add('selected');
1962                    selectedSeconds = parseInt(btn.dataset.seconds);
1963                });
1964            });
1965        }
1966
1967        // Handle cancel
1968        dialog.querySelector('[data-action="cancel"]').addEventListener('click', () => {
1969            backdrop.remove();
1970            dialog.remove();
1971            this.cancelSelection();
1972        });
1973
1974        // Handle start
1975        dialog.querySelector('[data-action="start"]').addEventListener('click', async () => {
1976            backdrop.remove();
1977            dialog.remove();
1978
1979            let startTime, endTime;
1980
1981            if (hasMarks) {
1982                // Use IN/OUT marks
1983                startTime = marks.inPoint;
1984                endTime = marks.outPoint;
1985            } else {
1986                // Calculate time range centered on reference time (where user drew the selection)
1987                // Use referenceTime to ensure we process frames around where the plate was visible
1988                const vp = window.app?.videoPlayer;
1989                // Get total duration (getTotalDuration is async!)
1990                let totalDuration = 600; // Default fallback
1991                if (vp?.getTotalDuration) {
1992                    try {
1993                        const duration = await vp.getTotalDuration();
1994                        if (duration && Number.isFinite(duration) && duration > 0) {
1995                            totalDuration = duration;
1996                        }
1997                    } catch (e) {
1998                        console.warn('[PlateEnhancer] Failed to get total duration:', e);
1999                    }
2000                }
2001                const halfRange = selectedSeconds / 2;
2002                startTime = Math.max(0, this.referenceTime - halfRange);
2003                endTime = Math.min(this.referenceTime + halfRange, totalDuration); // Clamp to video end
2004            }
2005
2006            // Always use 4x super-resolution upscaling for best OCR results
2007            const enableMLUpscale = true;
2008            const upscaleScale = 4;
2009            console.log('[PlateEnhancer] Using 4x super-resolution upscale');
2010
2011            this.startProcessing(startTime, endTime, enableMLUpscale, upscaleScale);
2012        });
2013
2014        // Handle backdrop click
2015        backdrop.addEventListener('click', () => {
2016            backdrop.remove();
2017            dialog.remove();
2018            this.cancelSelection();
2019        });
2020
2021        document.body.appendChild(backdrop);
2022        document.body.appendChild(dialog);
2023    }
2024
2025    /**
2026     * Remove all selection overlays
2027     */
2028    removeOverlays() {
2029        this.overlays.forEach((overlay) => {
2030            overlay.remove();
2031        });
2032        this.overlays.clear();
2033
2034        // Also remove instruction element if it exists (appended to body)
2035        if (this.instructionElement) {
2036            this.instructionElement.remove();
2037            this.instructionElement = null;
2038        }
2039    }
2040
2041    /**
2042     * Check if OpenCV is truly loaded and ready to use
2043     * @returns {boolean}
2044     */
2045    isOpenCVReady() {
2046        try {
2047            // Check if cv global exists and has key functions
2048            return typeof cv !== 'undefined' &&
2049                   typeof cv.Mat === 'function' &&
2050                   typeof cv.getBuildInformation === 'function';
2051        } catch (e) {
2052            return false;
2053        }
2054    }
2055
2056    /**
2057     * Load OpenCV.js on demand
2058     */
2059    async loadOpenCV() {
2060        if (this.isOpenCVLoaded && this.isOpenCVReady()) return true;
2061        if (this.isLoadingOpenCV) {
2062            // Wait for existing load with timeout
2063            return new Promise((resolve) => {
2064                let waited = 0;
2065                const check = setInterval(() => {
2066                    waited += 100;
2067                    if (this.isOpenCVLoaded) {
2068                        clearInterval(check);
2069                        resolve(true);
2070                    } else if (waited > 30000) {
2071                        clearInterval(check);
2072                        resolve(false);
2073                    }
2074                }, 100);
2075            });
2076        }
2077
2078        this.isLoadingOpenCV = true;
2079
2080        // Show loading indicator
2081        const loading = document.createElement('div');
2082        loading.className = 'plate-enhancer-loading-opencv';
2083        loading.innerHTML = `
2084            <div class="spinner"></div>
2085            <p>Loading image processing library...</p>
2086            <p style="font-size: 12px; color: var(--text-secondary, #888); margin-top: 8px;">First time may take 10-20 seconds</p>
2087        `;
2088        document.body.appendChild(loading);
2089
2090        try {
2091            await new Promise((resolve, reject) => {
2092                // Timeout after 30 seconds
2093                const timeout = setTimeout(() => {
2094                    reject(new Error('OpenCV.js load timeout'));
2095                }, 30000);
2096
2097                const script = document.createElement('script');
2098                script.src = 'https://docs.opencv.org/4.8.0/opencv.js';
2099                script.async = true;
2100
2101                script.onload = () => {
2102                    // OpenCV.js sets up a cv object but needs to be initialized
2103                    if (typeof cv !== 'undefined') {
2104                        if (cv.getBuildInformation) {
2105                            // Already ready
2106                            clearTimeout(timeout);
2107                            resolve();
2108                        } else {
2109                            // Wait for onRuntimeInitialized
2110                            cv.onRuntimeInitialized = () => {
2111                                clearTimeout(timeout);
2112                                resolve();
2113                            };
2114                        }
2115                    } else {
2116                        clearTimeout(timeout);
2117                        reject(new Error('OpenCV.js failed to load'));
2118                    }
2119                };
2120
2121                script.onerror = () => {
2122                    clearTimeout(timeout);
2123                    reject(new Error('Failed to load OpenCV.js'));
2124                };
2125                document.head.appendChild(script);
2126            });
2127
2128            this.isOpenCVLoaded = true;
2129            console.log('[PlateEnhancer] OpenCV.js loaded successfully');
2130            return true;
2131        } catch (error) {
2132            console.error('[PlateEnhancer] Failed to load OpenCV.js:', error);
2133            return false;
2134        } finally {
2135            loading.remove();
2136            this.isLoadingOpenCV = false;
2137        }
2138    }
2139
2140    /**
2141     * Start the enhancement processing
2142     * @param {number} startTime - Start time in seconds
2143     * @param {number} endTime - End time in seconds
2144     * @param {boolean} enableMLUpscale - Whether to apply super-resolution upscaling
2145     * @param {number} upscaleScale - Scale factor for upscaling (2, 3, or 4)
2146     */
2147    async startProcessing(startTime, endTime, enableMLUpscale = false, upscaleScale = 2) {
2148        this.removeOverlays();
2149        this.isProcessing = true;
2150        this.shouldCancel = false;
2151
2152        // Clear previous enhancement data (new extraction = new original)
2153        this.lastExtraction = null;
2154        this.lastEnhanced = null;
2155        this.originalEnhanced = null;
2156
2157        // Ensure OpenCV is loaded before proceeding
2158        if (!this.isOpenCVReady()) {
2159            console.log('[PlateEnhancer] OpenCV not ready, loading...');
2160            const loaded = await this.loadOpenCV();
2161            if (!loaded || !this.isOpenCVReady()) {
2162                this.showError('Failed to load image processing library. Please try again.');
2163                this.isProcessing = false;
2164                return;
2165            }
2166        }
2167
2168        // Save current video position to restore after extraction
2169        // Use ?? instead of || to handle time=0 correctly (0 is falsy but valid)
2170        const vp = window.app?.videoPlayer;
2171        const savedTime = vp?.getCurrentAbsoluteTime?.() ?? vp?.getCurrentTime?.() ?? 0;
2172        console.log(`[PlateEnhancer] Saving video position: ${savedTime.toFixed(2)}s`);
2173
2174        // Show progress modal
2175        const backdrop = document.createElement('div');
2176        backdrop.className = 'plate-enhancer-dialog-backdrop';
2177
2178        const progress = document.createElement('div');
2179        progress.className = 'plate-enhancer-progress';
2180        progress.innerHTML = `
2181            <h3>Enhancing License Plate</h3>
2182            <div class="plate-enhancer-progress-status">Preparing...</div>
2183            <div class="plate-enhancer-progress-bar">
2184                <div class="plate-enhancer-progress-fill" style="width: 0%"></div>
2185            </div>
2186            <div class="plate-enhancer-progress-percent">0%</div>
2187            <div class="plate-enhancer-progress-cancel">
2188                <button class="plate-enhancer-btn plate-enhancer-btn-secondary">Cancel</button>
2189            </div>
2190        `;
2191
2192        progress.querySelector('button').addEventListener('click', () => {
2193            this.shouldCancel = true;
2194        });
2195
2196        document.body.appendChild(backdrop);
2197        document.body.appendChild(progress);
2198
2199        const updateProgress = (status, percent) => {
2200            progress.querySelector('.plate-enhancer-progress-status').textContent = status;
2201            progress.querySelector('.plate-enhancer-progress-fill').style.width = `${percent}%`;
2202            progress.querySelector('.plate-enhancer-progress-percent').textContent = `${Math.round(percent)}%`;
2203        };
2204
2205        try {
2206            // Import the tracker and stacker modules dynamically
2207            const tracker = new RegionTracker();
2208            const stacker = new FrameStacker();
2209
2210            // Enable ML upscaling if requested (using ESRGAN-Thick for best quality)
2211            if (enableMLUpscale) {
2212                stacker.enableMLUpscale(true, upscaleScale, 'thick');
2213                stacker.postProcessSharpening = true; // Crisp text edges
2214                stacker.enablePreProcessing = true; // MAX QUALITY: contrast + sharpening before ESRGAN
2215            }
2216
2217            // ALWAYS use real multi-frame stacking - dramatically improves low-light results
2218            stacker.useRealStacking = true;
2219            stacker.stackingMethod = 'sigma-mean'; // Best: combines data while rejecting outliers
2220
2221            // Extract frames and track regions
2222            const results = await this.extractAndTrack(startTime, endTime, updateProgress, tracker);
2223
2224            if (this.shouldCancel) {
2225                throw new Error('Cancelled');
2226            }
2227
2228            // Get video enhancement settings (brightness, contrast, saturation)
2229            const videoEnhancements = this.getVideoEnhancementSettings();
2230
2231            // Store extraction data for re-processing (manual frame selection, region adjustment)
2232            this.lastExtraction = {
2233                regions: results.regions,
2234                frames: results.frames,
2235                settings: {
2236                    enableMLUpscale,
2237                    upscaleScale,
2238                    videoEnhancements,
2239                    startTime,
2240                    endTime
2241                }
2242            };
2243
2244            // Process frames through stacker
2245            updateProgress(`Ranking ${results.regions.length} frames by sharpness...`, 78);
2246            await new Promise(r => setTimeout(r, 47)); // Let UI update
2247
2248            const enhanced = await stacker.process(results.regions, results.frames, (status, pct) => {
2249                updateProgress(status, 78 + pct * 0.2); // 78-98%
2250            }, videoEnhancements);
2251
2252            if (this.shouldCancel) {
2253                throw new Error('Cancelled');
2254            }
2255
2256            // Store enhanced result
2257            this.lastEnhanced = enhanced;
2258            // Also store as original (never overwritten during reprocessing)
2259            // This preserves all frames so user can try different selections
2260            this.originalEnhanced = enhanced;
2261
2262            updateProgress('Complete!', 100);
2263
2264            // Clean up progress modal
2265            backdrop.remove();
2266            progress.remove();
2267
2268            // Show results with re-processing options
2269            await this.showResults(enhanced, true);
2270
2271        } catch (error) {
2272            backdrop.remove();
2273            progress.remove();
2274
2275            if (error.message !== 'Cancelled') {
2276                console.error('[PlateEnhancer] Processing error:', error);
2277                this.showError(`Enhancement failed: ${error.message}`);
2278            }
2279        } finally {
2280            this.isProcessing = false;
2281            this.isSelecting = false;
2282            this.activeCamera = null;
2283            this.selections.clear();
2284            document.removeEventListener('keydown', this.handleKeyDown);
2285
2286            // Restore video position after extraction
2287            if (vp && savedTime !== undefined) {
2288                console.log(`[PlateEnhancer] Restoring video position: ${savedTime.toFixed(2)}s`);
2289                try {
2290                    if (vp.seekToEventTime) {
2291                        await vp.seekToEventTime(savedTime);
2292                    }
2293                } catch (e) {
2294                    console.warn('[PlateEnhancer] Could not restore video position:', e);
2295                }
2296            }
2297        }
2298    }
2299
2300    /**
2301     * Generate time samples with variable density - dense near reference, sparse far away
2302     * @param {number} referenceTime - Center point (where user made selection)
2303     * @param {number} startTime - Start of range
2304     * @param {number} endTime - End of range
2305     * @returns {Array} Array of time values to sample
2306     */
2307    generateVariableDensitySamples(referenceTime, startTime, endTime) {
2308        const samples = new Set();
2309        samples.add(referenceTime); // Always include reference
2310
2311        // Capture EVERY frame at 36fps across the entire time range
2312        // Tesla cameras record at 36fps, so this gets every possible frame
2313        const frameInterval = 1 / 36;
2314
2315        // Add small buffer to endTime to avoid sampling exactly at video end
2316        // This prevents seek issues when at the tail of the video
2317        const safeEndTime = endTime - 0.05; // 50ms buffer from end
2318
2319        // Sample from start to end at native framerate
2320        for (let time = startTime; time <= safeEndTime; time += frameInterval) {
2321            samples.add(time);
2322        }
2323
2324        console.log(`[PlateEnhancer] Capturing every frame: ${samples.size} samples over ${(safeEndTime - startTime).toFixed(1)}s (ref: ${referenceTime.toFixed(2)}s)`);
2325
2326        // Sort samples chronologically
2327        return Array.from(samples).sort((a, b) => a - b);
2328    }
2329
2330    /**
2331     * Extract frames and track region across time
2332     * Tracks from reference frame (where user drew box) both forward and backward
2333     * Uses variable density sampling - dense near reference, sparse further away
2334     */
2335    async extractAndTrack(startTime, endTime, updateProgress, tracker) {
2336        // Get reference time (where user drew the selection box)
2337        // Clamp to be within the time range - user may have drawn selection outside IN/OUT marks
2338        // Use ?? instead of || to handle referenceTime of 0 correctly (0 is falsy but valid)
2339        let referenceTime = this.referenceTime ?? ((startTime + endTime) / 2);
2340        if (referenceTime < startTime) {
2341            console.log(`[PlateEnhancer] Reference time ${referenceTime.toFixed(2)}s is before start ${startTime.toFixed(2)}s, using start`);
2342            referenceTime = startTime;
2343        } else if (referenceTime > endTime) {
2344            console.log(`[PlateEnhancer] Reference time ${referenceTime.toFixed(2)}s is after end ${endTime.toFixed(2)}s, using end`);
2345            referenceTime = endTime;
2346        }
2347
2348        // Generate variable density time samples
2349        const allTimeSamples = this.generateVariableDensitySamples(referenceTime, startTime, endTime);
2350        const forwardSamples = allTimeSamples.filter(t => t >= referenceTime);
2351        const backwardSamples = allTimeSamples.filter(t => t <= referenceTime).reverse(); // ref first, then earlier times
2352
2353        console.log(`[PlateEnhancer] Variable density sampling: ${allTimeSamples.length} total samples`);
2354        console.log(`[PlateEnhancer] Zone breakdown: ~${Math.round(0.5 * 36 * 2)} near (±0.5s @36fps), ~${Math.round(1.5 * 15 * 2)} mid (±2s @15fps), rest sparse (@6fps)`);
2355
2356        const allRegions = [];
2357        const allFrames = [];
2358
2359        let cameraIndex = 0;
2360        const totalCameras = this.selections.size;
2361
2362        for (const [cameraId, selection] of this.selections) {
2363            const cameraName = this.getCameraDisplayName(cameraId);
2364            const cameraBaseProgress = (cameraIndex / totalCameras) * 75;
2365            const cameraProgressRange = 75 / totalCameras;
2366
2367            const video = this.getVideoElement(cameraId);
2368            if (!video) continue;
2369
2370            // Convert selection from container coords to video coords
2371            const videoSelection = this.containerToVideoCoords(selection);
2372
2373            // Extract reference frame first
2374            updateProgress(`${cameraName}: Extracting reference frame...`, cameraBaseProgress);
2375            const refFrame = await this.extractFrame(video, referenceTime, cameraId);
2376            if (!refFrame) {
2377                console.warn(`[PlateEnhancer] Could not extract reference frame for ${cameraId}`);
2378                cameraIndex++;
2379                continue;
2380            }
2381
2382            // Build frame list using variable density samples
2383            const forwardFrames = [];
2384            const backwardFrames = [];
2385            let extractedCount = 0;
2386            const totalFramesToExtract = forwardSamples.length + backwardSamples.length - 1; // -1 because ref is in both
2387
2388            // Forward frames (reference to end) - includes reference frame
2389            for (let i = 0; i < forwardSamples.length; i++) {
2390                if (this.shouldCancel) return { regions: [], frames: [] };
2391                const time = forwardSamples[i];
2392
2393                const frame = i === 0 ? refFrame : await this.extractFrame(video, time, cameraId);
2394                if (frame) {
2395                    forwardFrames.push({ imageData: frame, time, cameraId });
2396                }
2397                extractedCount++;
2398
2399                // Update progress during extraction
2400                if (extractedCount % 10 === 0) {
2401                    const extractProgress = (extractedCount / totalFramesToExtract) * 0.5;
2402                    updateProgress(`${cameraName}: Extracting frames (${extractedCount}/${totalFramesToExtract})...`,
2403                        cameraBaseProgress + extractProgress * cameraProgressRange);
2404                }
2405            }
2406
2407            // Backward frames: start from reference and go backwards
2408            backwardFrames.push({ imageData: refFrame, time: referenceTime, cameraId }); // Start with reference
2409
2410            for (let i = 1; i < backwardSamples.length; i++) {
2411                if (this.shouldCancel) return { regions: [], frames: [] };
2412                const time = backwardSamples[i];
2413
2414                const frame = await this.extractFrame(video, time, cameraId);
2415                if (frame) {
2416                    backwardFrames.push({ imageData: frame, time, cameraId });
2417                }
2418                extractedCount++;
2419
2420                // Update progress during extraction
2421                if (extractedCount % 10 === 0) {
2422                    const extractProgress = (extractedCount / totalFramesToExtract) * 0.5;
2423                    updateProgress(`${cameraName}: Extracting frames (${extractedCount}/${totalFramesToExtract})...`,
2424                        cameraBaseProgress + extractProgress * cameraProgressRange);
2425                }
2426            }
2427
2428            // Tracking phase
2429            const trackingStart = cameraBaseProgress + 0.55 * cameraProgressRange;
2430            const trackingRange = 0.35 * cameraProgressRange;
2431
2432            updateProgress(`${cameraName}: Tracking forward (${forwardFrames.length} frames)...`, trackingStart);
2433            const forwardTracked = await tracker.track(forwardFrames, videoSelection, (pct) => {
2434                updateProgress(`${cameraName}: Tracking forward (${Math.round(pct * forwardFrames.length)}/${forwardFrames.length})...`,
2435                    trackingStart + pct * trackingRange * 0.5);
2436            });
2437
2438            updateProgress(`${cameraName}: Tracking backward (${backwardFrames.length} frames)...`,
2439                trackingStart + trackingRange * 0.5);
2440            const backwardTracked = await tracker.track(backwardFrames, videoSelection, (pct) => {
2441                updateProgress(`${cameraName}: Tracking backward (${Math.round(pct * backwardFrames.length)}/${backwardFrames.length})...`,
2442                    trackingStart + trackingRange * 0.5 + pct * trackingRange * 0.5);
2443            });
2444
2445            updateProgress(`${cameraName}: Cropping regions...`,
2446                cameraBaseProgress + 0.9 * cameraProgressRange);
2447
2448            // Combine results chronologically:
2449            // backwardTracked = [ref, ref-1, ref-2, ...] -> reverse to [ref-2, ref-1, ref]
2450            // forwardTracked = [ref, ref+1, ref+2, ...]
2451            // Combined = [ref-2, ref-1, ref, ref+1, ref+2, ...] (but ref appears twice)
2452
2453            const backwardReversed = backwardTracked.slice().reverse(); // [oldest, ..., ref]
2454            const backwardFramesReversed = backwardFrames.slice().reverse(); // [oldest, ..., ref]
2455
2456            // Remove duplicate reference from backward (it's also in forward)
2457            const allTracked = [...backwardReversed.slice(0, -1), ...forwardTracked];
2458            const allCameraFrames = [...backwardFramesReversed.slice(0, -1), ...forwardFrames];
2459
2460            // Crop tracked regions from frames, validating each region
2461            let croppedCount = 0;
2462            let droppedCount = 0;
2463            for (let i = 0; i < allCameraFrames.length; i++) {
2464                const region = allTracked[i];
2465                if (region) {
2466                    // Validate region hasn't drifted too far or changed size too much
2467                    // This catches cases where tracking latched onto a different object
2468                    const isValid = this.validateTrackedRegion(region, videoSelection);
2469
2470                    if (!isValid) {
2471                        droppedCount++;
2472                        continue; // Skip this frame - likely tracking the wrong object
2473                    }
2474
2475                    const cropped = this.cropRegion(allCameraFrames[i].imageData, region);
2476                    if (cropped) {
2477                        allRegions.push({
2478                            imageData: cropped,
2479                            time: allCameraFrames[i].time,
2480                            cameraId: allCameraFrames[i].cameraId,
2481                            region: region
2482                        });
2483                        croppedCount++;
2484                    }
2485                }
2486            }
2487
2488            if (droppedCount > 0) {
2489                console.log(`[PlateEnhancer] ${cameraName}: Dropped ${droppedCount} frames (tracking drift detected)`);
2490            }
2491
2492            console.log(`[PlateEnhancer] ${cameraName}: Extracted ${allCameraFrames.length} frames, cropped ${croppedCount} regions`);
2493            allFrames.push(...allCameraFrames);
2494            cameraIndex++;
2495        }
2496
2497        return { regions: allRegions, frames: allFrames };
2498    }
2499
2500    /**
2501     * Get video element for camera ID
2502     */
2503    getVideoElement(cameraId) {
2504        const mapping = {
2505            'front': 'videoFront',
2506            'back': 'videoBack',
2507            'left_repeater': 'videoLeft',
2508            'right_repeater': 'videoRight',
2509            'left_pillar': 'videoLeftPillar',
2510            'right_pillar': 'videoRightPillar'
2511        };
2512        return document.getElementById(mapping[cameraId]);
2513    }
2514
2515    /**
2516     * Extract a single frame from video at given absolute event time
2517     * Uses app's seekToEventTime to handle clip boundaries properly
2518     */
2519    async extractFrame(video, time, cameraId) {
2520        const vp = window.app?.videoPlayer;
2521
2522        // Validate time is within bounds (prevent seeking past video end)
2523        if (vp) {
2524            // Use cached duration (synchronous) to avoid slow async call in loop
2525            let totalDuration = null;
2526            if (vp.cachedClipDurations?.length > 0) {
2527                totalDuration = vp.cachedClipDurations.reduce((a, b) => a + b, 0);
2528            }
2529            if (totalDuration && Number.isFinite(totalDuration) && totalDuration > 0) {
2530                // Clamp time to be within valid range with 0.1s buffer from end
2531                const maxValidTime = Math.max(0, totalDuration - 0.1);
2532                if (time > maxValidTime) {
2533                    console.log(`[PlateEnhancer] extractFrame: Clamping time ${time.toFixed(2)}s to ${maxValidTime.toFixed(2)}s (duration: ${totalDuration.toFixed(2)}s)`);
2534                    time = maxValidTime;
2535                }
2536                if (time < 0) {
2537                    time = 0;
2538                }
2539            }
2540        }
2541
2542        // Use the app's seek functionality to handle clip boundaries
2543        if (vp && vp.seekToEventTime) {
2544            try {
2545                // seekToEventTime loads the right clip and sets currentTime
2546                await vp.seekToEventTime(time);
2547
2548                // Get the video element (may have changed if clip was loaded)
2549                const currentVideo = this.getVideoElement(cameraId);
2550                if (!currentVideo) {
2551                    console.warn(`[PlateEnhancer] No video element found for ${cameraId}`);
2552                    return null;
2553                }
2554
2555                // Wait for the seek to actually complete (seeked event)
2556                // We must wait because seekToEventTime sets currentTime but doesn't wait for seek completion
2557                await new Promise((resolve) => {
2558                    let resolved = false;
2559
2560                    const cleanup = () => {
2561                        currentVideo.removeEventListener('seeked', onSeeked);
2562                        currentVideo.removeEventListener('canplay', onCanPlay);
2563                        clearTimeout(timeout);
2564                    };
2565
2566                    const onSeeked = () => {
2567                        if (resolved) return;
2568                        resolved = true;
2569                        cleanup();
2570                        resolve();
2571                    };
2572
2573                    const onCanPlay = () => {
2574                        if (resolved) return;
2575                        resolved = true;
2576                        cleanup();
2577                        resolve();
2578                    };
2579
2580                    currentVideo.addEventListener('seeked', onSeeked);
2581                    currentVideo.addEventListener('canplay', onCanPlay);
2582
2583                    // Timeout after 3 seconds
2584                    const timeout = setTimeout(() => {
2585                        if (resolved) return;
2586                        resolved = true;
2587                        cleanup();
2588                        console.warn(`[PlateEnhancer] Seek timeout for ${cameraId}, proceeding anyway`);
2589                        resolve();
2590                    }, 3000);
2591
2592                    // If video is not currently seeking and is ready, check after a short delay
2593                    // (the seeked event may have already fired before we added listeners)
2594                    setTimeout(() => {
2595                        if (!resolved && !currentVideo.seeking && currentVideo.readyState >= 2) {
2596                            resolved = true;
2597                            cleanup();
2598                            resolve();
2599                        }
2600                    }, 100);
2601                });
2602
2603                // Extra small delay to ensure frame is painted
2604                await new Promise(r => setTimeout(r, 30));
2605
2606                // Verify video is ready
2607                if (currentVideo.readyState < 2 || currentVideo.videoWidth === 0) {
2608                    console.warn(`[PlateEnhancer] Video not ready for ${cameraId}: readyState=${currentVideo.readyState}, size=${currentVideo.videoWidth}x${currentVideo.videoHeight}`);
2609                    return null;
2610                }
2611
2612                // Draw to canvas
2613                const canvas = document.createElement('canvas');
2614                canvas.width = currentVideo.videoWidth;
2615                canvas.height = currentVideo.videoHeight;
2616                const ctx = canvas.getContext('2d');
2617
2618                // Apply video enhancements to source frames if enabled
2619                if (this.applyVideoEnhancementsToSource) {
2620                    const settings = this.getVideoEnhancementSettings();
2621                    this.applyVideoEnhancementsToCanvas(ctx, settings);
2622                }
2623
2624                ctx.drawImage(currentVideo, 0, 0);
2625
2626                // Reset filter for subsequent operations
2627                ctx.filter = 'none';
2628
2629                return ctx.getImageData(0, 0, canvas.width, canvas.height);
2630            } catch (error) {
2631                console.error(`[PlateEnhancer] Seek error:`, error);
2632                return null;
2633            }
2634        }
2635
2636        // Fallback: direct seek (only works within current clip)
2637        // Store reference to this for callback
2638        const self = this;
2639
2640        return new Promise((resolve) => {
2641            video.currentTime = time;
2642
2643            const onSeeked = () => {
2644                video.removeEventListener('seeked', onSeeked);
2645
2646                // Draw to canvas
2647                const canvas = document.createElement('canvas');
2648                canvas.width = video.videoWidth;
2649                canvas.height = video.videoHeight;
2650                const ctx = canvas.getContext('2d');
2651
2652                // Apply video enhancements to source frames if enabled
2653                if (self.applyVideoEnhancementsToSource) {
2654                    const settings = self.getVideoEnhancementSettings();
2655                    self.applyVideoEnhancementsToCanvas(ctx, settings);
2656                }
2657
2658                ctx.drawImage(video, 0, 0);
2659
2660                // Reset filter
2661                ctx.filter = 'none';
2662
2663                resolve(ctx.getImageData(0, 0, canvas.width, canvas.height));
2664            };
2665
2666            video.addEventListener('seeked', onSeeked);
2667
2668            // Timeout fallback
2669            setTimeout(() => {
2670                video.removeEventListener('seeked', onSeeked);
2671                resolve(null);
2672            }, 2000);
2673        });
2674    }
2675
2676    /**
2677     * Crop a region from image data
2678     */
2679    cropRegion(imageData, region) {
2680        const { x, y, width, height } = region;
2681
2682        // Validate region bounds
2683        if (x < 0 || y < 0 || x + width > imageData.width || y + height > imageData.height) {
2684            return null;
2685        }
2686
2687        const canvas = document.createElement('canvas');
2688        canvas.width = width;
2689        canvas.height = height;
2690        const ctx = canvas.getContext('2d');
2691
2692        // Create temporary canvas with source image
2693        const srcCanvas = document.createElement('canvas');
2694        srcCanvas.width = imageData.width;
2695        srcCanvas.height = imageData.height;
2696        const srcCtx = srcCanvas.getContext('2d');
2697        srcCtx.putImageData(imageData, 0, 0);
2698
2699        // Crop
2700        ctx.drawImage(srcCanvas, x, y, width, height, 0, 0, width, height);
2701
2702        return ctx.getImageData(0, 0, width, height);
2703    }
2704
2705    /**
2706     * Validate that a tracked region hasn't drifted too far from the original selection
2707     * This catches cases where optical flow tracking latched onto a different object
2708     * @param {Object} region - The tracked region { x, y, width, height }
2709     * @param {Object} originalSelection - The original user selection in video coordinates
2710     * @returns {boolean} - True if region is valid, false if it should be dropped
2711     */
2712    validateTrackedRegion(region, originalSelection) {
2713        if (!region || !originalSelection) return false;
2714
2715        // 1. Check if region center has moved too far from original center
2716        const originalCenterX = originalSelection.x + originalSelection.width / 2;
2717        const originalCenterY = originalSelection.y + originalSelection.height / 2;
2718        const regionCenterX = region.x + region.width / 2;
2719        const regionCenterY = region.y + region.height / 2;
2720
2721        const dx = Math.abs(regionCenterX - originalCenterX);
2722        const dy = Math.abs(regionCenterY - originalCenterY);
2723
2724        // Allow movement up to 3x the original selection size
2725        // This accommodates vehicles moving across frame over multiple seconds
2726        const maxMoveX = originalSelection.width * 3;
2727        const maxMoveY = originalSelection.height * 3;
2728
2729        if (dx > maxMoveX || dy > maxMoveY) {
2730            return false; // Center has moved too far - likely tracking wrong object
2731        }
2732
2733        // 2. Check if region size has changed too dramatically
2734        const widthRatio = region.width / originalSelection.width;
2735        const heightRatio = region.height / originalSelection.height;
2736
2737        // Allow size to vary between 0.25x and 4x original
2738        // Vehicles can grow as they approach or shrink as they recede
2739        if (widthRatio < 0.25 || widthRatio > 4.0 || heightRatio < 0.25 || heightRatio > 4.0) {
2740            return false; // Size changed too much - likely tracking wrong object
2741        }
2742
2743        // 3. Check aspect ratio hasn't changed dramatically
2744        const originalAspect = originalSelection.width / originalSelection.height;
2745        const regionAspect = region.width / region.height;
2746        const aspectRatio = regionAspect / originalAspect;
2747
2748        // Aspect ratio should stay within 0.5x to 2x
2749        if (aspectRatio < 0.5 || aspectRatio > 2.0) {
2750            return false; // Aspect ratio changed too much
2751        }
2752
2753        return true;
2754    }
2755
2756    /**
2757     * Show results modal
2758     * @param {Object} enhanced - Enhanced results
2759     * @param {boolean} canReprocess - Whether re-processing is available
2760     */
2761    async showResults(enhanced, canReprocess = false) {
2762        // Check if user is Pro - free users get watermarked images
2763        // Per spec NMC-4702
2764        const isPro = await this.isProUser();
2765
2766        // For free users, watermark all displayed images to prevent right-click save
2767        if (!isPro) {
2768            console.log('[PlateEnhancer] Free user - applying watermarks to displayed images');
2769
2770            // Watermark combined result
2771            if (enhanced.combined?.dataUrl) {
2772                enhanced.combined.displayUrl = await this.addWatermark(enhanced.combined.dataUrl);
2773            }
2774
2775            // Watermark method results (including upscaled versions)
2776            if (enhanced.methods) {
2777                for (const key of Object.keys(enhanced.methods)) {
2778                    if (enhanced.methods[key]?.dataUrl) {
2779                        enhanced.methods[key].displayUrl = await this.addWatermark(enhanced.methods[key].dataUrl);
2780                    }
2781                    // Also watermark upscaled version if available
2782                    if (enhanced.methods[key]?.upscaledDataUrl) {
2783                        enhanced.methods[key].upscaledDisplayUrl = await this.addWatermark(enhanced.methods[key].upscaledDataUrl);
2784                    }
2785                }
2786            }
2787
2788            // Watermark upscaled
2789            if (enhanced.upscaled?.dataUrl) {
2790                enhanced.upscaled.displayUrl = await this.addWatermark(enhanced.upscaled.dataUrl);
2791            }
2792
2793            // Watermark top frames
2794            if (enhanced.topFrames) {
2795                for (const frame of enhanced.topFrames) {
2796                    if (frame.dataUrl) {
2797                        frame.displayUrl = await this.addWatermark(frame.dataUrl);
2798                    }
2799                }
2800            }
2801        }
2802
2803        // Helper to get display URL (watermarked for free, original for pro)
2804        const getDisplayUrl = (item) => item.displayUrl || item.dataUrl;
2805
2806        const backdrop = document.createElement('div');
2807        backdrop.className = 'plate-enhancer-dialog-backdrop';
2808
2809        const modal = document.createElement('div');
2810        modal.className = 'plate-enhancer-results';
2811
2812        // Create header
2813        const header = document.createElement('div');
2814        header.className = 'plate-enhancer-results-header';
2815        header.innerHTML = `
2816            <h3>Enhancement Results</h3>
2817            <button class="plate-enhancer-close-btn">
2818                <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
2819                    <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
2820                </svg>
2821            </button>
2822        `;
2823        modal.appendChild(header);
2824
2825        // Main preview
2826        const mainPreview = document.createElement('div');
2827        mainPreview.className = 'plate-enhancer-main-preview';
2828        const mainImg = document.createElement('img');
2829        mainImg.src = getDisplayUrl(enhanced.combined);
2830        mainPreview.appendChild(mainImg);
2831
2832        const mainLabel = document.createElement('div');
2833        mainLabel.className = 'plate-enhancer-main-preview-label';
2834        mainLabel.textContent = 'Combined Result (click thumbnails below to compare)';
2835        mainPreview.appendChild(mainLabel);
2836        modal.appendChild(mainPreview);
2837
2838        // OCR Section with ensemble OCR
2839        const ocrSection = document.createElement('div');
2840        ocrSection.className = 'plate-enhancer-ocr-section';
2841        ocrSection.innerHTML = `
2842            <div class="plate-enhancer-ocr-header">
2843                <span class="plate-enhancer-ocr-label">
2844                    <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;">
2845                        <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
2846                    </svg>
2847                    AI Detected Text
2848                </span>
2849                <span class="plate-enhancer-ocr-status">Initializing...</span>
2850            </div>
2851            <div class="plate-enhancer-ocr-progress" style="display: none;">
2852                <div class="plate-enhancer-ocr-progress-bar"></div>
2853                <span class="plate-enhancer-ocr-progress-text">Loading OCR model...</span>
2854            </div>
2855            <div class="plate-enhancer-ocr-result">
2856                <span class="plate-enhancer-ocr-text">-</span>
2857                <button class="plate-enhancer-ocr-copy" title="Copy text" style="display: none;">
2858                    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
2859                        <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
2860                    </svg>
2861                </button>
2862            </div>
2863            <div class="plate-enhancer-ocr-warning" style="display: none;">
2864                <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
2865                    <path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
2866                </svg>
2867                <span>Low confidence - verify manually</span>
2868            </div>
2869            <div class="plate-enhancer-ocr-disclaimer">Results may not be accurate - verify manually</div>
2870        `;
2871        modal.appendChild(ocrSection);
2872
2873        // Progress callback for OCR
2874        const updateOCRProgress = (status, progress) => {
2875            const statusEl = ocrSection.querySelector('.plate-enhancer-ocr-status');
2876            const progressContainer = ocrSection.querySelector('.plate-enhancer-ocr-progress');
2877            const progressBar = ocrSection.querySelector('.plate-enhancer-ocr-progress-bar');
2878            const progressText = ocrSection.querySelector('.plate-enhancer-ocr-progress-text');
2879
2880            statusEl.textContent = status;
2881
2882            if (progress !== null && progress >= 0) {
2883                progressContainer.style.display = 'block';
2884                progressBar.style.width = `${Math.round(progress * 100)}%`;
2885                progressText.textContent = `${status} (${Math.round(progress * 100)}%)`;
2886            } else {
2887                progressContainer.style.display = 'none';
2888            }
2889        };
2890
2891        // Store method elements for OCR updates (will be populated later)
2892        let methodElements = {};
2893
2894        // Run ensemble OCR on all enhancement methods
2895        this.runEnsembleOCR(enhanced, updateOCRProgress).then(result => {
2896            const statusEl = ocrSection.querySelector('.plate-enhancer-ocr-status');
2897            const textEl = ocrSection.querySelector('.plate-enhancer-ocr-text');
2898            const copyBtn = ocrSection.querySelector('.plate-enhancer-ocr-copy');
2899            const warningEl = ocrSection.querySelector('.plate-enhancer-ocr-warning');
2900
2901            // Hide progress bar when done
2902            const progressContainer = ocrSection.querySelector('.plate-enhancer-ocr-progress');
2903            if (progressContainer) progressContainer.style.display = 'none';
2904
2905            if (result.error) {
2906                statusEl.textContent = 'OCR unavailable';
2907                textEl.textContent = '-';
2908            } else if (result.text) {
2909                statusEl.textContent = `Confidence: ${result.confidence.toFixed(0)}%`;
2910                textEl.textContent = result.text;
2911                copyBtn.style.display = 'inline-flex';
2912
2913                // Show warning if low confidence
2914                if (result.confidence < 50) {
2915                    warningEl.style.display = 'flex';
2916                }
2917
2918                // Store OCR result for Pro features
2919                this.lastOCRResult = result;
2920
2921                // Copy OCR text (Pro feature)
2922                copyBtn.addEventListener('click', async () => {
2923                    try {
2924                        await navigator.clipboard.writeText(result.text);
2925                        this.showToast('Text copied!');
2926                    } catch (e) {
2927                        console.error('Copy failed:', e);
2928                    }
2929                });
2930
2931                // Update per-method OCR results if available
2932                if (result.methodResults && Object.keys(methodElements).length > 0) {
2933                    const votedText = result.text.replace(/[\s-]/g, '');
2934                    for (const [methodKey, methodResult] of Object.entries(result.methodResults)) {
2935                        const el = methodElements[methodKey];
2936                        if (el) {
2937                            const ocrTextEl = el.querySelector('.ocr-text');
2938                            const ocrConfEl = el.querySelector('.ocr-confidence');
2939                            if (ocrTextEl && methodResult.text) {
2940                                ocrTextEl.textContent = methodResult.text;
2941                                const cleanText = methodResult.text.replace(/[\s-]/g, '');
2942                                const isCorrect = cleanText === votedText;
2943                                ocrTextEl.classList.toggle('correct', isCorrect);
2944                                ocrTextEl.classList.toggle('incorrect', !isCorrect);
2945                                if (ocrConfEl) {
2946                                    ocrConfEl.textContent = `${methodResult.confidence.toFixed(0)}%`;
2947                                }
2948                            } else if (ocrTextEl) {
2949                                ocrTextEl.textContent = '-';
2950                            }
2951                        }
2952                    }
2953                }
2954            } else {
2955                statusEl.textContent = 'No text detected';
2956                textEl.textContent = '-';
2957            }
2958        });
2959
2960        // Enhancement Methods Section (if available)
2961        if (enhanced.methods) {
2962            const methodsSection = document.createElement('div');
2963            methodsSection.className = 'plate-enhancer-methods-section';
2964            methodsSection.innerHTML = `
2965                <div class="plate-enhancer-methods-label">Enhancement Methods (click to preview, 4x badge = upscaled available):</div>
2966                <div class="plate-enhancer-methods-grid"></div>
2967            `;
2968            modal.appendChild(methodsSection);
2969
2970            const methodsGrid = methodsSection.querySelector('.plate-enhancer-methods-grid');
2971            const methodOrder = ['sigmaClipped', 'msrClahe', 'weightedMean', 'bestFrame', 'bilateral', 'ensemble', 'luckyRegions'];
2972            const methodNames = {
2973                sigmaClipped: 'Sigma Clipped',
2974                msrClahe: 'MSR+CLAHE',
2975                weightedMean: 'Weighted Mean',
2976                bestFrame: 'Best Frame',
2977                bilateral: 'Bilateral',
2978                ensemble: 'Ensemble',
2979                luckyRegions: '🎯 Lucky'
2980            };
2981
2982            // Use outer methodElements for OCR updates
2983
2984            methodOrder.forEach((methodKey, index) => {
2985                const method = enhanced.methods[methodKey];
2986                if (!method) return;
2987
2988                const displayUrl = method.displayUrl || method.dataUrl;
2989                const hasUpscaled = !!method.upscaledDataUrl;
2990                const methodThumb = document.createElement('div');
2991                methodThumb.className = 'plate-enhancer-method-thumb' + (methodKey === 'luckyRegions' ? ' lucky-method' : '') + (methodKey === 'ensemble' ? ' selected' : '') + (hasUpscaled ? ' has-upscaled' : '');
2992                methodThumb.dataset.src = method.dataUrl; // Keep original for downloads
2993                methodThumb.dataset.displaySrc = displayUrl; // Watermarked for display
2994                methodThumb.dataset.methodKey = methodKey;
2995                methodThumb.dataset.label = method.name || methodNames[methodKey];
2996                methodThumb.dataset.showingUpscaled = 'false';
2997                if (hasUpscaled) {
2998                    methodThumb.dataset.upscaledSrc = method.upscaledDataUrl;
2999                    methodThumb.dataset.upscaledSharpness = method.upscaledSharpness;
3000                }
3001                methodThumb.innerHTML = `
3002                    <img src="${displayUrl}" alt="${methodNames[methodKey]}">
3003                    <div class="plate-enhancer-method-name">${methodNames[methodKey]}</div>
3004                    ${hasUpscaled ? '<div class="plate-enhancer-upscale-badge" title="Click badge to toggle 4x upscaled">4x</div>' : ''}
3005                    <div class="plate-enhancer-method-ocr">
3006                        <span class="ocr-text">...</span>
3007                        <span class="ocr-confidence"></span>
3008                    </div>
3009                `;
3010                methodElements[methodKey] = methodThumb;
3011                methodsGrid.appendChild(methodThumb);
3012
3013                // Click on 4x badge to toggle upscaled view
3014                const badge = methodThumb.querySelector('.plate-enhancer-upscale-badge');
3015                if (badge) {
3016                    badge.addEventListener('click', (e) => {
3017                        e.stopPropagation();
3018                        const isShowingUpscaled = methodThumb.dataset.showingUpscaled === 'true';
3019                        const img = methodThumb.querySelector('img');
3020                        const nameDiv = methodThumb.querySelector('.plate-enhancer-method-name');
3021
3022                        // Use watermarked version for display (upscaledDisplayUrl), original for downloads (upscaledDataUrl)
3023                        const upscaledDisplay = method.upscaledDisplayUrl || method.upscaledDataUrl;
3024
3025                        if (isShowingUpscaled) {
3026                            // Switch to original
3027                            img.src = displayUrl;
3028                            nameDiv.textContent = methodNames[methodKey];
3029                            badge.classList.remove('active');
3030                            methodThumb.dataset.showingUpscaled = 'false';
3031                            // Update main preview if this is selected
3032                            if (methodThumb.classList.contains('selected')) {
3033                                mainImg.src = displayUrl;
3034                                mainLabel.textContent = `${method.name} (Sharpness: ${method.sharpness.toFixed(1)})`;
3035                            }
3036                        } else {
3037                            // Switch to upscaled (watermarked for display)
3038                            img.src = upscaledDisplay;
3039                            nameDiv.textContent = methodNames[methodKey] + ' 4x';
3040                            badge.classList.add('active');
3041                            methodThumb.dataset.showingUpscaled = 'true';
3042                            // Update main preview if this is selected
3043                            if (methodThumb.classList.contains('selected')) {
3044                                mainImg.src = upscaledDisplay;
3045                                mainLabel.textContent = `${method.name} 4x (Sharpness: ${method.upscaledSharpness?.toFixed(1) || 'N/A'})`;
3046                            }
3047                        }
3048                    });
3049                }
3050
3051                // Click to preview
3052                methodThumb.addEventListener('click', (e) => {
3053                    // Don't trigger if clicking on badge
3054                    if (e.target.classList.contains('plate-enhancer-upscale-badge')) return;
3055
3056                    // Determine which version to show (use watermarked for display)
3057                    const isShowingUpscaled = methodThumb.dataset.showingUpscaled === 'true';
3058                    const upscaledDisplay = method.upscaledDisplayUrl || method.upscaledDataUrl;
3059                    const srcToShow = isShowingUpscaled ? upscaledDisplay : displayUrl;
3060                    const sharpnessToShow = isShowingUpscaled ? method.upscaledSharpness : method.sharpness;
3061                    const labelSuffix = isShowingUpscaled ? ' 4x' : '';
3062
3063                    // Update main preview
3064                    mainImg.src = srcToShow;
3065                    mainLabel.textContent = `${method.name}${labelSuffix} (Sharpness: ${sharpnessToShow?.toFixed(1) || 'N/A'})`;
3066
3067                    // Update selection state
3068                    methodsGrid.querySelectorAll('.plate-enhancer-method-thumb').forEach(t => t.classList.remove('selected'));
3069                    methodThumb.classList.add('selected');
3070
3071                    // Also update thumbnail selection (for consistency)
3072                    thumbsContainer?.querySelectorAll('.plate-enhancer-thumbnail').forEach(t => t.classList.remove('selected'));
3073                });
3074            });
3075        }
3076
3077        // Use originalEnhanced for frame selection (preserves all frames across reprocessing)
3078        // After reprocessing, enhanced.topFrames only has selected frames, but we want all original frames
3079        const allFrames = this.originalEnhanced?.topFrames || enhanced.topFrames;
3080
3081        // Frame selection controls (only if re-processing is available)
3082        let frameSelectionControls = null;
3083        if (canReprocess && this.lastExtraction) {
3084            frameSelectionControls = document.createElement('div');
3085            frameSelectionControls.className = 'plate-enhancer-frame-selection';
3086            frameSelectionControls.innerHTML = `
3087                <div class="plate-enhancer-frame-selection-header">
3088                    <span class="plate-enhancer-frame-selection-icon">🎞️</span>
3089                    <span class="plate-enhancer-frame-selection-label">Manual Frame Selection</span>
3090                </div>
3091                <p class="plate-enhancer-frame-selection-desc">
3092                    Not happy with the result? Uncheck blurry or misaligned frames below, then re-process with only the good ones.
3093                </p>
3094                <div class="plate-enhancer-frame-selection-controls">
3095                    <button class="plate-enhancer-btn-small" data-action="select-all" title="Include all frames - best for Lucky Regions">✓ All</button>
3096                    <button class="plate-enhancer-btn-small" data-action="select-none" title="Deselect all frames">✗ None</button>
3097                    <button class="plate-enhancer-btn-small" data-action="select-top5" title="Select only the 5 sharpest">Top 5</button>
3098                    <button class="plate-enhancer-btn-small" data-action="select-top10" title="Select the 10 sharpest - good balance">Top 10</button>
3099                    <button class="plate-enhancer-btn-small" data-action="select-top15" title="Select the 15 sharpest - great for Lucky">Top 15</button>
3100                    <span class="plate-enhancer-frame-count">0 / ${allFrames.length} selected</span>
3101                    <button class="plate-enhancer-btn plate-enhancer-btn-primary plate-enhancer-btn-reprocess" data-action="reprocess" disabled title="Need at least 2 frames">
3102                        🔄 Re-process Selected
3103                    </button>
3104                </div>
3105            `;
3106            modal.appendChild(frameSelectionControls);
3107        }
3108
3109        // Thumbnails
3110        const thumbsLabel = document.createElement('div');
3111        thumbsLabel.className = 'plate-enhancer-thumbnails-label';
3112        thumbsLabel.textContent = canReprocess ? 'Source frames (click to preview, check to include):' : 'Source frames (ranked by sharpness):';
3113        modal.appendChild(thumbsLabel);
3114
3115        const thumbsContainer = document.createElement('div');
3116        thumbsContainer.className = 'plate-enhancer-thumbnails';
3117
3118        // Add combined as first thumbnail
3119        const combinedThumb = this.createThumbnail(enhanced.combined, 'Combined', 'Stacked', true, false);
3120        combinedThumb.classList.add('combined', 'selected');
3121        thumbsContainer.appendChild(combinedThumb);
3122
3123        // Add upscaled version if available (for comparison)
3124        if (enhanced.upscaled) {
3125            const scale = enhanced.upscaled.scale || 2;
3126            const upscaledThumb = this.createThumbnail(enhanced.upscaled, `${scale}x Upscaled`, 'Super-res', true, false);
3127            upscaledThumb.classList.add('upscaled');
3128            thumbsContainer.appendChild(upscaledThumb);
3129        }
3130
3131        // Add ALL original frames with checkboxes if re-processing available
3132        // This uses allFrames (from originalEnhanced) so frames are never lost after reprocessing
3133        allFrames.forEach((frame, index) => {
3134            // Show sharpness score in sublabel for debugging
3135            const sharpLabel = frame.sharpness ? `${frame.sharpness.toFixed(0)}` : '';
3136            const thumb = this.createThumbnail(frame, `#${index + 1}`, `${frame.cameraId} (${sharpLabel})`, false, canReprocess);
3137            thumb.dataset.frameIndex = index;
3138            thumb.dataset.sharpness = frame.sharpness || 0;
3139            // Pre-select all frames by default
3140            if (canReprocess) {
3141                const checkbox = thumb.querySelector('.plate-enhancer-frame-checkbox');
3142                if (checkbox) checkbox.checked = true;
3143            }
3144            thumbsContainer.appendChild(thumb);
3145        });
3146
3147        modal.appendChild(thumbsContainer);
3148
3149        // Update frame count display
3150        const updateFrameCount = () => {
3151            if (!frameSelectionControls) return;
3152            const checked = thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox:checked').length;
3153            const total = allFrames.length; // Use allFrames (preserved across reprocessing)
3154            frameSelectionControls.querySelector('.plate-enhancer-frame-count').textContent = `${checked} / ${total} selected`;
3155            const reprocessBtn = frameSelectionControls.querySelector('[data-action="reprocess"]');
3156            reprocessBtn.disabled = checked < 2; // Need at least 2 frames
3157            if (checked < 2) {
3158                reprocessBtn.title = 'Select at least 2 frames';
3159            } else {
3160                reprocessBtn.title = '';
3161            }
3162        };
3163
3164        // Frame selection control handlers
3165        if (frameSelectionControls) {
3166            frameSelectionControls.querySelector('[data-action="select-all"]').addEventListener('click', () => {
3167                thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox').forEach(cb => cb.checked = true);
3168                updateFrameCount();
3169            });
3170
3171            frameSelectionControls.querySelector('[data-action="select-none"]').addEventListener('click', () => {
3172                thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox').forEach(cb => cb.checked = false);
3173                updateFrameCount();
3174            });
3175
3176            frameSelectionControls.querySelector('[data-action="select-top5"]').addEventListener('click', () => {
3177                const checkboxes = thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox');
3178                checkboxes.forEach((cb, i) => cb.checked = i < 5);
3179                updateFrameCount();
3180            });
3181
3182            frameSelectionControls.querySelector('[data-action="select-top10"]').addEventListener('click', () => {
3183                const checkboxes = thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox');
3184                checkboxes.forEach((cb, i) => cb.checked = i < 10);
3185                updateFrameCount();
3186            });
3187
3188            frameSelectionControls.querySelector('[data-action="select-top15"]').addEventListener('click', () => {
3189                const checkboxes = thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox');
3190                checkboxes.forEach((cb, i) => cb.checked = i < 15);
3191                updateFrameCount();
3192            });
3193
3194            frameSelectionControls.querySelector('[data-action="reprocess"]').addEventListener('click', () => {
3195                // Get selected frame indices
3196                const selectedIndices = [];
3197                thumbsContainer.querySelectorAll('.plate-enhancer-frame-checkbox:checked').forEach(cb => {
3198                    const thumb = cb.closest('.plate-enhancer-thumbnail');
3199                    if (thumb && thumb.dataset.frameIndex !== undefined) {
3200                        selectedIndices.push(parseInt(thumb.dataset.frameIndex));
3201                    }
3202                });
3203
3204                if (selectedIndices.length < 2) {
3205                    this.showToast('Select at least 2 frames');
3206                    return;
3207                }
3208
3209                // Close current modal and re-process
3210                backdrop.remove();
3211                modal.remove();
3212                this.reprocessWithSelectedFrames(selectedIndices);
3213            });
3214
3215            // Initial count
3216            updateFrameCount();
3217        }
3218
3219        // Handle thumbnail clicks (for preview) and checkbox changes
3220        thumbsContainer.addEventListener('click', (e) => {
3221            // Handle checkbox clicks separately
3222            if (e.target.classList.contains('plate-enhancer-frame-checkbox')) {
3223                e.stopPropagation();
3224                updateFrameCount();
3225                return;
3226            }
3227
3228            const thumb = e.target.closest('.plate-enhancer-thumbnail');
3229            if (thumb) {
3230                thumbsContainer.querySelectorAll('.plate-enhancer-thumbnail').forEach(t => t.classList.remove('selected'));
3231                thumb.classList.add('selected');
3232                // Also clear method thumb selection for consistency
3233                modal.querySelectorAll('.plate-enhancer-method-thumb').forEach(t => t.classList.remove('selected'));
3234                // Use displaySrc (watermarked) for preview, keep src for downloads
3235                mainImg.src = thumb.dataset.displaySrc || thumb.dataset.src;
3236                mainLabel.textContent = thumb.dataset.label;
3237            }
3238        });
3239
3240        // Actions
3241        const actions = document.createElement('div');
3242        actions.className = 'plate-enhancer-results-actions';
3243        actions.innerHTML = `
3244            <div class="plate-enhancer-actions-row">
3245                <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="crop-region" title="Draw a region on the preview to crop">
3246                    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;">
3247                        <path d="M17 15h2V7c0-1.1-.9-2-2-2H9v2h8v8zM7 17V1H5v4H1v2h4v10c0 1.1.9 2 2 2h10v4h2v-4h4v-2H7z"/>
3248                    </svg>
3249                    Crop Region
3250                </button>
3251                <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="save-notes" title="Save OCR result to event notes (Pro)">
3252                    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;">
3253                        <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
3254                    </svg>
3255                    Save to Notes
3256                    <span class="plate-enhancer-pro-badge">PRO</span>
3257                </button>
3258                <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="copy" title="Copy image to clipboard (Pro)">
3259                    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;">
3260                        <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
3261                    </svg>
3262                    Copy Image
3263                    <span class="plate-enhancer-pro-badge">PRO</span>
3264                </button>
3265            </div>
3266            <div class="plate-enhancer-actions-row">
3267                <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="retry-shorter" title="Retry with ±2s timeframe">
3268                    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;">
3269                        <path d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
3270                    </svg>
3271                    Retry Shorter
3272                </button>
3273                <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="detect-another" title="Run detection to find more plates">
3274                    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;">
3275                        <path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z"/>
3276                    </svg>
3277                    Detect Another
3278                </button>
3279                <button class="plate-enhancer-btn plate-enhancer-btn-secondary" data-action="download-all" title="Download all images as ZIP (Pro)">
3280                    Download All (ZIP)
3281                    <span class="plate-enhancer-pro-badge">PRO</span>
3282                </button>
3283                <button class="plate-enhancer-btn plate-enhancer-btn-primary" data-action="download-selected" title="Download selected image (Pro)">
3284                    Download Selected
3285                    <span class="plate-enhancer-pro-badge">PRO</span>
3286                </button>
3287            </div>
3288        `;
3289        modal.appendChild(actions);
3290
3291        // Save to Notes handler (Pro feature)
3292        actions.querySelector('[data-action="save-notes"]')?.addEventListener('click', async () => {
3293            if (!await this.checkProAccess('save to notes')) return;
3294            if (!this.lastOCRResult?.text) {
3295                this.showToast('No OCR result to save');
3296                return;
3297            }
3298            // Save to notes via NotesManager
3299            if (window.app?.notesManager) {
3300                const currentEvent = window.app.eventBrowser?.selectedEvent;
3301                if (currentEvent) {
3302                    const noteText = `License Plate: ${this.lastOCRResult.text} (${this.lastOCRResult.confidence.toFixed(0)}% confidence)`;
3303                    await window.app.notesManager.addNote(currentEvent.id, noteText);
3304                    this.showToast('Saved to notes!');
3305                } else {
3306                    this.showToast('No event selected', 'error');
3307                }
3308            } else {
3309                this.showToast('Notes feature not available', 'error');
3310            }
3311        });
3312
3313        // Retry Shorter handler - reprocess existing frames with subset
3314        actions.querySelector('[data-action="retry-shorter"]')?.addEventListener('click', async () => {
3315            closeModal();
3316
3317            // If we have topFrames from last enhancement, reprocess with shorter subset
3318            if (this.lastEnhanced?.topFrames && this.lastEnhanced.topFrames.length >= 2) {
3319                // Use first 5 frames (or all if less than 5) which are the sharpest
3320                const frameCount = Math.min(5, this.lastEnhanced.topFrames.length);
3321                const selectedIndices = Array.from({ length: frameCount }, (_, i) => i);
3322                await this.reprocessWithSelectedFrames(selectedIndices);
3323            } else {
3324                // No frames available - enter selection mode
3325                this.showToast('Please select a region to enhance', 'info');
3326                this.startSelectionMode();
3327            }
3328        });
3329
3330        // Detect Another handler
3331        actions.querySelector('[data-action="detect-another"]')?.addEventListener('click', async () => {
3332            closeModal();
3333            // Start plate selection mode
3334            await this.autoDetectPlates();
3335        });
3336
3337        // Download All handler (Pro feature)
3338        actions.querySelector('[data-action="download-all"]')?.addEventListener('click', async () => {
3339            if (!await this.checkProAccess('download all images')) return;
3340            await this.downloadAllAsZip(enhanced);
3341        });
3342
3343        // Crop region functionality
3344        let cropOverlay = null;
3345        let cropSelection = null;
3346        let isCropping = false;
3347        let cropEscapeHandler = null;
3348
3349        // Helper to exit crop mode cleanly
3350        const exitCropMode = () => {
3351            if (cropOverlay) cropOverlay.remove();
3352            cropOverlay = null;
3353            cropSelection = null;
3354            isCropping = false;
3355            const cropBtn = actions.querySelector('[data-action="crop-region"]');
3356            cropBtn.classList.remove('crop-mode-active');
3357            cropBtn.innerHTML = `
3358                <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;">
3359                    <path d="M17 15h2V7c0-1.1-.9-2-2-2H9v2h8v8zM7 17V1H5v4H1v2h4v10c0 1.1.9 2 2 2h10v4h2v-4h4v-2H7z"/>
3360                </svg>
3361                Crop Region
3362            `;
3363            mainLabel.textContent = 'Combined Result (click thumbnails below to compare)';
3364            if (cropEscapeHandler) {
3365                document.removeEventListener('keydown', cropEscapeHandler);
3366                cropEscapeHandler = null;
3367            }
3368        };
3369
3370        actions.querySelector('[data-action="crop-region"]').addEventListener('click', () => {
3371            if (isCropping) {
3372                exitCropMode();
3373                return;
3374            }
3375
3376            isCropping = true;
3377            const cropBtn = actions.querySelector('[data-action="crop-region"]');
3378            cropBtn.innerHTML = `
3379                <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;">
3380                    <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
3381                </svg>
3382                Cancel Crop (Esc)
3383            `;
3384            cropBtn.classList.add('crop-mode-active');
3385            mainLabel.textContent = '';
3386
3387            // Create crop overlay with instruction
3388            cropOverlay = document.createElement('div');
3389            cropOverlay.className = 'plate-enhancer-crop-overlay';
3390
3391            // Add instruction banner
3392            const instruction = document.createElement('div');
3393            instruction.className = 'plate-enhancer-crop-instruction';
3394            instruction.innerHTML = `
3395                <div class="crop-instruction-icon">✂️</div>
3396                <div class="crop-instruction-text">
3397                    <strong>Click and drag</strong> to select the area you want to crop
3398                </div>
3399                <div class="crop-instruction-hint">Press <kbd>Esc</kbd> or click the red button to cancel</div>
3400            `;
3401            cropOverlay.appendChild(instruction);
3402
3403            mainPreview.appendChild(cropOverlay);
3404
3405            // Add Escape key handler for crop mode
3406            cropEscapeHandler = (e) => {
3407                if (e.key === 'Escape' && isCropping) {
3408                    e.preventDefault();
3409                    exitCropMode();
3410                }
3411            };
3412            document.addEventListener('keydown', cropEscapeHandler);
3413
3414            let startX, startY, selectionBox;
3415
3416            cropOverlay.addEventListener('mousedown', (e) => {
3417                // Hide instruction banner when user starts drawing
3418                const instructionEl = cropOverlay.querySelector('.plate-enhancer-crop-instruction');
3419                if (instructionEl) instructionEl.remove();
3420
3421                const rect = cropOverlay.getBoundingClientRect();
3422                startX = e.clientX - rect.left;
3423                startY = e.clientY - rect.top;
3424
3425                // Remove existing selection
3426                if (selectionBox) selectionBox.remove();
3427
3428                selectionBox = document.createElement('div');
3429                selectionBox.className = 'plate-enhancer-crop-selection';
3430                selectionBox.style.left = startX + 'px';
3431                selectionBox.style.top = startY + 'px';
3432                cropOverlay.appendChild(selectionBox);
3433
3434                const onMouseMove = (e) => {
3435                    const currentX = e.clientX - rect.left;
3436                    const currentY = e.clientY - rect.top;
3437
3438                    const x = Math.min(startX, currentX);
3439                    const y = Math.min(startY, currentY);
3440                    const width = Math.abs(currentX - startX);
3441                    const height = Math.abs(currentY - startY);
3442
3443                    selectionBox.style.left = x + 'px';
3444                    selectionBox.style.top = y + 'px';
3445                    selectionBox.style.width = width + 'px';
3446                    selectionBox.style.height = height + 'px';
3447                };
3448
3449                const onMouseUp = (e) => {
3450                    document.removeEventListener('mousemove', onMouseMove);
3451                    document.removeEventListener('mouseup', onMouseUp);
3452
3453                    const currentX = e.clientX - rect.left;
3454                    const currentY = e.clientY - rect.top;
3455
3456                    const x = Math.min(startX, currentX);
3457                    const y = Math.min(startY, currentY);
3458                    const width = Math.abs(currentX - startX);
3459                    const height = Math.abs(currentY - startY);
3460
3461                    if (width > 10 && height > 10) {
3462                        // Valid selection - add apply button
3463                        // Get the actual image rect (may be letterboxed within overlay)
3464                        const imgRect = mainImg.getBoundingClientRect();
3465                        cropSelection = {
3466                            x, y, width, height,
3467                            overlayWidth: rect.width,
3468                            overlayHeight: rect.height,
3469                            imgRect: { left: imgRect.left, top: imgRect.top, width: imgRect.width, height: imgRect.height },
3470                            overlayRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
3471                        };
3472
3473                        // Add button container to selection
3474                        const btnContainer = document.createElement('div');
3475                        btnContainer.className = 'plate-enhancer-crop-buttons';
3476
3477                        // Cancel button
3478                        const cancelBtn = document.createElement('button');
3479                        cancelBtn.className = 'plate-enhancer-crop-cancel';
3480                        cancelBtn.textContent = 'Cancel';
3481                        cancelBtn.addEventListener('mousedown', (e) => e.stopPropagation());
3482                        cancelBtn.addEventListener('click', (e) => {
3483                            e.stopPropagation();
3484                            exitCropMode();
3485                        });
3486                        btnContainer.appendChild(cancelBtn);
3487
3488                        // Apply button
3489                        const applyBtn = document.createElement('button');
3490                        applyBtn.className = 'plate-enhancer-crop-apply';
3491                        applyBtn.textContent = 'Apply & Re-process';
3492                        applyBtn.addEventListener('mousedown', (e) => e.stopPropagation());
3493                        applyBtn.addEventListener('click', (e) => {
3494                            e.stopPropagation();
3495                            // Close the modal and re-process with cropped region
3496                            backdrop.remove();
3497                            modal.remove();
3498                            this.reprocessWithCroppedRegion(cropSelection, enhanced);
3499                        });
3500                        btnContainer.appendChild(applyBtn);
3501
3502                        selectionBox.appendChild(btnContainer);
3503                    } else {
3504                        // Too small - remove
3505                        selectionBox.remove();
3506                    }
3507                };
3508
3509                document.addEventListener('mousemove', onMouseMove);
3510                document.addEventListener('mouseup', onMouseUp);
3511            });
3512        });
3513
3514        // Handle close
3515        const closeModal = () => {
3516            backdrop.remove();
3517            modal.remove();
3518        };
3519
3520        header.querySelector('.plate-enhancer-close-btn').addEventListener('click', closeModal);
3521        backdrop.addEventListener('click', closeModal);
3522
3523        // Handle copy to clipboard (Pro feature)
3524        actions.querySelector('[data-action="copy"]').addEventListener('click', async () => {
3525            if (!await this.checkProAccess('copy image')) return;
3526            // Check both thumbnail and method thumb selections
3527            const selectedThumb = thumbsContainer.querySelector('.plate-enhancer-thumbnail.selected');
3528            const selectedMethod = modal.querySelector('.plate-enhancer-method-thumb.selected');
3529            const selected = selectedThumb || selectedMethod;
3530            if (selected) {
3531                await this.copyToClipboard(selected.dataset.src);
3532            } else {
3533                this.showToast('No image selected', 'info');
3534            }
3535        });
3536
3537        // Handle downloads (Pro feature)
3538        actions.querySelector('[data-action="download-selected"]').addEventListener('click', async () => {
3539            if (!await this.checkProAccess('download image')) return;
3540            // Check both thumbnail and method thumb selections
3541            const selectedThumb = thumbsContainer.querySelector('.plate-enhancer-thumbnail.selected');
3542            const selectedMethod = modal.querySelector('.plate-enhancer-method-thumb.selected');
3543            const selected = selectedThumb || selectedMethod;
3544            if (selected) {
3545                this.downloadImage(selected.dataset.src, 'enhanced-plate.png');
3546            } else {
3547                this.showToast('No image selected', 'info');
3548            }
3549        });
3550
3551        actions.querySelector('[data-action="download-all"]').addEventListener('click', async () => {
3552            if (!await this.checkProAccess('download all images')) return;
3553            await this.downloadAllAsZip(enhanced);
3554        });
3555
3556        document.body.appendChild(backdrop);
3557        document.body.appendChild(modal);
3558
3559        // Block right-click on images for free users only
3560        this.setupImageRightClickProtection(modal);
3561    }
3562
3563    /**
3564     * Block right-click on images for free users, allow for Pro users
3565     * @param {HTMLElement} container - Container with images to protect
3566     */
3567    async setupImageRightClickProtection(container) {
3568        const isPro = await this.isProUser();
3569        if (isPro) return; // Pro users can right-click freely
3570
3571        // Block right-click on all images in the container
3572        container.addEventListener('contextmenu', (e) => {
3573            if (e.target.tagName === 'IMG') {
3574                e.preventDefault();
3575            }
3576        });
3577    }
3578
3579    /**
3580     * Create thumbnail element
3581     * @param {Object} frame - Frame data with dataUrl and optional displayUrl (watermarked)
3582     * @param {string} rank - Rank label
3583     * @param {string} cameraLabel - Camera identifier
3584     * @param {boolean} isCombined - Whether this is a combined/processed result
3585     * @param {boolean} showCheckbox - Whether to show selection checkbox
3586     */
3587    createThumbnail(frame, rank, cameraLabel, isCombined = false, showCheckbox = false) {
3588        const thumb = document.createElement('div');
3589        thumb.className = 'plate-enhancer-thumbnail';
3590        if (showCheckbox) thumb.classList.add('selectable');
3591        thumb.dataset.src = frame.dataUrl; // Original for downloads
3592        thumb.dataset.displaySrc = frame.displayUrl || frame.dataUrl; // Watermarked for display
3593        thumb.dataset.label = isCombined ? 'Combined Result' : `${rank} - ${this.getCameraDisplayName(cameraLabel)}`;
3594
3595        // Add checkbox for frame selection
3596        if (showCheckbox) {
3597            const checkbox = document.createElement('input');
3598            checkbox.type = 'checkbox';
3599            checkbox.className = 'plate-enhancer-frame-checkbox';
3600            checkbox.title = 'Include in re-processing';
3601            thumb.appendChild(checkbox);
3602        }
3603
3604        const img = document.createElement('img');
3605        img.src = frame.displayUrl || frame.dataUrl; // Show watermarked version
3606        thumb.appendChild(img);
3607
3608        const info = document.createElement('div');
3609        info.className = 'plate-enhancer-thumbnail-info';
3610        info.innerHTML = `
3611            <div class="plate-enhancer-thumbnail-rank">${rank}</div>
3612            <div class="plate-enhancer-thumbnail-camera">${this.getCameraDisplayName(cameraLabel)}</div>
3613        `;
3614        thumb.appendChild(info);
3615
3616        return thumb;
3617    }
3618
3619    /**
3620     * Re-process with only selected frames
3621     * @param {number[]} selectedIndices - Indices of frames to include
3622     */
3623    async reprocessWithSelectedFrames(selectedIndices) {
3624        // Use originalEnhanced if available (preserves all frames across reprocessing)
3625        const sourceEnhanced = this.originalEnhanced || this.lastEnhanced;
3626
3627        if (!this.lastExtraction || !sourceEnhanced) {
3628            this.showError('No extraction data available for re-processing');
3629            return;
3630        }
3631
3632        this.isProcessing = true;
3633        this.shouldCancel = false;
3634
3635        // Show progress modal
3636        const backdrop = document.createElement('div');
3637        backdrop.className = 'plate-enhancer-dialog-backdrop';
3638
3639        const progress = document.createElement('div');
3640        progress.className = 'plate-enhancer-progress';
3641        progress.innerHTML = `
3642            <h3>Re-processing with ${selectedIndices.length} frames</h3>
3643            <div class="plate-enhancer-progress-status">Preparing...</div>
3644            <div class="plate-enhancer-progress-bar">
3645                <div class="plate-enhancer-progress-fill" style="width: 0%"></div>
3646            </div>
3647            <div class="plate-enhancer-progress-percent">0%</div>
3648            <div class="plate-enhancer-progress-cancel">
3649                <button class="plate-enhancer-btn plate-enhancer-btn-secondary">Cancel</button>
3650            </div>
3651        `;
3652
3653        progress.querySelector('button').addEventListener('click', () => {
3654            this.shouldCancel = true;
3655        });
3656
3657        document.body.appendChild(backdrop);
3658        document.body.appendChild(progress);
3659
3660        const updateProgress = (status, percent) => {
3661            progress.querySelector('.plate-enhancer-progress-status').textContent = status;
3662            progress.querySelector('.plate-enhancer-progress-fill').style.width = `${percent}%`;
3663            progress.querySelector('.plate-enhancer-progress-percent').textContent = `${Math.round(percent)}%`;
3664        };
3665
3666        try {
3667            const { settings } = this.lastExtraction;
3668
3669            // Get only selected frames from the ORIGINAL topFrames (preserved across reprocessing)
3670            // This allows user to try different selections without losing frames
3671            const selectedFrameData = selectedIndices.map(i => sourceEnhanced.topFrames[i]);
3672
3673            // We need the original regions and frames that correspond to these
3674            // The topFrames have indices into the original data
3675            const selectedRegions = selectedFrameData.map(f => ({
3676                x: 0,
3677                y: 0,
3678                width: f.width,
3679                height: f.height,
3680                imageData: f.imageData,
3681                sharpness: f.sharpness,
3682                cameraId: f.cameraId
3683            }));
3684
3685            // Create new stacker
3686            const stacker = new FrameStacker();
3687
3688            if (settings.enableMLUpscale) {
3689                stacker.enableMLUpscale(true, settings.upscaleScale, 'thick');
3690                stacker.postProcessSharpening = true; // Crisp text edges
3691                stacker.enablePreProcessing = true; // MAX QUALITY pipeline
3692            }
3693
3694            // ALWAYS use real multi-frame stacking
3695            stacker.useRealStacking = true;
3696            stacker.stackingMethod = 'sigma-mean';
3697
3698            updateProgress('Processing selected frames...', 20);
3699            await new Promise(r => setTimeout(r, 47));
3700
3701            if (this.shouldCancel) throw new Error('Cancelled');
3702
3703            // Process with selected frames only
3704            // We pass the cropped regions directly since they're already extracted
3705            const enhanced = await stacker.processPreCropped(selectedFrameData, (status, pct) => {
3706                updateProgress(status, 20 + pct * 0.7);
3707            }, settings.videoEnhancements);
3708
3709            if (this.shouldCancel) throw new Error('Cancelled');
3710
3711            // Store for potential further re-processing
3712            this.lastEnhanced = enhanced;
3713
3714            updateProgress('Complete!', 100);
3715
3716            backdrop.remove();
3717            progress.remove();
3718
3719            // Show results (with re-processing still available)
3720            await this.showResults(enhanced, true);
3721
3722        } catch (error) {
3723            backdrop.remove();
3724            progress.remove();
3725
3726            if (error.message !== 'Cancelled') {
3727                console.error('[PlateEnhancer] Re-processing error:', error);
3728                this.showError(`Re-processing failed: ${error.message}`);
3729            }
3730        } finally {
3731            this.isProcessing = false;
3732        }
3733    }
3734
3735    /**
3736     * Re-process all frames with a cropped region
3737     * @param {Object} selection - Crop selection with coordinates
3738     * @param {Object} enhanced - Current enhanced result with topFrames
3739     */
3740    async reprocessWithCroppedRegion(selection, enhanced) {
3741        if (!enhanced || !enhanced.topFrames || enhanced.topFrames.length < 2) {
3742            this.showError('No frames available for re-processing');
3743            return;
3744        }
3745
3746        this.isProcessing = true;
3747        this.shouldCancel = false;
3748
3749        // Show progress modal
3750        const backdrop = document.createElement('div');
3751        backdrop.className = 'plate-enhancer-dialog-backdrop';
3752
3753        const progress = document.createElement('div');
3754        progress.className = 'plate-enhancer-progress';
3755        progress.innerHTML = `
3756            <h3>Re-processing with Cropped Region</h3>
3757            <div class="plate-enhancer-progress-status">Preparing...</div>
3758            <div class="plate-enhancer-progress-bar">
3759                <div class="plate-enhancer-progress-fill" style="width: 0%"></div>
3760            </div>
3761            <div class="plate-enhancer-progress-percent">0%</div>
3762            <div class="plate-enhancer-progress-cancel">
3763                <button class="plate-enhancer-btn plate-enhancer-btn-secondary">Cancel</button>
3764            </div>
3765        `;
3766
3767        progress.querySelector('button').addEventListener('click', () => {
3768            this.shouldCancel = true;
3769        });
3770
3771        document.body.appendChild(backdrop);
3772        document.body.appendChild(progress);
3773
3774        const updateProgress = (status, percent) => {
3775            progress.querySelector('.plate-enhancer-progress-status').textContent = status;
3776            progress.querySelector('.plate-enhancer-progress-fill').style.width = `${percent}%`;
3777            progress.querySelector('.plate-enhancer-progress-percent').textContent = `${Math.round(percent)}%`;
3778        };
3779
3780        try {
3781            // Calculate crop as percentages (relative to displayed image)
3782            const imgRect = selection.imgRect;
3783            const overlayRect = selection.overlayRect;
3784
3785            const imgOffsetX = imgRect.left - overlayRect.left;
3786            const imgOffsetY = imgRect.top - overlayRect.top;
3787
3788            const relX = selection.x - imgOffsetX;
3789            const relY = selection.y - imgOffsetY;
3790
3791            // Calculate crop percentages relative to displayed image
3792            const cropLeftPct = Math.max(0, relX / imgRect.width);
3793            const cropTopPct = Math.max(0, relY / imgRect.height);
3794            const cropWidthPct = Math.min(1 - cropLeftPct, selection.width / imgRect.width);
3795            const cropHeightPct = Math.min(1 - cropTopPct, selection.height / imgRect.height);
3796
3797            console.log('[PlateEnhancer] Crop percentages:', { cropLeftPct, cropTopPct, cropWidthPct, cropHeightPct });
3798
3799            if (cropWidthPct <= 0 || cropHeightPct <= 0) {
3800                throw new Error('Invalid crop region');
3801            }
3802
3803            // Apply crop to all source frames
3804            updateProgress('Cropping source frames...', 10);
3805            const croppedFrames = [];
3806
3807            for (let i = 0; i < enhanced.topFrames.length; i++) {
3808                if (this.shouldCancel) throw new Error('Cancelled');
3809
3810                const frame = enhanced.topFrames[i];
3811                const croppedDataUrl = await this.cropImageByPercentage(frame.dataUrl, cropLeftPct, cropTopPct, cropWidthPct, cropHeightPct);
3812
3813                croppedFrames.push({
3814                    dataUrl: croppedDataUrl,
3815                    sharpness: frame.sharpness,
3816                    cameraId: frame.cameraId,
3817                    time: frame.time
3818                });
3819
3820                updateProgress(`Cropping frames (${i + 1}/${enhanced.topFrames.length})...`, 10 + (i / enhanced.topFrames.length) * 20);
3821            }
3822
3823            if (this.shouldCancel) throw new Error('Cancelled');
3824
3825            // Re-process cropped frames through stacker
3826            const settings = this.lastExtraction?.settings || {};
3827            const stacker = new FrameStacker();
3828
3829            if (settings.enableMLUpscale) {
3830                stacker.enableMLUpscale(true, settings.upscaleScale, 'thick');
3831                stacker.postProcessSharpening = true; // Crisp text edges
3832                stacker.enablePreProcessing = true; // MAX QUALITY pipeline
3833            }
3834
3835            // ALWAYS use real multi-frame stacking
3836            stacker.useRealStacking = true;
3837            stacker.stackingMethod = 'sigma-mean';
3838
3839            updateProgress('Stacking cropped frames...', 35);
3840
3841            const newEnhanced = await stacker.processPreCropped(croppedFrames, (status, pct) => {
3842                updateProgress(status, 35 + pct * 0.55);
3843            }, settings.videoEnhancements);
3844
3845            if (this.shouldCancel) throw new Error('Cancelled');
3846
3847            // Store for potential further re-processing
3848            this.lastEnhanced = newEnhanced;
3849
3850            updateProgress('Complete!', 100);
3851
3852            backdrop.remove();
3853            progress.remove();
3854
3855            // Show results
3856            await this.showResults(newEnhanced, true);
3857
3858        } catch (error) {
3859            backdrop.remove();
3860            progress.remove();
3861
3862            if (error.message !== 'Cancelled') {
3863                console.error('[PlateEnhancer] Crop re-processing error:', error);
3864                this.showError(`Crop re-processing failed: ${error.message}`);
3865            }
3866        } finally {
3867            this.isProcessing = false;
3868        }
3869    }
3870
3871    /**
3872     * Crop an image by percentage values
3873     * @param {string} dataUrl - Source image
3874     * @param {number} leftPct - Left offset as percentage (0-1)
3875     * @param {number} topPct - Top offset as percentage (0-1)
3876     * @param {number} widthPct - Width as percentage (0-1)
3877     * @param {number} heightPct - Height as percentage (0-1)
3878     * @returns {string} Cropped image data URL
3879     */
3880    async cropImageByPercentage(dataUrl, leftPct, topPct, widthPct, heightPct) {
3881        return new Promise((resolve, reject) => {
3882            const img = new Image();
3883            img.onload = () => {
3884                const x = Math.round(img.naturalWidth * leftPct);
3885                const y = Math.round(img.naturalHeight * topPct);
3886                const w = Math.round(img.naturalWidth * widthPct);
3887                const h = Math.round(img.naturalHeight * heightPct);
3888
3889                const canvas = document.createElement('canvas');
3890                canvas.width = w;
3891                canvas.height = h;
3892                const ctx = canvas.getContext('2d');
3893                ctx.drawImage(img, x, y, w, h, 0, 0, w, h);
3894
3895                resolve(canvas.toDataURL('image/png'));
3896            };
3897            img.onerror = reject;
3898            img.src = dataUrl;
3899        });
3900    }
3901
3902    /**
3903     * Apply crop to result and add as new thumbnail
3904     * @param {string} imageSrc - Source image data URL
3905     * @param {Object} selection - Crop selection { x, y, width, height, overlayWidth, overlayHeight, imgRect, overlayRect }
3906     * @param {HTMLImageElement} mainImg - Main preview image element
3907     * @param {HTMLElement} mainLabel - Main preview label element
3908     * @param {HTMLElement} thumbsContainer - Thumbnails container
3909     */
3910    async applyCropToResult(imageSrc, selection, mainImg, mainLabel, thumbsContainer) {
3911        try {
3912            // Load the image to get natural dimensions
3913            const img = new Image();
3914            await new Promise((resolve, reject) => {
3915                img.onload = resolve;
3916                img.onerror = reject;
3917                img.src = imageSrc;
3918            });
3919
3920            // The image might be letterboxed within the container due to object-fit: contain
3921            // We need to calculate where the image actually is displayed
3922            const imgRect = selection.imgRect;
3923            const overlayRect = selection.overlayRect;
3924
3925            // Calculate the offset of the image within the overlay
3926            const imgOffsetX = imgRect.left - overlayRect.left;
3927            const imgOffsetY = imgRect.top - overlayRect.top;
3928
3929            // Adjust selection coordinates to be relative to the displayed image
3930            const relX = selection.x - imgOffsetX;
3931            const relY = selection.y - imgOffsetY;
3932
3933            // Scale from displayed image size to natural image size
3934            const scaleX = img.naturalWidth / imgRect.width;
3935            const scaleY = img.naturalHeight / imgRect.height;
3936
3937            // Calculate crop in natural pixel coordinates
3938            const cropX = Math.max(0, Math.round(relX * scaleX));
3939            const cropY = Math.max(0, Math.round(relY * scaleY));
3940            let cropWidth = Math.round(selection.width * scaleX);
3941            let cropHeight = Math.round(selection.height * scaleY);
3942
3943            // Clamp to image bounds
3944            cropWidth = Math.min(cropWidth, img.naturalWidth - cropX);
3945            cropHeight = Math.min(cropHeight, img.naturalHeight - cropY);
3946
3947            console.log('[PlateEnhancer] Crop coords:', { cropX, cropY, cropWidth, cropHeight, naturalW: img.naturalWidth, naturalH: img.naturalHeight });
3948
3949            // Validate crop dimensions
3950            if (cropWidth <= 0 || cropHeight <= 0) {
3951                this.showToast('Invalid crop region - selection outside image');
3952                return;
3953            }
3954
3955            // Create canvas and crop
3956            const canvas = document.createElement('canvas');
3957            canvas.width = cropWidth;
3958            canvas.height = cropHeight;
3959            const ctx = canvas.getContext('2d');
3960
3961            ctx.drawImage(img, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
3962
3963            const croppedDataUrl = canvas.toDataURL('image/png');
3964
3965            // Update main preview
3966            mainImg.src = croppedDataUrl;
3967            mainLabel.textContent = 'Cropped Result';
3968
3969            // Add cropped version as a new thumbnail at the beginning
3970            const croppedThumb = document.createElement('div');
3971            croppedThumb.className = 'plate-enhancer-thumbnail cropped selected';
3972            croppedThumb.dataset.src = croppedDataUrl;
3973            croppedThumb.dataset.label = 'Cropped Result';
3974
3975            const thumbImg = document.createElement('img');
3976            thumbImg.src = croppedDataUrl;
3977            croppedThumb.appendChild(thumbImg);
3978
3979            const info = document.createElement('div');
3980            info.className = 'plate-enhancer-thumbnail-info';
3981            info.innerHTML = `
3982                <div class="plate-enhancer-thumbnail-rank">Cropped</div>
3983                <div class="plate-enhancer-thumbnail-camera">${cropWidth}x${cropHeight}</div>
3984            `;
3985            croppedThumb.appendChild(info);
3986
3987            // Deselect other thumbnails
3988            thumbsContainer.querySelectorAll('.plate-enhancer-thumbnail').forEach(t => t.classList.remove('selected'));
3989
3990            // Insert at beginning (after combined if it exists)
3991            const combined = thumbsContainer.querySelector('.combined');
3992            if (combined) {
3993                combined.after(croppedThumb);
3994            } else {
3995                thumbsContainer.prepend(croppedThumb);
3996            }
3997
3998            this.showToast('Region cropped successfully!');
3999
4000        } catch (error) {
4001            console.error('[PlateEnhancer] Crop failed:', error);
4002            this.showToast('Crop failed');
4003        }
4004    }
4005
4006    /**
4007     * Check if user has Pro access (no watermark needed)
4008     * @returns {Promise<boolean>}
4009     */
4010    async isProUser() {
4011        try {
4012            const access = await window.app?.sessionManager?.checkAccess?.('plateEnhancement');
4013            return access?.allowed === true;
4014        } catch (e) {
4015            return false;
4016        }
4017    }
4018
4019    /**
4020     * Add watermark to image for free users
4021     * @param {string} dataUrl - Source image data URL
4022     * @returns {Promise<string>} - Watermarked image data URL
4023     */
4024    async addWatermark(dataUrl) {
4025        return new Promise((resolve) => {
4026            const img = new Image();
4027            img.onload = () => {
4028                const canvas = document.createElement('canvas');
4029                canvas.width = img.width;
4030                canvas.height = img.height;
4031                const ctx = canvas.getContext('2d');
4032
4033                // Draw original image
4034                ctx.drawImage(img, 0, 0);
4035
4036                // Calculate diagonal angle from corner to corner
4037                const diagonalAngle = Math.atan2(img.height, img.width);
4038                const diagonalLength = Math.sqrt(img.width * img.width + img.height * img.height);
4039
4040                // Font size based on diagonal length for good coverage
4041                const fontSize = Math.max(16, Math.min(48, diagonalLength / 12));
4042                ctx.font = `bold ${fontSize}px Arial, sans-serif`;
4043
4044                const text = 'TeslaCamViewer.com';
4045                const textMetrics = ctx.measureText(text);
4046
4047                // Save context, move to center, rotate
4048                ctx.save();
4049                ctx.translate(img.width / 2, img.height / 2);
4050                ctx.rotate(diagonalAngle);
4051
4052                // Draw text centered on diagonal with outline for visibility
4053                ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
4054                ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
4055                ctx.lineWidth = 3;
4056                ctx.textAlign = 'center';
4057                ctx.textBaseline = 'middle';
4058
4059                ctx.strokeText(text, 0, 0);
4060                ctx.fillText(text, 0, 0);
4061
4062                ctx.restore();
4063
4064                resolve(canvas.toDataURL('image/png'));
4065            };
4066            img.onerror = () => resolve(dataUrl); // Fallback to original on error
4067            img.src = dataUrl;
4068        });
4069    }
4070
4071    /**
4072     * Download single image (with watermark for free users)
4073     */
4074    async downloadImage(dataUrl, filename) {
4075        // Check if Pro user - Pro users get clean images
4076        const isPro = await this.isProUser();
4077        const finalDataUrl = isPro ? dataUrl : await this.addWatermark(dataUrl);
4078
4079        const link = document.createElement('a');
4080        link.href = finalDataUrl;
4081        link.download = filename;
4082        document.body.appendChild(link);
4083        link.click();
4084        document.body.removeChild(link);
4085    }
4086
4087    /**
4088     * Copy image to clipboard (with watermark for free users)
4089     */
4090    async copyToClipboard(dataUrl) {
4091        try {
4092            // Check if Pro user - Pro users get clean images
4093            const isPro = await this.isProUser();
4094            const finalDataUrl = isPro ? dataUrl : await this.addWatermark(dataUrl);
4095
4096            // Convert data URL to blob
4097            const response = await fetch(finalDataUrl);
4098            const blob = await response.blob();
4099
4100            // Use clipboard API
4101            await navigator.clipboard.write([
4102                new ClipboardItem({
4103                    [blob.type]: blob
4104                })
4105            ]);
4106
4107            // Show brief success feedback
4108            this.showToast('Copied to clipboard!');
4109        } catch (error) {
4110            console.error('[PlateEnhancer] Copy failed:', error);
4111            this.showError('Failed to copy to clipboard. Try downloading instead.');
4112        }
4113    }
4114
4115    /**
4116     * Show a brief toast notification
4117     */
4118    showToast(message) {
4119        const toast = document.createElement('div');
4120        toast.className = 'plate-enhancer-toast';
4121        toast.textContent = message;
4122        document.body.appendChild(toast);
4123
4124        // Animate in
4125        setTimeout(() => toast.classList.add('show'), 10);
4126
4127        // Remove after delay
4128        setTimeout(() => {
4129            toast.classList.remove('show');
4130            setTimeout(() => toast.remove(), 300);
4131        }, 2000);
4132    }
4133
4134    /**
4135     * Download all results as ZIP (with watermarks for free users)
4136     */
4137    async downloadAllAsZip(enhanced) {
4138        if (typeof JSZip === 'undefined') {
4139            this.showError('ZIP library not available');
4140            return;
4141        }
4142
4143        // Check if Pro user - Pro users get clean images
4144        const isPro = await this.isProUser();
4145
4146        const zip = new JSZip();
4147        const folder = zip.folder('enhanced-plates');
4148
4149        // Helper to get data for zip (with optional watermark)
4150        const getImageData = async (dataUrl) => {
4151            if (isPro) {
4152                return dataUrl.split(',')[1];
4153            }
4154            const watermarked = await this.addWatermark(dataUrl);
4155            return watermarked.split(',')[1];
4156        };
4157
4158        // Add combined result
4159        const combinedData = await getImageData(enhanced.combined.dataUrl);
4160        folder.file('00-combined.png', combinedData, { base64: true });
4161
4162        // Add upscaled version if available
4163        if (enhanced.upscaled) {
4164            const upscaledData = await getImageData(enhanced.upscaled.dataUrl);
4165            const scale = enhanced.upscaled.scale || 2;
4166            folder.file(`00-combined-${scale}x-upscaled.png`, upscaledData, { base64: true });
4167        }
4168
4169        // Add all enhancement methods (including Lucky Regions if available)
4170        if (enhanced.methods) {
4171            const methodOrder = ['sigmaClipped', 'msrClahe', 'weightedMean', 'bestFrame', 'bilateral', 'ensemble', 'luckyRegions'];
4172            for (let idx = 0; idx < methodOrder.length; idx++) {
4173                const key = methodOrder[idx];
4174                const method = enhanced.methods[key];
4175                if (method?.dataUrl) {
4176                    // Add original version
4177                    const data = await getImageData(method.dataUrl);
4178                    folder.file(`01-method-${String(idx + 1).padStart(2, '0')}-${key}.png`, data, { base64: true });
4179
4180                    // Add 4x upscaled version if available
4181                    if (method.upscaledDataUrl) {
4182                        const upscaledData = await getImageData(method.upscaledDataUrl);
4183                        folder.file(`01-method-${String(idx + 1).padStart(2, '0')}-${key}-4x.png`, upscaledData, { base64: true });
4184                    }
4185                }
4186            }
4187        }
4188
4189        // Add top frames
4190        for (let idx = 0; idx < enhanced.topFrames.length; idx++) {
4191            const frame = enhanced.topFrames[idx];
4192            const data = await getImageData(frame.dataUrl);
4193            const camera = frame.cameraId.replace('_', '-');
4194            folder.file(`02-source-${String(idx + 1).padStart(2, '0')}-${camera}.png`, data, { base64: true });
4195        }
4196
4197        // Generate and download
4198        const blob = await zip.generateAsync({ type: 'blob' });
4199        const url = URL.createObjectURL(blob);
4200        const link = document.createElement('a');
4201        link.href = url;
4202        link.download = 'enhanced-plates.zip';
4203        document.body.appendChild(link);
4204        link.click();
4205        document.body.removeChild(link);
4206        URL.revokeObjectURL(url);
4207    }
4208
4209    /**
4210     * Get current video enhancement settings (brightness, contrast, saturation)
4211     */
4212    getVideoEnhancementSettings() {
4213        const enhancer = window.app?.videoEnhancer;
4214        if (!enhancer || !enhancer.settings) {
4215            return { brightness: 100, contrast: 100, saturation: 100 };
4216        }
4217        // Use ?? instead of || to handle 0 values correctly (saturation=0 means grayscale)
4218        return {
4219            brightness: enhancer.settings.brightness ?? 100,
4220            contrast: enhancer.settings.contrast ?? 100,
4221            saturation: enhancer.settings.saturation ?? 100
4222        };
4223    }
4224
4225    /**
4226     * Apply video enhancement settings to a canvas context before drawing
4227     * Uses CSS filter syntax on canvas 2D context
4228     * @param {CanvasRenderingContext2D} ctx - The canvas context
4229     * @param {Object} settings - Video enhancement settings { brightness, contrast, saturation }
4230     * @returns {string|null} The filter string applied, or null if no filter needed
4231     */
4232    applyVideoEnhancementsToCanvas(ctx, settings) {
4233        if (!settings) return null;
4234
4235        const { brightness, contrast, saturation } = settings;
4236
4237        // Skip if all settings are at default (100)
4238        if (brightness === 100 && contrast === 100 && saturation === 100) {
4239            return null;
4240        }
4241
4242        // Build CSS filter string (values are 0-200, where 100 = 1.0)
4243        const filterParts = [];
4244        if (brightness !== 100) {
4245            filterParts.push(`brightness(${brightness / 100})`);
4246        }
4247        if (contrast !== 100) {
4248            filterParts.push(`contrast(${contrast / 100})`);
4249        }
4250        if (saturation !== 100) {
4251            filterParts.push(`saturate(${saturation / 100})`);
4252        }
4253
4254        const filterString = filterParts.join(' ');
4255        ctx.filter = filterString;
4256
4257        return filterString;
4258    }
4259
4260    /**
4261     * Show error message
4262     */
4263    showError(message) {
4264        const backdrop = document.createElement('div');
4265        backdrop.className = 'plate-enhancer-dialog-backdrop';
4266
4267        const dialog = document.createElement('div');
4268        dialog.className = 'plate-enhancer-dialog';
4269        dialog.innerHTML = `
4270            <h3>Error</h3>
4271            <div class="plate-enhancer-error">${message}</div>
4272            <div class="plate-enhancer-dialog-actions">
4273                <button class="plate-enhancer-btn plate-enhancer-btn-primary">OK</button>
4274            </div>
4275        `;
4276
4277        dialog.querySelector('button').addEventListener('click', () => {
4278            backdrop.remove();
4279            dialog.remove();
4280        });
4281
4282        backdrop.addEventListener('click', () => {
4283            backdrop.remove();
4284            dialog.remove();
4285        });
4286
4287        document.body.appendChild(backdrop);
4288        document.body.appendChild(dialog);
4289    }
4290
4291    /**
4292     * Load Tesseract.js on demand
4293     */
4294    async loadTesseract() {
4295        if (this.isTesseractLoaded) return true;
4296        if (this.isLoadingTesseract) {
4297            // Wait for existing load
4298            return new Promise((resolve) => {
4299                const check = setInterval(() => {
4300                    if (this.isTesseractLoaded) {
4301                        clearInterval(check);
4302                        resolve(true);
4303                    }
4304                }, 100);
4305                setTimeout(() => {
4306                    clearInterval(check);
4307                    resolve(false);
4308                }, 30000);
4309            });
4310        }
4311
4312        this.isLoadingTesseract = true;
4313
4314        try {
4315            // Load Tesseract.js from CDN
4316            await new Promise((resolve, reject) => {
4317                const script = document.createElement('script');
4318                script.src = 'https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js';
4319                script.onload = resolve;
4320                script.onerror = reject;
4321                document.head.appendChild(script);
4322            });
4323
4324            this.isTesseractLoaded = true;
4325            console.log('[PlateEnhancer] Tesseract.js loaded');
4326            return true;
4327        } catch (error) {
4328            console.error('[PlateEnhancer] Failed to load Tesseract.js:', error);
4329            return false;
4330        } finally {
4331            this.isLoadingTesseract = false;
4332        }
4333    }
4334
4335    /**
4336     * Check Pro access for a feature
4337     * @param {string} feature - Feature name for display
4338     * @returns {boolean} - Whether access is allowed
4339     */
4340    async checkProAccess(feature) {
4341        // Check if session manager exists and has checkAccess
4342        if (!window.app?.sessionManager?.checkAccess) {
4343            // No session manager - this is free mode, block Pro features
4344            console.log(`[PlateEnhancer] Pro feature "${feature}" blocked - no session manager`);
4345            this.showToast('This feature requires TeslaCamViewer Pro');
4346            return false;
4347        }
4348
4349        const access = await window.app.sessionManager.checkAccess('plateEnhancement');
4350        if (!access.allowed) {
4351            window.app.sessionManager.showLimitModal('premium');
4352            return false;
4353        }
4354        return true;
4355    }
4356
4357    /**
4358     * Run ensemble OCR across all enhancement methods
4359     * Takes the best result from all methods
4360     * @param {Object} enhanced - Enhanced results with methods
4361     * @param {Function} onProgress - Progress callback
4362     * @returns {Object} - { text, confidence, warning }
4363     */
4364    async runEnsembleOCR(enhanced, onProgress = null) {
4365        // Collect all images to run OCR on
4366        const images = [];
4367
4368        // Add the main combined result
4369        images.push({ name: 'combined', dataUrl: enhanced.combined.dataUrl });
4370
4371        // Add all method results if available
4372        if (enhanced.methods) {
4373            for (const [key, method] of Object.entries(enhanced.methods)) {
4374                if (method.dataUrl) {
4375                    images.push({ name: key, dataUrl: method.dataUrl });
4376                }
4377            }
4378        }
4379
4380        // Add upscaled if available
4381        if (enhanced.upscaled?.dataUrl) {
4382            images.push({ name: 'upscaled', dataUrl: enhanced.upscaled.dataUrl });
4383        }
4384
4385        if (onProgress) onProgress('Running ensemble OCR...', 0);
4386
4387        const results = [];
4388        const methodResults = {}; // Store per-method OCR results
4389        let processedCount = 0;
4390
4391        for (const img of images) {
4392            try {
4393                const result = await this.runOCR(img.dataUrl, (status, progress) => {
4394                    if (onProgress) {
4395                        const overallProgress = (processedCount + (progress || 0)) / images.length;
4396                        onProgress(`Analyzing ${img.name}...`, overallProgress);
4397                    }
4398                });
4399
4400                // Store per-method result
4401                methodResults[img.name] = {
4402                    text: result.text || '',
4403                    confidence: result.confidence || 0
4404                };
4405
4406                if (result.text && result.confidence > 0) {
4407                    results.push({
4408                        ...result,
4409                        source: img.name
4410                    });
4411                }
4412            } catch (e) {
4413                console.warn(`[PlateEnhancer] OCR failed for ${img.name}:`, e);
4414                methodResults[img.name] = { text: '', confidence: 0, error: e.message };
4415            }
4416            processedCount++;
4417        }
4418
4419        if (results.length === 0) {
4420            return { text: '', confidence: 0, error: 'No text detected', methodResults };
4421        }
4422
4423        // Vote for the best result
4424        // Group by text and sum confidences
4425        const textVotes = new Map();
4426        for (const result of results) {
4427            const normalizedText = result.text.toUpperCase().replace(/\s+/g, '');
4428            if (!textVotes.has(normalizedText)) {
4429                textVotes.set(normalizedText, { text: result.text, totalConf: 0, count: 0 });
4430            }
4431            const vote = textVotes.get(normalizedText);
4432            vote.totalConf += result.confidence;
4433            vote.count++;
4434        }
4435
4436        // Find best result (highest total confidence)
4437        let bestResult = null;
4438        let bestScore = -1;
4439        for (const vote of textVotes.values()) {
4440            const score = vote.totalConf + (vote.count * 10); // Bonus for consistency
4441            if (score > bestScore) {
4442                bestScore = score;
4443                bestResult = vote;
4444            }
4445        }
4446
4447        const avgConfidence = bestResult.totalConf / bestResult.count;
4448        console.log(`[PlateEnhancer] Ensemble OCR: "${bestResult.text}" (${avgConfidence.toFixed(1)}% avg, ${bestResult.count} votes)`);
4449
4450        return {
4451            text: bestResult.text,
4452            confidence: avgConfidence,
4453            votes: bestResult.count,
4454            warning: avgConfidence < 50,
4455            methodResults // Include per-method results
4456        };
4457    }
4458
4459    /**
4460     * Run OCR on an image using PlateRecognizer (CCT model)
4461     * @param {string} dataUrl - Image data URL
4462     * @param {Function} onProgress - Progress callback (status, progress 0-1)
4463     * @returns {Object} - { text, confidence }
4464     */
4465    async runOCR(dataUrl, onProgress = null) {
4466        // Use PlateRecognizer (CCT model trained on license plates) for better accuracy
4467        if (!window.plateRecognizer) {
4468            window.plateRecognizer = new PlateRecognizer();
4469        }
4470
4471        const recognizer = window.plateRecognizer;
4472
4473        // Load the model if needed
4474        if (!recognizer.isLoaded) {
4475            if (onProgress) onProgress('Loading plate OCR model...', null);
4476            const loaded = await recognizer.loadModel((progress) => {
4477                if (onProgress && progress.percent >= 0) {
4478                    onProgress(progress.status || 'Loading...', progress.percent / 100);
4479                }
4480            });
4481            if (!loaded) {
4482                console.warn('[PlateEnhancer] PlateRecognizer failed to load, falling back to Tesseract');
4483                return this.runOCRTesseract(dataUrl, onProgress);
4484            }
4485        }
4486
4487        try {
4488            if (onProgress) onProgress('Recognizing plate...', 0.5);
4489
4490            // Convert dataUrl to image
4491            const img = new Image();
4492            await new Promise((resolve, reject) => {
4493                img.onload = resolve;
4494                img.onerror = reject;
4495                img.src = dataUrl;
4496            });
4497
4498            // Use ensemble recognition for best results
4499            const result = await recognizer.recognizeEnsemble(img, {});
4500
4501            console.log(`[PlateEnhancer] OCR result: "${result.text}" (confidence: ${result.confidence.toFixed(1)}%)`);
4502
4503            return {
4504                text: result.text,
4505                confidence: result.confidence
4506            };
4507        } catch (error) {
4508            console.error('[PlateEnhancer] PlateRecognizer OCR failed:', error);
4509            // Fallback to Tesseract
4510            console.log('[PlateEnhancer] Falling back to Tesseract OCR...');
4511            return this.runOCRTesseract(dataUrl, onProgress);
4512        }
4513    }
4514
4515    /**
4516     * Fallback OCR using Tesseract
4517     * @param {string} dataUrl - Image data URL
4518     * @param {Function} onProgress - Progress callback
4519     * @returns {Object} - { text, confidence }
4520     */
4521    async runOCRTesseract(dataUrl, onProgress = null) {
4522        if (!this.isTesseractLoaded) {
4523            if (onProgress) onProgress('Loading Tesseract...', null);
4524            const loaded = await this.loadTesseract();
4525            if (!loaded) {
4526                return { text: '', confidence: 0, error: 'Failed to load OCR library' };
4527            }
4528        }
4529
4530        try {
4531            const result = await Tesseract.recognize(dataUrl, 'eng', {
4532                logger: m => {
4533                    if (onProgress && m.progress) {
4534                        onProgress('Analyzing text...', m.progress);
4535                    }
4536                }
4537            });
4538
4539            const rawText = result.data.text.trim();
4540            const cleanedText = rawText.replace(/[^A-Z0-9\s-]/gi, '').trim();
4541
4542            return {
4543                text: cleanedText,
4544                rawText: rawText,
4545                confidence: result.data.confidence
4546            };
4547        } catch (error) {
4548            console.error('[PlateEnhancer] Tesseract OCR failed:', error);
4549            return { text: '', confidence: 0, error: error.message };
4550        }
4551    }
4552}
4553
4554// Create global instance
4555window.plateEnhancer = new PlateEnhancer();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.