""" 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 = '''
Speed:
''' # Generate frame stepping controls frame_stepping_html = "" if js_config.get('frameStepping'): frame_stepping_html = '''
''' # Generate mode-specific controls mode_controls_html = "" if mode in ['keyframe', 'combined']: mode_controls_html += '''
''' if mode in ['frame', 'combined']: mode_controls_html += '''
''' if mode in ['tracking', 'combined']: mode_controls_html += '''
''' # Generate timecode display timecode_html = "" if js_config.get('showTimecode'): timecode_html = '''
Frame: 0 00:00:00.000
''' # 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'''
{description}
{timecode_html}
How to annotate video segments (click to expand)

To create a segment:

  1. Select a label (colored buttons below)
  2. Play/scrub video to the start point, then click [ or press the [ key
  3. Move to the end point, then click ] or press the ] key
  4. Click + Segment or press Enter to create the segment

Timeline controls: + zoom in, - zoom out, Fit show entire video

Playback: Space play/pause, ◀▶ frame step, change speed with dropdown

{frame_stepping_html} 0:00 / 0:00
{playback_rate_html} {label_selector} {mode_controls_html}
Segments: 0
{ai_toolbar_html}

Annotations

Object Tracks

Click "+ Track" to create a track, then draw bounding boxes on the video. Boxes are interpolated between keyframes.
''' 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( '' ) if pre_annotate_enabled: buttons.append( '' ) if frame_classification_enabled and mode in ['frame', 'combined']: buttons.append( '' ) if keyframe_detection_enabled and mode in ['keyframe', 'combined']: buttons.append( '' ) if tracking_enabled and mode == 'tracking': buttons.append( '' ) if hint_enabled: buttons.append( '' ) if not buttons: return "" return f'''
AI Assist: {" ".join(buttons)}
''' 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'' ) return f'''
Label: {"".join(buttons)}
''' 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