Spaces:
Paused
Paused
| """ | |
| Video Annotation Layout | |
| Generates a form interface for video annotation with: | |
| - Temporal segment marking (like audio annotation) | |
| - Frame-level classification | |
| - Keyframe annotation | |
| - Object tracking across frames (basic support) | |
| Uses Peaks.js for timeline visualization with synchronized video playback. | |
| Features: | |
| - Waveform/timeline visualization from video's audio track | |
| - Video preview panel with frame counter | |
| - Segment creation and labeling | |
| - Frame-by-frame stepping (,/. keys) | |
| - Multiple playback speeds (0.1x to 2.0x) | |
| - Keyframe marking (K key) | |
| """ | |
| import logging | |
| import json | |
| from typing import List, Dict, Tuple, Any | |
| from .identifier_utils import ( | |
| safe_generate_layout, | |
| escape_html_content | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # Default colors for segment/frame labels | |
| DEFAULT_COLORS = [ | |
| "#4ECDC4", # Teal | |
| "#FF6B6B", # Red | |
| "#45B7D1", # Blue | |
| "#96CEB4", # Green | |
| "#FFEAA7", # Yellow | |
| "#DDA0DD", # Plum | |
| "#95A5A6", # Gray | |
| "#F39C12", # Orange | |
| "#9B59B6", # Purple | |
| "#3498DB", # Light Blue | |
| ] | |
| # Valid annotation modes | |
| VALID_MODES = ["segment", "frame", "keyframe", "tracking", "combined"] | |
| def generate_video_annotation_layout(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]: | |
| """ | |
| Generate HTML for a video annotation interface. | |
| Args: | |
| annotation_scheme (dict): Configuration including: | |
| - name: Schema identifier | |
| - description: Display description | |
| - mode: "segment" | "frame" | "keyframe" | "tracking" | "combined" | |
| - labels: List of labels (for segment/frame/keyframe modes) | |
| - segment_schemes: List of annotation schemes per segment (optional) | |
| - min_segments: Minimum required segments (default: 0) | |
| - max_segments: Maximum allowed segments (default: null/unlimited) | |
| - timeline_height: Height of timeline in pixels (default: 70) | |
| - overview_height: Height of overview bar (default: 40) | |
| - zoom_enabled: Whether to enable zoom (default: True) | |
| - playback_rate_control: Show playback speed controls (default: True) | |
| - frame_stepping: Enable frame-by-frame navigation (default: True) | |
| - show_timecode: Show timecode display (default: True) | |
| - video_fps: Frames per second for frame calculations (default: 30) | |
| Returns: | |
| tuple: (html_string, key_bindings) | |
| html_string: Complete HTML for the video annotation interface | |
| key_bindings: List of keyboard shortcuts | |
| Raises: | |
| ValueError: If required fields are missing or invalid | |
| """ | |
| return safe_generate_layout(annotation_scheme, _generate_video_annotation_layout_internal) | |
| def _generate_video_annotation_layout_internal(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]: | |
| """ | |
| Internal function to generate video annotation layout after validation. | |
| """ | |
| schema_name = annotation_scheme.get('name', 'video_annotation') | |
| logger.debug(f"Generating video annotation layout for schema: {schema_name}") | |
| # Get mode (default to "segment") | |
| mode = annotation_scheme.get('mode', 'segment') | |
| if mode not in VALID_MODES: | |
| error_msg = f"Invalid mode '{mode}' in schema: {schema_name}. Must be one of: {VALID_MODES}" | |
| logger.error(error_msg) | |
| raise ValueError(error_msg) | |
| # Validate labels for segment/frame/keyframe/tracking/combined modes | |
| labels = [] | |
| if mode in ['segment', 'frame', 'keyframe', 'tracking', 'combined']: | |
| if 'labels' not in annotation_scheme: | |
| error_msg = f"Missing labels in schema: {schema_name} (required for mode '{mode}')" | |
| logger.error(error_msg) | |
| raise ValueError(error_msg) | |
| labels = _process_labels(annotation_scheme['labels']) | |
| # Validate segment_schemes for combined mode | |
| segment_schemes = [] | |
| if mode in ['combined'] and 'segment_schemes' in annotation_scheme: | |
| segment_schemes = annotation_scheme['segment_schemes'] | |
| if not isinstance(segment_schemes, list): | |
| error_msg = f"segment_schemes must be a list in schema: {schema_name}" | |
| logger.error(error_msg) | |
| raise ValueError(error_msg) | |
| # Get configuration options | |
| min_segments = annotation_scheme.get('min_segments', 0) | |
| max_segments = annotation_scheme.get('max_segments', None) | |
| timeline_height = annotation_scheme.get('timeline_height', 70) | |
| overview_height = annotation_scheme.get('overview_height', 40) | |
| zoom_enabled = annotation_scheme.get('zoom_enabled', True) | |
| playback_rate_control = annotation_scheme.get('playback_rate_control', True) | |
| frame_stepping = annotation_scheme.get('frame_stepping', True) | |
| show_timecode = annotation_scheme.get('show_timecode', True) | |
| video_fps = annotation_scheme.get('video_fps', 30) | |
| # AI support configuration | |
| ai_support = annotation_scheme.get("ai_support", {}) | |
| ai_enabled = ai_support.get("enabled", False) | |
| # source_field: Links this annotation schema to a display field from instance_display | |
| source_field = annotation_scheme.get("source_field", "") | |
| # Build config object for JavaScript | |
| js_config = { | |
| "schemaName": schema_name, | |
| "mode": mode, | |
| "labels": labels, | |
| "segmentSchemes": segment_schemes, | |
| "minSegments": min_segments, | |
| "maxSegments": max_segments, | |
| "timelineHeight": timeline_height, | |
| "overviewHeight": overview_height, | |
| "zoomEnabled": zoom_enabled, | |
| "playbackRateControl": playback_rate_control, | |
| "frameStepping": frame_stepping, | |
| "showTimecode": show_timecode, | |
| "videoFps": video_fps, | |
| "aiSupport": ai_enabled, | |
| "aiFeatures": ai_support.get("features", {}) if ai_enabled else {}, | |
| "sourceField": source_field, | |
| } | |
| # Generate HTML | |
| html = _generate_html(annotation_scheme, js_config, schema_name, labels, mode, ai_enabled, ai_support) | |
| # Generate keybindings | |
| keybindings = _generate_keybindings(labels, mode, frame_stepping) | |
| logger.info(f"Successfully generated video annotation layout for {schema_name}") | |
| return html, keybindings | |
| def _process_labels(labels_config: List) -> List[Dict[str, Any]]: | |
| """ | |
| Process label configuration and assign colors. | |
| Args: | |
| labels_config: List of label configs (strings or dicts) | |
| Returns: | |
| List of processed label dicts with name, color, and optional key_value | |
| """ | |
| processed = [] | |
| for i, label in enumerate(labels_config): | |
| if isinstance(label, str): | |
| processed.append({ | |
| "name": label, | |
| "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)], | |
| }) | |
| elif isinstance(label, dict): | |
| processed.append({ | |
| "name": label.get("name", f"label_{i}"), | |
| "color": label.get("color", DEFAULT_COLORS[i % len(DEFAULT_COLORS)]), | |
| "key_value": label.get("key_value"), | |
| }) | |
| else: | |
| processed.append({ | |
| "name": str(label), | |
| "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)], | |
| }) | |
| return processed | |
| def _generate_html( | |
| annotation_scheme: Dict[str, Any], | |
| js_config: Dict[str, Any], | |
| schema_name: str, | |
| labels: List[Dict[str, Any]], | |
| mode: str, | |
| ai_enabled: bool = False, | |
| ai_support: Dict[str, Any] = None | |
| ) -> str: | |
| """ | |
| Generate the HTML for the video annotation interface. | |
| """ | |
| escaped_name = escape_html_content(schema_name) | |
| description = escape_html_content(annotation_scheme.get('description', '')) | |
| config_json = json.dumps(js_config) | |
| timeline_height = js_config.get('timelineHeight', 70) | |
| overview_height = js_config.get('overviewHeight', 40) | |
| # Generate label buttons | |
| label_selector = "" | |
| if labels: | |
| label_selector = _generate_label_selector(labels) | |
| # Generate AI toolbar if enabled | |
| ai_toolbar_html = "" | |
| ai_init_script = "" | |
| if ai_enabled: | |
| ai_features = ai_support.get("features", {}) if ai_support else {} | |
| ai_toolbar_html = _generate_video_ai_toolbar(ai_features, mode) | |
| ai_init_script = _generate_video_ai_init_script(escaped_name) | |
| # Generate playback rate control | |
| playback_rate_html = "" | |
| if js_config.get('playbackRateControl'): | |
| playback_rate_html = ''' | |
| <div class="playback-rate-group"> | |
| <span class="tool-group-label">Speed:</span> | |
| <select class="playback-rate-select"> | |
| <option value="0.1">0.1x</option> | |
| <option value="0.25">0.25x</option> | |
| <option value="0.5">0.5x</option> | |
| <option value="0.75">0.75x</option> | |
| <option value="1" selected>1x</option> | |
| <option value="1.25">1.25x</option> | |
| <option value="1.5">1.5x</option> | |
| <option value="2">2x</option> | |
| </select> | |
| </div> | |
| ''' | |
| # Generate frame stepping controls | |
| frame_stepping_html = "" | |
| if js_config.get('frameStepping'): | |
| frame_stepping_html = ''' | |
| <div class="frame-stepping-group"> | |
| <button type="button" class="frame-btn" data-action="frame-back" title="Step back one frame for precise positioning (Keyboard: ,)">|<</button> | |
| <button type="button" class="frame-btn" data-action="frame-forward" title="Step forward one frame for precise positioning (Keyboard: .)">|></button> | |
| </div> | |
| ''' | |
| # Generate mode-specific controls | |
| mode_controls_html = "" | |
| if mode in ['keyframe', 'combined']: | |
| mode_controls_html += ''' | |
| <div class="keyframe-group"> | |
| <button type="button" class="keyframe-btn" data-action="mark-keyframe" title="Mark Keyframe (K)">Mark Keyframe</button> | |
| </div> | |
| ''' | |
| if mode in ['frame', 'combined']: | |
| mode_controls_html += ''' | |
| <div class="frame-classify-group"> | |
| <button type="button" class="frame-classify-btn" data-action="classify-frame" title="Classify Current Frame (C)">Classify Frame</button> | |
| </div> | |
| ''' | |
| if mode in ['tracking', 'combined']: | |
| mode_controls_html += ''' | |
| <div class="tracking-controls-group"> | |
| <button type="button" class="tracking-btn" data-action="create-track" title="Create a new object track (T)">+ Track</button> | |
| <button type="button" class="tracking-btn delete-btn" data-action="delete-track" title="Delete selected track" disabled>Delete Track</button> | |
| <select class="interpolation-select" title="Interpolation method between keyframes"> | |
| <option value="linear">Linear</option> | |
| <option value="cubic">Cubic (smooth)</option> | |
| <option value="constant">Constant (hold)</option> | |
| </select> | |
| </div> | |
| ''' | |
| # Generate timecode display | |
| timecode_html = "" | |
| if js_config.get('showTimecode'): | |
| timecode_html = ''' | |
| <div class="timecode-display"> | |
| <span class="frame-number">Frame: <span class="frame-value">0</span></span> | |
| <span class="timecode">00:00:00.000</span> | |
| </div> | |
| ''' | |
| # source_field attribute for linking to display fields | |
| source_field = annotation_scheme.get("source_field", "") | |
| source_field_attr = f' data-source-field="{escape_html_content(source_field)}"' if source_field else "" | |
| html = f''' | |
| <form id="{escaped_name}" class="annotation-form video-annotation" action="javascript:void(0)"{source_field_attr}> | |
| <fieldset schema="{escaped_name}"> | |
| <legend>{description}</legend> | |
| <!-- Video Annotation Container --> | |
| <div class="video-annotation-container" data-schema="{escaped_name}" data-mode="{mode}"> | |
| <!-- Video Preview Panel --> | |
| <div class="video-preview-panel"> | |
| <video id="video-{escaped_name}" class="video-element" controls></video> | |
| <div class="video-overlay"> | |
| {timecode_html} | |
| </div> | |
| <!-- Tracking canvas overlay (for tracking mode) --> | |
| <canvas id="tracking-canvas-{escaped_name}" class="tracking-overlay" style="display: none;"></canvas> | |
| </div> | |
| <!-- Instructions panel (collapsible) --> | |
| <details class="video-instructions" style="margin-bottom: 0.75rem; padding: 0.5rem 0.75rem; background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 0.375rem; font-size: 0.8rem;"> | |
| <summary style="cursor: pointer; font-weight: 600; color: #0369a1;">How to annotate video segments (click to expand)</summary> | |
| <div style="margin-top: 0.5rem; color: #475569; line-height: 1.5;"> | |
| <p style="margin: 0.25rem 0;"><strong>To create a segment:</strong></p> | |
| <ol style="margin: 0.25rem 0 0.5rem 1.25rem; padding: 0;"> | |
| <li>Select a label (colored buttons below)</li> | |
| <li>Play/scrub video to the start point, then click <kbd>[</kbd> or press the [ key</li> | |
| <li>Move to the end point, then click <kbd>]</kbd> or press the ] key</li> | |
| <li>Click <kbd>+ Segment</kbd> or press Enter to create the segment</li> | |
| </ol> | |
| <p style="margin: 0.25rem 0;"><strong>Timeline controls:</strong> <kbd>+</kbd> zoom in, <kbd>-</kbd> zoom out, <kbd>Fit</kbd> show entire video</p> | |
| <p style="margin: 0.25rem 0;"><strong>Playback:</strong> <kbd>Space</kbd> play/pause, <kbd>◀▶</kbd> frame step, change speed with dropdown</p> | |
| </div> | |
| </details> | |
| <!-- Toolbar --> | |
| <div class="video-annotation-toolbar"> | |
| <!-- Playback controls --> | |
| <div class="playback-group"> | |
| <button type="button" class="playback-btn" data-action="play" title="Play or pause the video (Keyboard: Space)"> | |
| <span class="play-icon">▶</span> | |
| <span class="pause-icon" style="display:none;">⏸</span> | |
| </button> | |
| <button type="button" class="playback-btn" data-action="stop" title="Stop playback and return to start">⏹</button> | |
| {frame_stepping_html} | |
| <span class="time-display"> | |
| <span class="current-time">0:00</span> / <span class="total-time">0:00</span> | |
| </span> | |
| </div> | |
| {playback_rate_html} | |
| <!-- Label selector --> | |
| {label_selector} | |
| <!-- Mode-specific controls --> | |
| {mode_controls_html} | |
| <!-- Zoom controls --> | |
| <div class="zoom-group" title="Timeline zoom controls"> | |
| <button type="button" class="zoom-btn" data-action="zoom-in" title="Zoom in on timeline to see more detail (Keyboard: +)">+</button> | |
| <button type="button" class="zoom-btn" data-action="zoom-out" title="Zoom out on timeline to see more of the video (Keyboard: -)">-</button> | |
| <button type="button" class="zoom-btn" data-action="zoom-fit" title="Fit entire video duration in timeline view (Keyboard: 0)">Fit</button> | |
| </div> | |
| <!-- Segment controls --> | |
| <div class="segment-group" title="Segment creation controls"> | |
| <button type="button" class="segment-btn" data-action="set-start" title="Mark the START of a new segment at current video position (Keyboard: [)">[</button> | |
| <button type="button" class="segment-btn" data-action="set-end" title="Mark the END of a new segment at current video position (Keyboard: ])">]</button> | |
| <button type="button" class="segment-btn" data-action="create-segment" title="Create a segment from marked start/end points with selected label (Keyboard: Enter)">+ Segment</button> | |
| <button type="button" class="segment-btn delete-btn" data-action="delete-segment" title="Delete the currently selected segment (Keyboard: Delete)" disabled>Delete</button> | |
| </div> | |
| <!-- Annotation count --> | |
| <div class="count-group"> | |
| <span class="segment-count">Segments: <span class="count-value">0</span></span> | |
| </div> | |
| </div> | |
| {ai_toolbar_html} | |
| <!-- Timeline (Peaks.js) --> | |
| <div class="timeline-wrapper"> | |
| <div id="zoomview-{escaped_name}" class="timeline-container" style="height: {timeline_height}px;"></div> | |
| <div id="overview-{escaped_name}" class="overview-container" style="height: {overview_height}px;"></div> | |
| </div> | |
| <!-- Annotation list --> | |
| <div class="annotation-list-container"> | |
| <h4>Annotations</h4> | |
| <div id="annotation-list-{escaped_name}" class="annotation-list"></div> | |
| </div> | |
| <!-- Segment questions panel (for combined mode) --> | |
| <div id="segment-questions-{escaped_name}" class="segment-questions-panel" style="display: none;"> | |
| <h4>Segment Details</h4> | |
| <div class="segment-questions-content"></div> | |
| </div> | |
| <!-- Tracking panel (for tracking mode) --> | |
| <div id="tracking-panel-{escaped_name}" class="tracking-panel" style="display: {'block' if mode in ['tracking', 'combined'] else 'none'};"> | |
| <h4>Object Tracks</h4> | |
| <div class="tracking-instructions" style="font-size: 0.8rem; color: #666; margin-bottom: 0.5rem;"> | |
| Click "+ Track" to create a track, then draw bounding boxes on the video. Boxes are interpolated between keyframes. | |
| </div> | |
| <div class="tracking-track-list"></div> | |
| </div> | |
| <!-- Hidden input for storing annotation data --> | |
| <!-- autocomplete="off" prevents Firefox from restoring old values on reload --> | |
| <input type="hidden" | |
| name="{escaped_name}" | |
| id="input-{escaped_name}" | |
| class="annotation-data-input" | |
| autocomplete="off" | |
| value=""> | |
| </div> | |
| <!-- Initialize the annotation manager --> | |
| <script> | |
| (function() {{ | |
| // Guard against multiple initializations | |
| var initKey = '__videoAnnotationInit_{escaped_name}'; | |
| if (window[initKey]) {{ | |
| console.log('[VideoAnnotation INIT] Already initialized, skipping'); | |
| return; | |
| }} | |
| window[initKey] = true; | |
| console.log('[VideoAnnotation INIT] Script starting for schema: {escaped_name}'); | |
| function initWhenReady() {{ | |
| // Only wait for VideoAnnotationManager - Peaks is optional | |
| if (typeof VideoAnnotationManager === 'undefined') {{ | |
| setTimeout(initWhenReady, 100); | |
| return; | |
| }} | |
| console.log('[VideoAnnotation INIT] VideoAnnotationManager ready, Peaks available:', typeof Peaks !== 'undefined'); | |
| var container = document.querySelector('.video-annotation-container[data-schema="{escaped_name}"]'); | |
| if (!container) {{ | |
| console.error('[VideoAnnotation INIT] Container not found!'); | |
| return; | |
| }} | |
| // Check if already initialized on this container | |
| if (container.videoAnnotationManager) {{ | |
| console.log('[VideoAnnotation INIT] Container already has manager, skipping'); | |
| return; | |
| }} | |
| console.log('[VideoAnnotation INIT] Initializing video annotation...'); | |
| var config = {config_json}; | |
| var videoId = 'video-{escaped_name}'; | |
| var zoomviewId = 'zoomview-{escaped_name}'; | |
| var overviewId = 'overview-{escaped_name}'; | |
| var inputId = 'input-{escaped_name}'; | |
| var annotationListId = 'annotation-list-{escaped_name}'; | |
| var questionsId = 'segment-questions-{escaped_name}'; | |
| var trackingCanvasId = 'tracking-canvas-{escaped_name}'; | |
| // Get video URL from the instance display | |
| console.log('[VideoAnnotation DEBUG] Looking for video URL...'); | |
| console.log('[VideoAnnotation DEBUG] config.sourceField:', config.sourceField); | |
| var videoUrl = null; | |
| // Method 1: Use sourceField to find video element by data-field-key | |
| if (config.sourceField) {{ | |
| var videoContainer = document.querySelector('.video-container[data-field-key="' + config.sourceField + '"]'); | |
| console.log('[VideoAnnotation DEBUG] Looking for video-container with data-field-key:', config.sourceField, videoContainer); | |
| if (videoContainer) {{ | |
| var videoEl = videoContainer.querySelector('video'); | |
| if (videoEl) {{ | |
| videoUrl = videoEl.getAttribute('data-source-url') || | |
| (videoEl.querySelector('source') && videoEl.querySelector('source').src) || | |
| videoEl.src; | |
| console.log('[VideoAnnotation DEBUG] Found video via sourceField:', videoUrl); | |
| }} | |
| }} | |
| }} | |
| // Method 2: Find any video element with data-source-url | |
| if (!videoUrl) {{ | |
| var anyVideo = document.querySelector('video[data-source-url]'); | |
| if (anyVideo) {{ | |
| videoUrl = anyVideo.getAttribute('data-source-url'); | |
| console.log('[VideoAnnotation DEBUG] Found video via data-source-url attribute:', videoUrl); | |
| }} | |
| }} | |
| // Method 3: Find any video element with source child | |
| if (!videoUrl) {{ | |
| var anyVideo = document.querySelector('video source'); | |
| if (anyVideo && anyVideo.src) {{ | |
| videoUrl = anyVideo.src; | |
| console.log('[VideoAnnotation DEBUG] Found video via source element:', videoUrl); | |
| }} | |
| }} | |
| // Method 4: Search in instance text containers | |
| if (!videoUrl) {{ | |
| var textContent = document.getElementById('text-content'); | |
| var instanceText = document.getElementById('instance-text'); | |
| var instanceTextUnderscore = document.getElementById('instance_text'); | |
| var instanceContainer = textContent || instanceText || instanceTextUnderscore; | |
| if (instanceContainer) {{ | |
| var text = instanceContainer.textContent || instanceContainer.innerText; | |
| if (text && (text.trim().startsWith('http') || text.trim().endsWith('.mp4') || text.trim().endsWith('.webm') || text.trim().endsWith('.ogg'))) {{ | |
| videoUrl = text.trim(); | |
| console.log('[VideoAnnotation DEBUG] Found video URL in text content:', videoUrl); | |
| }} | |
| }} | |
| }} | |
| // Debug: Log if video URL not found | |
| if (!videoUrl) {{ | |
| console.error('[VideoAnnotation ERROR] Could not find video URL!'); | |
| // Show visible error message | |
| var errorDiv = document.createElement('div'); | |
| errorDiv.style.cssText = 'background: #fee2e2; border: 2px solid #ef4444; color: #dc2626; padding: 1rem; margin: 1rem 0; border-radius: 0.5rem;'; | |
| errorDiv.innerHTML = '<strong>Video URL not found!</strong><br>Check browser console for details.'; | |
| container.insertBefore(errorDiv, container.firstChild); | |
| }} else {{ | |
| console.log('[VideoAnnotation DEBUG] Video URL found:', videoUrl); | |
| }} | |
| // Check if video element exists | |
| var videoElement = document.getElementById(videoId); | |
| console.log('[VideoAnnotation DEBUG] Video element ID:', videoId); | |
| console.log('[VideoAnnotation DEBUG] Video element found:', videoElement); | |
| if (!videoElement) {{ | |
| console.error('[VideoAnnotation ERROR] Video element not found!'); | |
| var errorDiv = document.createElement('div'); | |
| errorDiv.style.cssText = 'background: #fee2e2; border: 2px solid #ef4444; color: #dc2626; padding: 1rem; margin: 1rem 0; border-radius: 0.5rem;'; | |
| errorDiv.innerHTML = '<strong>Video element not found!</strong><br>Expected element with ID: ' + videoId; | |
| container.insertBefore(errorDiv, container.firstChild); | |
| return; | |
| }} | |
| // Initialize manager | |
| var manager = new VideoAnnotationManager({{ | |
| container: container, | |
| videoId: videoId, | |
| zoomviewId: zoomviewId, | |
| overviewId: overviewId, | |
| inputId: inputId, | |
| annotationListId: annotationListId, | |
| questionsId: questionsId, | |
| trackingCanvasId: trackingCanvasId, | |
| config: config | |
| }}); | |
| // Store reference on container | |
| container.videoAnnotationManager = manager; | |
| // Load video if available | |
| if (videoUrl) {{ | |
| console.log('[VideoAnnotation DEBUG] Calling manager.loadVideo with URL:', videoUrl); | |
| manager.loadVideo(videoUrl); | |
| }} else {{ | |
| console.error('[VideoAnnotation ERROR] No video URL to load!'); | |
| }} | |
| // Wire up toolbar buttons | |
| container.querySelectorAll('.playback-btn').forEach(function(btn) {{ | |
| btn.addEventListener('click', function() {{ | |
| var action = this.dataset.action; | |
| if (action === 'play') manager.togglePlayPause(); | |
| else if (action === 'stop') manager.stop(); | |
| }}); | |
| }}); | |
| container.querySelectorAll('.frame-btn').forEach(function(btn) {{ | |
| btn.addEventListener('click', function() {{ | |
| var action = this.dataset.action; | |
| if (action === 'frame-back') manager.stepFrameBackward(); | |
| else if (action === 'frame-forward') manager.stepFrameForward(); | |
| }}); | |
| }}); | |
| container.querySelectorAll('.label-btn').forEach(function(btn) {{ | |
| btn.addEventListener('click', function() {{ | |
| var label = this.dataset.label; | |
| var color = this.dataset.color; | |
| manager.setActiveLabel(label, color); | |
| container.querySelectorAll('.label-btn').forEach(function(b) {{ | |
| b.classList.remove('active'); | |
| }}); | |
| this.classList.add('active'); | |
| }}); | |
| }}); | |
| container.querySelectorAll('.zoom-btn').forEach(function(btn) {{ | |
| btn.addEventListener('click', function() {{ | |
| var action = this.dataset.action; | |
| if (action === 'zoom-in') manager.zoomIn(); | |
| else if (action === 'zoom-out') manager.zoomOut(); | |
| else if (action === 'zoom-fit') manager.zoomToFit(); | |
| }}); | |
| }}); | |
| container.querySelectorAll('.segment-btn').forEach(function(btn) {{ | |
| btn.addEventListener('click', function() {{ | |
| var action = this.dataset.action; | |
| if (action === 'set-start') manager.setSelectionStart(); | |
| else if (action === 'set-end') manager.setSelectionEnd(); | |
| else if (action === 'create-segment') manager.createSegmentFromSelection(); | |
| else if (action === 'delete-segment') manager.deleteSelectedAnnotation(); | |
| }}); | |
| }}); | |
| container.querySelectorAll('.keyframe-btn').forEach(function(btn) {{ | |
| btn.addEventListener('click', function() {{ | |
| manager.markKeyframe(manager.activeLabel); | |
| }}); | |
| }}); | |
| container.querySelectorAll('.frame-classify-btn').forEach(function(btn) {{ | |
| btn.addEventListener('click', function() {{ | |
| manager.classifyCurrentFrame(manager.activeLabel, manager.activeLabelColor); | |
| }}); | |
| }}); | |
| // Playback rate control | |
| var rateSelect = container.querySelector('.playback-rate-select'); | |
| if (rateSelect) {{ | |
| rateSelect.addEventListener('change', function() {{ | |
| manager.setPlaybackRate(parseFloat(this.value)); | |
| }}); | |
| }} | |
| // Tracking mode initialization | |
| var trackingMode = container.dataset.mode; | |
| if ((trackingMode === 'tracking' || trackingMode === 'combined') && typeof TrackingUIManager !== 'undefined') {{ | |
| console.log('[VideoAnnotation] Initializing TrackingUIManager for mode:', trackingMode); | |
| // Get tracking canvas and video element | |
| var trackingCanvas = document.getElementById(trackingCanvasId); | |
| var videoEl = document.getElementById(videoId); | |
| if (trackingCanvas && videoEl) {{ | |
| // Show tracking canvas | |
| trackingCanvas.style.display = 'block'; | |
| // Initialize tracking UI manager | |
| var trackingManager = new TrackingUIManager({{ | |
| canvas: trackingCanvas, | |
| video: videoEl, | |
| annotationManager: manager, | |
| config: {{ | |
| videoFps: config.videoFps || 30, | |
| interpolation: 'linear', | |
| autoAdvanceFrames: config.autoAdvanceFrames || 5 | |
| }} | |
| }}); | |
| // Store reference on manager | |
| manager.trackingManager = trackingManager; | |
| // Sync canvas size with video | |
| function syncTrackingCanvasSize() {{ | |
| // Use clientWidth/clientHeight for display size | |
| var w = videoEl.clientWidth || videoEl.offsetWidth || 640; | |
| var h = videoEl.clientHeight || videoEl.offsetHeight || 360; | |
| if (w > 0 && h > 0) {{ | |
| trackingCanvas.width = w; | |
| trackingCanvas.height = h; | |
| console.log('[VideoAnnotation] Tracking canvas sized to:', w, 'x', h); | |
| trackingManager.renderOverlay(); | |
| }} | |
| }} | |
| videoEl.addEventListener('loadedmetadata', syncTrackingCanvasSize); | |
| videoEl.addEventListener('loadeddata', syncTrackingCanvasSize); | |
| videoEl.addEventListener('canplay', syncTrackingCanvasSize); | |
| videoEl.addEventListener('resize', syncTrackingCanvasSize); | |
| window.addEventListener('resize', syncTrackingCanvasSize); | |
| // Also sync immediately in case video already loaded | |
| setTimeout(syncTrackingCanvasSize, 100); | |
| setTimeout(syncTrackingCanvasSize, 500); | |
| // Re-render tracking overlay on time update | |
| videoEl.addEventListener('timeupdate', function() {{ | |
| trackingManager.renderOverlay(); | |
| }}); | |
| // Wire up tracking toolbar buttons | |
| container.querySelectorAll('.tracking-btn').forEach(function(btn) {{ | |
| btn.addEventListener('click', function() {{ | |
| var action = this.dataset.action; | |
| if (action === 'create-track') {{ | |
| var label = manager.activeLabel || 'object'; | |
| var color = manager.activeLabelColor || '#FF6B6B'; | |
| trackingManager.createTrack(label, color); | |
| // Enable delete button | |
| var deleteBtn = container.querySelector('[data-action="delete-track"]'); | |
| if (deleteBtn) deleteBtn.disabled = false; | |
| }} else if (action === 'delete-track') {{ | |
| if (trackingManager.activeTrackId) {{ | |
| trackingManager.deleteTrack(trackingManager.activeTrackId); | |
| // Disable delete button if no more tracks | |
| if (Object.keys(trackingManager.tracks).length === 0) {{ | |
| this.disabled = true; | |
| }} | |
| }} | |
| }} | |
| }}); | |
| }}); | |
| // Interpolation method selector | |
| var interpSelect = container.querySelector('.interpolation-select'); | |
| if (interpSelect) {{ | |
| interpSelect.addEventListener('change', function() {{ | |
| if (trackingManager.activeTrackId) {{ | |
| trackingManager.setInterpolation(trackingManager.activeTrackId, this.value); | |
| }} | |
| }}); | |
| }} | |
| // Extend manager's save method to include tracking data | |
| var originalSave = manager.save ? manager.save.bind(manager) : null; | |
| manager.save = function() {{ | |
| var data = originalSave ? originalSave() : {{}}; | |
| data.tracks = trackingManager.getTrackingData(); | |
| return data; | |
| }}; | |
| // Load existing tracking data if available | |
| var existingData = manager.getAnnotationData ? manager.getAnnotationData() : null; | |
| if (existingData && existingData.tracks) {{ | |
| trackingManager.loadTrackingData(existingData.tracks); | |
| }} | |
| console.log('[VideoAnnotation] TrackingUIManager initialized successfully'); | |
| }} else {{ | |
| console.warn('[VideoAnnotation] Tracking canvas or video element not found'); | |
| }} | |
| }} else if (trackingMode === 'tracking' || trackingMode === 'combined') {{ | |
| console.warn('[VideoAnnotation] TrackingUIManager not loaded, tracking features unavailable'); | |
| }} | |
| // Set default label | |
| var firstLabelBtn = container.querySelector('.label-btn'); | |
| if (firstLabelBtn) firstLabelBtn.click(); | |
| {ai_init_script} | |
| }} | |
| if (document.readyState === 'loading') {{ | |
| document.addEventListener('DOMContentLoaded', initWhenReady); | |
| }} else {{ | |
| initWhenReady(); | |
| }} | |
| }})(); | |
| </script> | |
| </fieldset> | |
| </form> | |
| ''' | |
| return html | |
| def _generate_video_ai_toolbar(ai_features: Dict[str, Any], mode: str) -> str: | |
| """ | |
| Generate HTML for the video AI assistance toolbar. | |
| """ | |
| scene_detection_enabled = ai_features.get("scene_detection", True) | |
| frame_classification_enabled = ai_features.get("frame_classification", False) | |
| keyframe_detection_enabled = ai_features.get("keyframe_detection", False) | |
| tracking_enabled = ai_features.get("tracking", False) | |
| pre_annotate_enabled = ai_features.get("pre_annotate", True) | |
| hint_enabled = ai_features.get("hint", True) | |
| buttons = [] | |
| if scene_detection_enabled and mode in ['segment', 'combined']: | |
| buttons.append( | |
| '<button type="button" class="ai-btn" data-action="scene_detection" title="Detect scene boundaries">' | |
| '<span class="ai-btn-icon">🎬</span> Scenes</button>' | |
| ) | |
| if pre_annotate_enabled: | |
| buttons.append( | |
| '<button type="button" class="ai-btn" data-action="pre_annotate" title="Auto-segment the entire video">' | |
| '<span class="ai-btn-icon">⚡</span> Auto</button>' | |
| ) | |
| if frame_classification_enabled and mode in ['frame', 'combined']: | |
| buttons.append( | |
| '<button type="button" class="ai-btn" data-action="frame_classification" title="Classify current frame">' | |
| '<span class="ai-btn-icon">🏷️</span> Classify</button>' | |
| ) | |
| if keyframe_detection_enabled and mode in ['keyframe', 'combined']: | |
| buttons.append( | |
| '<button type="button" class="ai-btn" data-action="keyframe_detection" title="Detect keyframes">' | |
| '<span class="ai-btn-icon">📍</span> Keyframes</button>' | |
| ) | |
| if tracking_enabled and mode == 'tracking': | |
| buttons.append( | |
| '<button type="button" class="ai-btn" data-action="tracking_suggestion" title="Get tracking suggestions">' | |
| '<span class="ai-btn-icon">👁️</span> Track</button>' | |
| ) | |
| if hint_enabled: | |
| buttons.append( | |
| '<button type="button" class="ai-btn" data-action="hint" title="Get a hint for annotation">' | |
| '<span class="ai-btn-icon">💡</span> Hint</button>' | |
| ) | |
| if not buttons: | |
| return "" | |
| return f''' | |
| <!-- AI Assistance Toolbar --> | |
| <div class="ai-toolbar"> | |
| <div class="ai-toolbar-group"> | |
| <span class="ai-toolbar-label">AI Assist:</span> | |
| {" ".join(buttons)} | |
| </div> | |
| <div class="ai-suggestion-controls" style="display: none;"> | |
| <span class="suggestion-count">0 suggestions</span> | |
| <button type="button" class="ai-btn ai-btn-accept" data-action="accept-all" title="Accept all suggestions"> | |
| Accept All | |
| </button> | |
| <button type="button" class="ai-btn ai-btn-clear" data-action="clear" title="Clear all suggestions"> | |
| Clear | |
| </button> | |
| </div> | |
| <div class="ai-loading-indicator" style="display: none;"> | |
| <span class="spinner"></span> Loading... | |
| </div> | |
| </div> | |
| <!-- AI Tooltip Container --> | |
| <div class="ai-tooltip-container" style="display: none;"></div> | |
| ''' | |
| def _generate_video_ai_init_script(escaped_name: str) -> str: | |
| """ | |
| Generate JavaScript initialization code for video AI assistant. | |
| """ | |
| return f''' | |
| // Initialize AI assistant if enabled and VisualAIAssistantManager is available | |
| if (config.aiSupport && typeof VisualAIAssistantManager !== 'undefined') {{ | |
| var annotationId = Array.from(document.querySelectorAll('.annotation-form')).indexOf( | |
| document.getElementById('{escaped_name}') | |
| ); | |
| container.aiAssistant = new VisualAIAssistantManager({{ | |
| annotationType: 'video_annotation', | |
| annotationId: annotationId >= 0 ? annotationId : 0, | |
| annotationManager: manager | |
| }}); | |
| }} | |
| ''' | |
| def _generate_label_selector(labels: List[Dict[str, Any]]) -> str: | |
| """ | |
| Generate HTML for label selection buttons. | |
| """ | |
| import html | |
| buttons = [] | |
| for label in labels: | |
| # Escape for both content and attributes | |
| name_escaped = escape_html_content(label["name"]) | |
| name_attr = html.escape(label["name"], quote=True) | |
| color = label["color"] | |
| key_hint = f' ({label["key_value"]})' if label.get("key_value") else "" | |
| key_hint_escaped = html.escape(key_hint, quote=True) | |
| buttons.append( | |
| f'<button type="button" class="label-btn" data-label="{name_attr}" data-color="{color}" ' | |
| f'title="{name_attr}{key_hint_escaped}" style="--label-color: {color};">' | |
| f'<span class="label-color-dot" style="background-color: {color};"></span>' | |
| f'{name_escaped}</button>' | |
| ) | |
| return f''' | |
| <div class="label-group"> | |
| <span class="tool-group-label">Label:</span> | |
| {"".join(buttons)} | |
| </div> | |
| ''' | |
| def _generate_keybindings(labels: List[Dict[str, Any]], mode: str, frame_stepping: bool) -> List[Tuple[str, str]]: | |
| """ | |
| Generate keybinding list for the schema. | |
| """ | |
| keybindings = [] | |
| # Playback shortcuts | |
| keybindings.append(("Space", "Play/Pause")) | |
| keybindings.append(("Left/Right", "Seek 5 seconds")) | |
| # Frame stepping | |
| if frame_stepping: | |
| keybindings.append((",", "Previous frame")) | |
| keybindings.append((".", "Next frame")) | |
| # Label shortcuts | |
| for label in labels: | |
| if label.get("key_value"): | |
| keybindings.append((label["key_value"], f"Select: {label['name']}")) | |
| # Segment shortcuts | |
| if mode in ['segment', 'combined']: | |
| keybindings.append(("[", "Set segment start")) | |
| keybindings.append(("]", "Set segment end")) | |
| keybindings.append(("Enter", "Create segment")) | |
| # Keyframe shortcut | |
| if mode in ['keyframe', 'combined']: | |
| keybindings.append(("K", "Mark keyframe")) | |
| # Frame classification | |
| if mode in ['frame', 'combined']: | |
| keybindings.append(("C", "Classify current frame")) | |
| # Delete shortcut | |
| keybindings.append(("Del", "Delete selected")) | |
| # Zoom shortcuts | |
| keybindings.append(("+/-", "Zoom in/out")) | |
| keybindings.append(("0", "Fit to view")) | |
| return keybindings | |