//! MoMentry Playground - Main entry point //! //! Unified media player with ASR/YOLO/Chunks overlay support use anyhow::Result; use log::{error, info, warn}; use sdl2::pixels::PixelFormatEnum; use sdl2::rect::Rect; use sdl2::ttf::{self, Font}; use std::path::Path; mod config; mod overlay; mod player; mod search; mod web; use config::Config; use overlay::{AsrLoader, ChunkLoader, YoloLoader}; use player::audio::AudioPlayer; use player::ffmpeg::FFmpegDecoder; use player::state::{PlaybackState, PlayerState}; use search::{create_searcher, SearchResult, VectorSearcher}; fn main() -> Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let config = Config::load()?; info!("MoMentry Playground starting..."); info!("Window: {}x{}", config.width, config.height); if let Err(e) = run(&config) { error!("Application error: {}", e); std::process::exit(1); } Ok(()) } fn run(config: &Config) -> Result<()> { let sdl_context = sdl2::init().map_err(|e| anyhow::anyhow!("SDL init failed: {}", e))?; let video_subsystem = sdl_context .video() .map_err(|e| anyhow::anyhow!("Video subsystem failed: {}", e))?; let ttf_context = ttf::init().map_err(|e| anyhow::anyhow!("TTF init failed: {}", e))?; let font: Option = ttf_context .load_font("/System/Library/Fonts/Supplemental/Arial.ttf", 16) .ok(); let window = video_subsystem .window("MoMentry Playground", config.width, config.height) .position_centered() .build() .map_err(|e| anyhow::anyhow!("Window creation failed: {}", e))?; let mut canvas = window .into_canvas() .build() .map_err(|e| anyhow::anyhow!("Canvas creation failed: {}", e))?; let texture_creator = canvas.texture_creator(); let mut decoder: Option = None; let mut texture: Option = None; let mut video_info = None; let mut asr: Option = None; let mut yolo: Option = None; let mut chunks: Option = None; let mut audio_player: Option = None; let mut is_fullscreen = false; let mut is_dragging = false; let mut searcher: Option = None; let mut show_search = false; let mut search_query = String::new(); let mut search_results: Vec = Vec::new(); let mut show_help = false; let mut last_click_time: u64 = 0; let mut last_click_x: i32 = 0; let mut last_click_y: i32 = 0; if let Some(ref video_path) = config.video { info!("Loading video: {:?}", video_path); let path = Path::new(video_path); let mut dec = FFmpegDecoder::new(path)?; let info = dec.get_info(); video_info = Some(info.clone()); info!( "Video info: {}x{} @ {:.2}fps, {} frames", info.width, info.height, info.fps, info.frame_count ); let tex = texture_creator .create_texture_streaming(PixelFormatEnum::RGB24, info.width, info.height) .map_err(|e| anyhow::anyhow!("Texture creation failed: {}", e))?; texture = Some(tex); dec.start_decoding(0)?; decoder = Some(dec); let player = AudioPlayer::new(); info!("Audio player initialized"); audio_player = Some(player); } if let Some(ref asr_path) = config.asr { info!("Loading ASR: {:?}", asr_path); match AsrLoader::load(asr_path) { Ok(loader) => { info!("ASR loaded: {} segments", loader.segment_count()); asr = Some(loader); let mut search = create_searcher(); if search.load_asr(Path::new(asr_path)).is_ok() { searcher = Some(search); info!("Search index ready"); } } Err(e) => { error!("Failed to load ASR: {}", e); } } } if let Some(ref yolo_path) = config.yolo { info!("Loading YOLO: {:?}", yolo_path); match YoloLoader::load(yolo_path) { Ok(loader) => { info!("YOLO loaded: {} frames", loader.total_frames()); yolo = Some(loader); } Err(e) => { error!("Failed to load YOLO: {}", e); } } } if let Some(ref chunks_path) = config.chunks { info!("Loading Chunks: {:?}", chunks_path); match ChunkLoader::load(Path::new(chunks_path)) { Ok(loader) => { info!("Chunks loaded: {} scenes", loader.scene_count()); chunks = Some(loader); } Err(e) => { error!("Failed to load Chunks: {}", e); } } } let mut player_state = PlayerState::default(); if let Some(ref info) = video_info { player_state.total_frames = info.frame_count; player_state.duration_ms = info.duration_ms; player_state.fps = info.fps; } let mut event_pump = sdl_context .event_pump() .map_err(|e| anyhow::anyhow!("Event pump failed: {}", e))?; let header_height: i32 = 50; let progress_height: i32 = 30; info!("Main loop started - waiting for events..."); let mut running = true; while running { for event in event_pump.poll_iter() { match event { sdl2::event::Event::Quit { .. } => { running = false; } sdl2::event::Event::TextInput { text, .. } if show_search => { search_query.push_str(&text); } sdl2::event::Event::KeyDown { keycode, keymod, .. } => { if let Some(key) = keycode { let shift = keymod.intersects(sdl2::keyboard::Mod::LSHIFTMOD) || keymod.intersects(sdl2::keyboard::Mod::RSHIFTMOD); match key { sdl2::keyboard::Keycode::Escape => { if show_search { show_search = false; search_query.clear(); search_results.clear(); } else if show_help { show_help = false; } else { running = false; } } sdl2::keyboard::Keycode::H => { show_help = !show_help; } sdl2::keyboard::Keycode::Slash => { if searcher.is_some() { show_search = true; search_query.clear(); search_results.clear(); info!("Search mode enabled"); } } sdl2::keyboard::Keycode::Return | sdl2::keyboard::Keycode::KpEnter => { if show_search && !search_query.is_empty() { if let Some(ref search) = searcher { search_results = search.search(&search_query); info!("Found {} results", search_results.len()); } } } sdl2::keyboard::Keycode::Backspace => { if show_search && !search_query.is_empty() { search_query.pop(); if search_query.is_empty() { search_results.clear(); } } } sdl2::keyboard::Keycode::Num1 | sdl2::keyboard::Keycode::Num2 | sdl2::keyboard::Keycode::Num3 | sdl2::keyboard::Keycode::Num4 | sdl2::keyboard::Keycode::Num5 | sdl2::keyboard::Keycode::Num6 => { if show_search && !search_results.is_empty() { let idx = match key { sdl2::keyboard::Keycode::Num1 => 0, sdl2::keyboard::Keycode::Num2 => 1, sdl2::keyboard::Keycode::Num3 => 2, sdl2::keyboard::Keycode::Num4 => 3, sdl2::keyboard::Keycode::Num5 => 4, sdl2::keyboard::Keycode::Num6 => 5, _ => 0, }; if idx < search_results.len() { let result = search_results[idx].clone(); if let Some(ref mut dec) = decoder { if dec.seek(result.time_ms).is_ok() { player_state.current_frame = result.frame; player_state.current_time_ms = result.time_ms; player_state.playback = PlaybackState::Paused; show_search = false; search_query.clear(); search_results.clear(); sync_audio( &mut audio_player, &config.video, result.time_ms, false, ); info!("Jumped to: {}", result.text); } } } } } sdl2::keyboard::Keycode::Space => { let was_playing = player_state.playback == PlaybackState::Playing; player_state.playback = if was_playing { PlaybackState::Paused } else { PlaybackState::Playing }; if let Some(ref mut audio) = audio_player { if player_state.playback == PlaybackState::Playing { if !player_state.muted { audio.resume(); } } else { audio.pause(); } } } sdl2::keyboard::Keycode::S => { player_state.show_subtitle = !player_state.show_subtitle; info!( "Subtitle: {}", if player_state.show_subtitle { "ON" } else { "OFF" } ); } sdl2::keyboard::Keycode::Y => { player_state.show_yolo = !player_state.show_yolo; info!( "YOLO: {}", if player_state.show_yolo { "ON" } else { "OFF" } ); } sdl2::keyboard::Keycode::C => { player_state.show_chunks = !player_state.show_chunks; } sdl2::keyboard::Keycode::M => { player_state.muted = !player_state.muted; if let Some(ref mut audio) = audio_player { if player_state.muted { audio.pause(); } else if player_state.playback == PlaybackState::Playing { audio.resume(); } } info!("Audio: {}", if player_state.muted { "MUTED" } else { "ON" }); } sdl2::keyboard::Keycode::F => { is_fullscreen = !is_fullscreen; } sdl2::keyboard::Keycode::Home => { if let Some(ref mut dec) = decoder { dec.seek(0).ok(); player_state.current_frame = 0; player_state.current_time_ms = 0; sync_audio( &mut audio_player, &config.video, 0, player_state.playback == PlaybackState::Playing, ); } } sdl2::keyboard::Keycode::End => { if let Some(ref mut dec) = decoder { let last_frame = player_state.total_frames.saturating_sub(1); let time_ms = ((last_frame as f64 / player_state.fps) * 1000.0) as u64; dec.seek(time_ms).ok(); player_state.current_frame = last_frame; player_state.current_time_ms = player_state.duration_ms; sync_audio( &mut audio_player, &config.video, time_ms, player_state.playback == PlaybackState::Playing, ); } } sdl2::keyboard::Keycode::Left => { let step = if shift { 60 } else { 1 }; if let Some(ref mut dec) = decoder { let current = player_state.current_frame.saturating_sub(step); let time_ms = ((current as f64 / player_state.fps) * 1000.0) as u64; dec.seek(time_ms).ok(); player_state.current_frame = current; player_state.current_time_ms = time_ms; sync_audio( &mut audio_player, &config.video, time_ms, player_state.playback == PlaybackState::Playing, ); } } sdl2::keyboard::Keycode::Right => { let step = if shift { 60 } else { 1 }; if let Some(ref mut dec) = decoder { let current = player_state.current_frame + step; let time_ms = ((current as f64 / player_state.fps) * 1000.0) as u64; dec.seek(time_ms).ok(); player_state.current_frame = current; player_state.current_time_ms = time_ms; sync_audio( &mut audio_player, &config.video, time_ms, player_state.playback == PlaybackState::Playing, ); } } sdl2::keyboard::Keycode::Up => { if player_state.zoom > 1.0 { player_state.pan_y = (player_state.pan_y - 50.0).max(-500.0); } } sdl2::keyboard::Keycode::Down => { if player_state.zoom > 1.0 { player_state.pan_y = (player_state.pan_y + 50.0).min(500.0); } } sdl2::keyboard::Keycode::Equals | sdl2::keyboard::Keycode::KpPlus => { player_state.zoom = (player_state.zoom * 1.2).min(10.0); } sdl2::keyboard::Keycode::Minus | sdl2::keyboard::Keycode::KpMinus => { player_state.zoom = (player_state.zoom / 1.2).max(0.5); if player_state.zoom == 1.0 { player_state.pan_x = 0.0; player_state.pan_y = 0.0; } } sdl2::keyboard::Keycode::Backquote | sdl2::keyboard::Keycode::R => { player_state.zoom = 1.0; player_state.pan_x = 0.0; player_state.pan_y = 0.0; } sdl2::keyboard::Keycode::Comma | sdl2::keyboard::Keycode::Less => { player_state.speed = (player_state.speed / 1.5).max(0.25); info!("Speed: {:.2}x", player_state.speed); } sdl2::keyboard::Keycode::Period | sdl2::keyboard::Keycode::Greater => { player_state.speed = (player_state.speed * 1.5).min(4.0); info!("Speed: {:.2}x", player_state.speed); } sdl2::keyboard::Keycode::P => { player_state.speed = 1.0; info!("Speed: 1.0x"); } sdl2::keyboard::Keycode::E => { if let Some(ref info) = video_info { let frame_num = player_state.current_frame; let output_dir = std::env::current_dir().unwrap_or_default(); let filename = format!("frame_{:06}.png", frame_num); let output_path = output_dir.join(&filename); if let Err(e) = export_current_frame( &mut texture, info.width, info.height, &output_path, ) { error!("Failed to export frame: {}", e); } else { info!("Exported: {:?}", output_path); } } } _ => {} } } } sdl2::event::Event::MouseWheel { y, .. } => { if y > 0 { player_state.zoom = (player_state.zoom * 1.1).min(10.0); } else if y < 0 { player_state.zoom = (player_state.zoom / 1.1).max(0.5); if player_state.zoom == 1.0 { player_state.pan_x = 0.0; player_state.pan_y = 0.0; } } } sdl2::event::Event::MouseButtonDown { x, y, .. } => { let progress_y = config.height as i32 - progress_height + 5; let bar_height = 20i32; let header_bottom = 60i32; if y >= progress_y && y <= progress_y + bar_height && player_state.total_frames > 0 { let bar_x_start = 10i32; let bar_width = config.width as i32 - 20; if x >= bar_x_start && x <= bar_x_start + bar_width { is_dragging = true; player_state.playback = PlaybackState::Paused; let ratio = (x - bar_x_start) as f64 / bar_width as f64; let target_frame = (player_state.total_frames as f64 * ratio) as u64; let time_ms = ((target_frame as f64 / player_state.fps) * 1000.0) as u64; if let Some(ref mut dec) = decoder { if dec.seek(time_ms).is_ok() { player_state.current_frame = target_frame; player_state.current_time_ms = time_ms; sync_audio(&mut audio_player, &config.video, time_ms, false); info!( "Seeked to frame {} ({:.1}%)", target_frame, ratio * 100.0 ); } } } } else if y > header_bottom && y < progress_y - 5 { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let dx = (x - last_click_x).abs(); let dy = (y - last_click_y).abs(); let is_double_click = dx < 10 && dy < 10 && (now - last_click_time) < 300; if is_double_click { is_fullscreen = !is_fullscreen; info!( "Double-click: fullscreen {}", if is_fullscreen { "ON" } else { "OFF" } ); } else { player_state.playback = if player_state.playback == PlaybackState::Playing { PlaybackState::Paused } else { PlaybackState::Playing }; if let Some(ref mut audio) = audio_player { if player_state.playback == PlaybackState::Playing { if !player_state.muted { audio.resume(); } } else { audio.pause(); } } info!( "Video click: {}", if player_state.playback == PlaybackState::Playing { "PLAY" } else { "PAUSE" } ); } last_click_time = now; last_click_x = x; last_click_y = y; } } sdl2::event::Event::MouseButtonUp { .. } => { if is_dragging { is_dragging = false; info!("Drag seek ended"); } } sdl2::event::Event::MouseMotion { x, y, .. } => { if is_dragging { let progress_y = config.height as i32 - progress_height + 5; let bar_height = 20i32; if y >= progress_y - 30 && y <= progress_y + bar_height + 30 && player_state.total_frames > 0 { let bar_x_start = 10i32; let bar_width = config.width as i32 - 20; let clamped_x = x.clamp(bar_x_start, bar_x_start + bar_width); let ratio = (clamped_x - bar_x_start) as f64 / bar_width as f64; let target_frame = (player_state.total_frames as f64 * ratio) as u64; if let Some(ref mut dec) = decoder { let time_ms = ((target_frame as f64 / player_state.fps) * 1000.0) as u64; if dec.seek(time_ms).is_ok() { player_state.current_frame = target_frame; player_state.current_time_ms = time_ms; } } } } } _ => {} } } canvas.set_draw_color(sdl2::pixels::Color::BLACK); canvas.clear(); let fs_type = if is_fullscreen { sdl2::video::FullscreenType::Desktop } else { sdl2::video::FullscreenType::Off }; canvas.window_mut().set_fullscreen(fs_type).ok(); if player_state.playback == PlaybackState::Playing { if let Some(ref mut dec) = decoder { if let Some(ref mut tex) = texture { match dec.read_frame() { Ok(Some(data)) => { if let Some(ref info) = video_info { player_state.current_frame += 1; player_state.current_time_ms = ((player_state.current_frame as f64 / info.fps) * 1000.0) as u64; tex.update(None, &data, (info.width * 3) as usize) .map_err(|e| anyhow::anyhow!("Texture update failed: {}", e)) .ok(); } } Ok(None) => { info!("Playback ended"); break; } Err(e) => { warn!("Frame read error: {}", e); break; } } } } } let (vid_width, vid_height) = video_info .as_ref() .map(|i| (i.width, i.height)) .unwrap_or((config.width, config.height)); let video_area_height = config.height as i32 - header_height - progress_height; let scale: f64 = if player_state.zoom != 1.0 { player_state.zoom as f64 } else { let scale_x = config.width as f64 / vid_width as f64; let scale_y = video_area_height as f64 / vid_height as f64; scale_x.min(scale_y).min(1.0) }; let scaled_w = (vid_width as f64 * scale) as u32; let scaled_h = (vid_height as f64 * scale) as u32; let offset_x = ((config.width as i32 - scaled_w as i32) / 2) as i32 + player_state.pan_x as i32; let offset_y = ((config.height as i32 - progress_height - scaled_h as i32) / 2) as i32 + player_state.pan_y as i32; if let Some(ref mut tex) = texture { let dst = Rect::new(offset_x, offset_y, scaled_w, scaled_h); canvas.copy(tex, None, Some(dst)).ok(); } if player_state.show_yolo { if let Some(ref mut yolo_loader) = yolo { let detections = yolo_loader.get_detections(player_state.current_frame); for det in detections { let x1 = (det.x1 * scale) as i32 + offset_x; let y1 = (det.y1 * scale) as i32 + offset_y; let w = ((det.x2 - det.x1) * scale) as u32; let h = ((det.y2 - det.y1) * scale) as u32; canvas.set_draw_color(sdl2::pixels::Color::RGB(0, 255, 0)); let _ = canvas.draw_rect(Rect::new(x1, y1, w, h)); if w > 30 && h > 10 { if let Some(ref f) = font { let label = format!("{} {:.0}%", det.class_name, det.confidence * 100.0); if let Ok(surface) = f.render(&label).solid(sdl2::pixels::Color::RGB(0, 255, 0)) { if let Ok(tex_label) = texture_creator.create_texture_from_surface(&surface) { let lw = surface.width().min(w as u32); let label_rect = Rect::new(x1, y1.saturating_sub(18), lw, 16); canvas.copy(&tex_label, None, Some(label_rect)).ok(); } } } } } } } if player_state.show_subtitle { if let Some(ref asr_loader) = asr { if let Some(text) = asr_loader.get_text_at(player_state.current_time_ms as f64) { if let Some(ref f) = font { if let Ok(surface) = f .render(&text) .blended(sdl2::pixels::Color::RGBA(255, 255, 255, 255)) { if let Ok(tex_label) = texture_creator.create_texture_from_surface(&surface) { let query = tex_label.query(); let x = (config.width - query.width) / 2; let y = config.height as i32 - progress_height - query.height as i32 - 10; let rect = Rect::new(x as i32, y as i32, query.width as u32, query.height); canvas.set_draw_color(sdl2::pixels::Color::RGBA(0, 0, 0, 200)); let _ = canvas.fill_rect(Rect::new( rect.x() - 6, rect.y() - 3, rect.width() + 12, rect.height() + 6, )); canvas.copy(&tex_label, None, Some(rect)).ok(); } } } } } } if let Some(ref f) = font { let time_str = format_time(player_state.current_time_ms); let duration_str = format_time(player_state.duration_ms); let play_icon = if player_state.playback == PlaybackState::Playing { "▶" } else { "⏸" }; let progress_pct = if player_state.total_frames > 0 { (player_state.current_frame as f64 / player_state.total_frames as f64 * 100.0) as u32 } else { 0 }; let line1 = format!( "{} {:>5} / {} ({:>3}%) Frame {:>6} / {:>6} {:.2}fps", play_icon, time_str, duration_str, progress_pct, player_state.current_frame, player_state.total_frames, player_state.fps ); let line2 = format!( "[S]ub {} [Y]olo {} [C]hunks [M]ute Spd{:.2}x Zoom{:.1}x [F]ullscreen [H]Help", if player_state.show_subtitle { "ON" } else { "OFF" }, if player_state.show_yolo { "ON" } else { "OFF" }, player_state.speed, player_state.zoom ); if let Ok(surface) = f .render(&line1) .solid(sdl2::pixels::Color::RGB(200, 200, 200)) { if let Ok(tex_label) = texture_creator.create_texture_from_surface(&surface) { let rect = Rect::new(10, 10, surface.width(), surface.height()); canvas.set_draw_color(sdl2::pixels::Color::RGBA(0, 0, 0, 150)); let _ = canvas.fill_rect(Rect::new( 5, 5, surface.width() + 10, surface.height() + 10, )); canvas.copy(&tex_label, None, Some(rect)).ok(); } } if let Ok(surface) = f .render(&line2) .solid(sdl2::pixels::Color::RGB(150, 150, 150)) { if let Ok(tex_label) = texture_creator.create_texture_from_surface(&surface) { let rect = Rect::new(10, 30, surface.width(), surface.height()); canvas.set_draw_color(sdl2::pixels::Color::RGBA(0, 0, 0, 150)); let _ = canvas.fill_rect(Rect::new( 5, 25, surface.width() + 10, surface.height() + 10, )); canvas.copy(&tex_label, None, Some(rect)).ok(); } } } let progress_y = config.height as i32 - progress_height + 5; let progress_bar_height = 8i32; canvas.set_draw_color(sdl2::pixels::Color::RGB(50, 50, 50)); let _ = canvas.fill_rect(Rect::new( 10, progress_y, (config.width - 20) as u32, progress_bar_height as u32, )); if player_state.total_frames > 0 { let progress = player_state.current_frame as f64 / player_state.total_frames as f64; let progress_width = ((config.width - 20) as f64 * progress) as u32; canvas.set_draw_color(sdl2::pixels::Color::RGB(100, 150, 255)); let _ = canvas.fill_rect(Rect::new( 10, progress_y, progress_width, progress_bar_height as u32, )); if player_state.show_chunks { if let Some(ref chunk_loader) = chunks { let bar_start = 10i32; let bar_width = config.width as i32 - 20; let boundaries = chunk_loader.get_scene_boundaries(); for &boundary_frame in &boundaries { let ratio = boundary_frame as f64 / player_state.total_frames as f64; let x_pos = (bar_start + (bar_width as f64 * ratio) as i32) .min(bar_start + bar_width - 1); if x_pos > bar_start && x_pos < bar_start + bar_width { canvas.set_draw_color(sdl2::pixels::Color::RGB(255, 200, 50)); let _ = canvas.fill_rect(Rect::new( x_pos, progress_y - 2, 2, (progress_bar_height + 4) as u32, )); } } if let Some(current_scene) = chunk_loader.get_current_scene(player_state.current_frame) { if let Some(ref font) = font { let scene_text = format!("Scene {}", current_scene.scene_number); if let Ok(surface) = font .render(&scene_text) .solid(sdl2::pixels::Color::RGB(255, 200, 50)) { if let Ok(tex) = texture_creator.create_texture_from_surface(&surface) { let rect = Rect::new( bar_start + bar_width - surface.width() as i32 - 10, progress_y - 25, surface.width(), surface.height(), ); canvas.set_draw_color(sdl2::pixels::Color::RGBA(0, 0, 0, 180)); let _ = canvas.fill_rect(Rect::new( rect.x() - 4, rect.y() - 2, rect.width() + 8, rect.height() + 4, )); canvas.copy(&tex, None, Some(rect)).ok(); } } } } } } } if show_search { if let Some(ref font) = font { let search_bg_h = 250u32; let search_bg_y = (config.height as i32 - search_bg_h as i32) / 2; canvas.set_draw_color(sdl2::pixels::Color::RGBA(20, 20, 30, 230)); let _ = canvas.fill_rect(Rect::new( config.width as i32 / 2 - 300, search_bg_y, 600, search_bg_h, )); canvas.set_draw_color(sdl2::pixels::Color::RGBA(60, 60, 80, 255)); let _ = canvas.draw_rect(Rect::new( config.width as i32 / 2 - 300, search_bg_y, 600, search_bg_h, )); let input_text = if search_query.is_empty() { "Type to search... (Enter to search, Esc to close)".to_string() } else { format!("Search: {}", search_query) }; if let Ok(surface) = font .render(&input_text) .solid(sdl2::pixels::Color::RGB(200, 200, 200)) { if let Ok(tex) = texture_creator.create_texture_from_surface(&surface) { let rect = Rect::new( config.width as i32 / 2 - 290, search_bg_y + 10, surface.width(), surface.height(), ); canvas.copy(&tex, None, Some(rect)).ok(); } } if !search_results.is_empty() { let mut y_offset = search_bg_y + 40; for (i, result) in search_results.iter().take(6).enumerate() { let result_text = format!("[{}] {}", i + 1, result.text); let color = if i == 0 { sdl2::pixels::Color::RGB(100, 200, 255) } else { sdl2::pixels::Color::RGB(180, 180, 180) }; if let Ok(surface) = font.render(&result_text).solid(color) { if let Ok(tex) = texture_creator.create_texture_from_surface(&surface) { let rect = Rect::new( config.width as i32 / 2 - 290, y_offset, surface.width().min(580), surface.height(), ); canvas.copy(&tex, None, Some(rect)).ok(); } } y_offset += 25; } } else if !search_query.is_empty() { let hint = "Press Enter to search..."; if let Ok(surface) = font .render(hint) .solid(sdl2::pixels::Color::RGB(150, 150, 150)) { if let Ok(tex) = texture_creator.create_texture_from_surface(&surface) { let rect = Rect::new( config.width as i32 / 2 - 290, search_bg_y + 40, surface.width(), surface.height(), ); canvas.copy(&tex, None, Some(rect)).ok(); } } } } } if show_help { if let Some(ref font) = font { let help_lines = vec![ "MoMentry Playground - Keyboard Shortcuts", "", "=== Playback ===", "Space Play / Pause", "Home Jump to start", "End Jump to end", "Left/Right Frame step (Shift for 1 sec)", "<,> Speed control", "P Reset speed", "", "=== Display ===", "S Toggle subtitles", "Y Toggle YOLO boxes", "C Toggle chunk markers", "F Toggle fullscreen", "M Mute / Unmute", "+/- Zoom in / out", "0 or ` Reset zoom", "Arrow keys Pan when zoomed", "E Export current frame", "", "=== Search ===", "/ Open search panel", "Enter Search", "1-6 Jump to result", "Esc Close panel", "", "=== Mouse ===", "Click video Play / Pause", "Double-click video Fullscreen", "Click bar Seek", "Drag bar Scrub", "Scroll Zoom", "", "=== Other ===", "H Toggle this help", "Esc Exit", ]; let line_height = 18u32; let help_h = (help_lines.len() as u32) * line_height + 40; let help_w = 380u32; let help_x = (config.width as i32 - help_w as i32) / 2; let help_y = (config.height as i32 - help_h as i32) / 2; canvas.set_draw_color(sdl2::pixels::Color::RGBA(15, 15, 25, 245)); let _ = canvas.fill_rect(Rect::new(help_x, help_y, help_w, help_h)); canvas.set_draw_color(sdl2::pixels::Color::RGBA(80, 80, 120, 255)); let _ = canvas.draw_rect(Rect::new(help_x, help_y, help_w, help_h)); let mut y_offset = help_y + 15; for (i, line) in help_lines.iter().enumerate() { let color = if line.ends_with("===") || line.is_empty() { sdl2::pixels::Color::RGB(120, 180, 255) } else if line.starts_with("===") { sdl2::pixels::Color::RGB(100, 100, 120) } else { sdl2::pixels::Color::RGB(200, 200, 200) }; if let Ok(surface) = font.render(line).solid(color) { if let Ok(tex) = texture_creator.create_texture_from_surface(&surface) { let rect = Rect::new(help_x + 20, y_offset, surface.width(), surface.height()); canvas.copy(&tex, None, Some(rect)).ok(); } } y_offset += line_height as i32; } } } canvas.present(); std::thread::sleep(std::time::Duration::from_millis(16)); } info!("Application closed"); Ok(()) } fn sync_audio( audio_player: &mut Option, video_path: &Option, time_ms: u64, is_playing: bool, ) { if let Some(ref mut audio) = audio_player { if let Some(ref path) = video_path { audio.play(path.as_path(), time_ms); if !is_playing { audio.pause(); } } } } fn export_current_frame( texture: &mut Option, width: u32, height: u32, output_path: &std::path::Path, ) -> anyhow::Result<()> { if let Some(ref mut tex) = texture { let mut pixels = vec![0u8; (width * height * 3) as usize]; tex.with_lock(None, |buffer, _pitch| { pixels.copy_from_slice(buffer); }); let surf = sdl2::surface::Surface::from_data( &mut pixels, width, height, (width * 3) as u32, sdl2::pixels::PixelFormatEnum::RGB24, ) .map_err(|e| anyhow::anyhow!("Failed to create surface: {}", e))?; surf.save_bmp(output_path) .map_err(|e| anyhow::anyhow!("Failed to save BMP: {}", e))?; let png_path = output_path.with_extension("png"); let cmd = std::process::Command::new("ffmpeg") .args(["-y", "-i"]) .arg(output_path.to_str().unwrap_or("")) .arg(png_path.to_str().unwrap_or("")) .output() .map_err(|e| anyhow::anyhow!("Failed to convert to PNG: {}", e))?; if cmd.status.success() { std::fs::remove_file(output_path).ok(); } Ok(()) } else { anyhow::bail!("No texture available") } } fn format_time(ms: u64) -> String { let total_secs = ms / 1000; let hours = total_secs / 3600; let minutes = (total_secs % 3600) / 60; let seconds = total_secs % 60; let millis = ms % 1000; if hours > 0 { format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis) } else { format!("{:02}:{:02}.{:03}", minutes, seconds, millis) } }