diff --git a/src/config.rs b/src/config.rs index c443933..f0dab69 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ //! Configuration module -//! +//! //! Command line arguments and runtime configuration use anyhow::Result; @@ -19,16 +19,30 @@ pub struct Config { #[arg(short = 'y', long = "yolo", help = "YOLO JSON file path")] pub yolo: Option, - #[arg(short = 'w', long = "width", default_value = "1280", help = "Window width")] + #[arg( + short = 'w', + long = "width", + default_value = "1280", + help = "Window width" + )] pub width: u32, - #[arg(short = 'h', long = "height", default_value = "720", help = "Window height")] + #[arg( + short = 'h', + long = "height", + default_value = "720", + help = "Window height" + )] pub height: u32, #[arg(long = "fullscreen", help = "Start in fullscreen mode")] pub fullscreen: bool, - #[arg(long = "locale", default_value = "en", help = "UI language (en, zh-TW, etc.)")] + #[arg( + long = "locale", + default_value = "en", + help = "UI language (en, zh-TW, etc.)" + )] pub locale: String, } diff --git a/src/main.rs b/src/main.rs index cc966c6..e1fb4e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,10 @@ //! MoMentry Playground - Main entry point -//! +//! //! Unified media player with ASR/YOLO/Chunks overlay support use anyhow::Result; -use clap::Parser; use log::{error, info}; +use sdl2::pixels::PixelFormatEnum; use std::path::Path; mod config; @@ -14,42 +14,67 @@ mod web; use config::Config; use overlay::{AsrLoader, YoloLoader}; -use player::{Video, Renderer, PlaybackState}; -use player::state::PlayerState; +use player::ffmpeg::FFmpegDecoder; +use player::state::{PlaybackState, PlayerState}; fn main() -> Result<()> { - env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("info") - ).init(); - - let config = Config::load(); + 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 mut video = Video::new(); - let mut renderer = Renderer::new("MoMentry Playground", config.width, config.height)?; - + 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 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 asr: Option = None; let mut yolo: Option = None; - + if let Some(ref video_path) = config.video { info!("Loading video: {:?}", video_path); - let info_data = video.open(video_path)?; - info!("Video info: {}x{} @ {:.2}fps, {} frames", - info_data.width, info_data.height, info_data.fps, info_data.total_frames); - - renderer.create_texture(info_data.width, info_data.height)?; + let path = Path::new(video_path); + let mut dec = FFmpegDecoder::new(path)?; + let info = dec.get_info(); + 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); } - + if let Some(ref asr_path) = config.asr { info!("Loading ASR: {:?}", asr_path); match AsrLoader::load(asr_path) { @@ -62,7 +87,7 @@ fn run(config: &Config) -> Result<()> { } } } - + if let Some(ref yolo_path) = config.yolo { info!("Loading YOLO: {:?}", yolo_path); match YoloLoader::load(yolo_path) { @@ -75,82 +100,133 @@ fn run(config: &Config) -> Result<()> { } } } - - if config.fullscreen { - renderer.set_fullscreen(true)?; - } - + let mut player_state = PlayerState::default(); - if let Some(info) = video.get_info() { - player_state.total_frames = info.total_frames; + if let Some(ref dec) = decoder { + let info = dec.get_info(); + player_state.total_frames = info.frame_count; player_state.duration_ms = info.duration_ms; player_state.fps = info.fps; } - - info!("Main loop started - waiting for events..."); - - if let Some(ref video_path) = config.video { - video.play()?; - player_state.playback = PlaybackState::Playing; - run_playback_loop(&mut video, &mut renderer, &mut player_state, &mut asr, &mut yolo)?; - } - - loop { - std::thread::sleep(std::time::Duration::from_millis(100)); - } -} -fn run_playback_loop( - video: &mut Video, - renderer: &mut Renderer, - state: &mut PlayerState, - asr: &mut Option, - yolo: &mut Option, -) -> Result<()> { - let frame_duration = std::time::Duration::from_millis(16); - - loop { - let start = std::time::Instant::now(); - - match video.read_frame() { - Ok(Some(frame)) => { - state.current_frame = frame.frame_number; - state.current_time_ms = frame.timestamp_ms; - - renderer.update_texture(&frame.data)?; - - if state.show_yolo { - if let Some(ref mut yolo_loader) = yolo { - let detections = yolo_loader.get_detections(frame.frame_number); - for det in detections { - renderer.draw_bbox( - det.x1 as i32, - det.y1 as i32, - (det.x2 - det.x1) as u32, - (det.y2 - det.y1) as u32, - &det.class_name, - ); + let mut event_pump = sdl_context + .event_pump() + .map_err(|e| anyhow::anyhow!("Event pump failed: {}", e))?; + + 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::KeyDown { keycode, .. } => { + if let Some(key) = keycode { + match key { + sdl2::keyboard::Keycode::Escape => running = false, + sdl2::keyboard::Keycode::Space => { + player_state.playback = + if player_state.playback == PlaybackState::Playing { + PlaybackState::Paused + } else { + PlaybackState::Playing + }; + } + sdl2::keyboard::Keycode::S => { + player_state.show_subtitle = !player_state.show_subtitle; + } + sdl2::keyboard::Keycode::Y => { + player_state.show_yolo = !player_state.show_yolo; + } + sdl2::keyboard::Keycode::C => { + player_state.show_chunks = !player_state.show_chunks; + } + sdl2::keyboard::Keycode::M => { + player_state.muted = !player_state.muted; + } + sdl2::keyboard::Keycode::Left => { + if let Some(ref mut dec) = decoder { + let current = player_state.current_frame.saturating_sub(1); + dec.seek( + ((current as f64 / player_state.fps) * 1000.0) as u64, + )?; + player_state.current_frame = current; + } + } + sdl2::keyboard::Keycode::Right => { + if let Some(ref mut dec) = decoder { + let current = player_state.current_frame + 1; + dec.seek( + ((current as f64 / player_state.fps) * 1000.0) as u64, + )?; + player_state.current_frame = current; + } + } + _ => {} } } } - - renderer.present(); - } - Ok(None) => { - info!("Playback ended"); - break; - } - Err(e) => { - error!("Frame read error: {}", e); - break; + _ => {} } } - - let elapsed = start.elapsed(); - if elapsed < frame_duration { - std::thread::sleep(frame_duration - elapsed); + + canvas.set_draw_color(sdl2::pixels::Color::BLACK); + canvas.clear(); + + 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)) => { + let info = dec.get_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))?; + + canvas + .copy(tex, None, None) + .map_err(|e| anyhow::anyhow!("Copy failed: {}", e))?; + + 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 as i32; + let y1 = det.y1 as i32; + let w = (det.x2 - det.x1) as u32; + let h = (det.y2 - det.y1) as u32; + + canvas.set_draw_color(sdl2::pixels::Color::RGB(0, 255, 0)); + let _ = + canvas.draw_rect(sdl2::rect::Rect::new(x1, y1, w, h)); + } + } + } + } + Ok(None) => { + info!("Playback ended"); + break; + } + Err(e) => { + error!("Frame read error: {}", e); + break; + } + } + } + } } + + canvas.present(); + + std::thread::sleep(std::time::Duration::from_millis(16)); } - + + info!("Application closed"); Ok(()) } diff --git a/src/overlay/asr.rs b/src/overlay/asr.rs index 59fe268..27cc8bf 100644 --- a/src/overlay/asr.rs +++ b/src/overlay/asr.rs @@ -27,18 +27,19 @@ impl AsrLoader { pub fn load(path: &Path) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read ASR file: {:?}", path))?; - - let data: AsrData = serde_json::from_str(&content) - .with_context(|| "Failed to parse ASR JSON")?; - + + let data: AsrData = + serde_json::from_str(&content).with_context(|| "Failed to parse ASR JSON")?; + Ok(Self { data }) } pub fn get_segment_at(&self, time_ms: f64) -> Option<&AsrSegment> { let time_sec = time_ms / 1000.0; - self.data.segments.iter().find(|seg| { - time_sec >= seg.start && time_sec < seg.end - }) + self.data + .segments + .iter() + .find(|seg| time_sec >= seg.start && time_sec < seg.end) } pub fn get_text_at(&self, time_ms: f64) -> Option { diff --git a/src/overlay/mod.rs b/src/overlay/mod.rs index e7ecbb5..c6563a6 100644 --- a/src/overlay/mod.rs +++ b/src/overlay/mod.rs @@ -1,5 +1,5 @@ //! Overlay module -//! +//! //! ASR subtitle and YOLO bbox overlay management pub mod asr; diff --git a/src/overlay/yolo.rs b/src/overlay/yolo.rs index 0a6c9a7..1186a20 100644 --- a/src/overlay/yolo.rs +++ b/src/overlay/yolo.rs @@ -5,7 +5,8 @@ use lru::LruCache; use serde::Deserialize; use std::collections::HashMap; use std::fs::File; -use std::io::{BufRead, BufReader}; +use std::io::BufReader; +use std::num::NonZeroUsize; use std::path::Path; #[derive(Debug, Clone, Deserialize)] @@ -52,53 +53,39 @@ pub struct YoloData { pub struct YoloLoader { data: YoloData, cache: LruCache>, - frame_index: HashMap, - file_path: String, } impl YoloLoader { const CACHE_SIZE: usize = 60; pub fn load(path: &Path) -> Result { - let file_path = path.to_string_lossy().to_string(); - - let file = File::open(path) - .with_context(|| format!("Failed to open YOLO file: {:?}", path))?; + let file = + File::open(path).with_context(|| format!("Failed to open YOLO file: {:?}", path))?; let reader = BufReader::new(file); - - let data: YoloData = serde_json::from_reader(reader) - .with_context(|| "Failed to parse YOLO JSON")?; - - let mut frame_index = HashMap::new(); - for (i, (key, frame)) in data.frames.iter().enumerate() { - if let Ok(frame_num) = key.parse::() { - frame_index.insert(frame_num, i); - } - } - - Ok(Self { - data, - cache: LruCache::new(Self::CACHE_SIZE), - frame_index, - file_path, - }) + + let data: YoloData = + serde_json::from_reader(reader).with_context(|| "Failed to parse YOLO JSON")?; + + let cache = LruCache::new(NonZeroUsize::new(Self::CACHE_SIZE).unwrap()); + + Ok(Self { data, cache }) } - pub fn get_detections(&mut self, frame: u64) -> Vec<&Detection> { + pub fn get_detections(&mut self, frame: u64) -> Vec { if let Some(dets) = self.cache.get(&frame) { - return dets.iter().collect(); + return dets.clone(); } - + if let Some(frame_data) = self.data.frames.get(&frame.to_string()) { - let dets: Vec = frame_data.detections.clone(); + let dets = frame_data.detections.clone(); self.cache.put(frame, dets.clone()); - dets.iter().collect() + dets } else { Vec::new() } } - pub fn get_detections_at_time(&mut self, time_ms: u64) -> Vec<&Detection> { + pub fn get_detections_at_time(&mut self, time_ms: u64) -> Vec { let fps = self.data.metadata.fps; let frame = ((time_ms as f64 / 1000.0) * fps) as u64; self.get_detections(frame) diff --git a/src/player/ffmpeg.rs b/src/player/ffmpeg.rs index 15ef7f4..6521ee1 100644 --- a/src/player/ffmpeg.rs +++ b/src/player/ffmpeg.rs @@ -1,10 +1,9 @@ -//! FFmpeg 封裝 +//! FFmpeg wrapper use anyhow::{Context, Result}; +use std::io::{BufReader, Read}; use std::path::Path; -use std::process::{Command, Stdio, Child, ChildStdout}; -use std::io::{Read, BufReader}; -use std::sync::{Arc, Mutex}; +use std::process::{Child, ChildStdout, Command, Stdio}; #[derive(Debug, Clone)] pub struct VideoInfo { @@ -27,7 +26,7 @@ impl FFmpegDecoder { pub fn new(path: &Path) -> Result { let path_str = path.to_string_lossy().to_string(); let info = Self::probe(path)?; - + Ok(Self { path: path_str, process: None, @@ -39,8 +38,10 @@ impl FFmpegDecoder { fn probe(path: &Path) -> Result { let output = Command::new("ffprobe") .args([ - "-v", "quiet", - "-print_format", "json", + "-v", + "quiet", + "-print_format", + "json", "-show_format", "-show_streams", path.to_str().unwrap_or(""), @@ -48,24 +49,25 @@ impl FFmpegDecoder { .output() .context("Failed to run ffprobe")?; - let json: serde_json::Value = serde_json::from_slice(&output.stdout) - .context("Failed to parse ffprobe output")?; + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).context("Failed to parse ffprobe output")?; let video_stream = json["streams"] .as_array() - .and_then(|streams| { - streams.iter().find(|s| s["codec_type"] == "video") - }) + .and_then(|streams| streams.iter().find(|s| s["codec_type"] == "video")) .context("No video stream found")?; let width = video_stream["width"].as_u64().unwrap_or(0) as u32; let height = video_stream["height"].as_u64().unwrap_or(0) as u32; - + let fps_str = video_stream["r_frame_rate"].as_str().unwrap_or("30/1"); let (num, den) = { let parts: Vec<&str> = fps_str.split('/').collect(); if parts.len() == 2 { - (parts[0].parse::().unwrap_or(30.0), parts[1].parse::().unwrap_or(1.0)) + ( + parts[0].parse::().unwrap_or(30.0), + parts[1].parse::().unwrap_or(1.0), + ) } else { (fps_str.parse::().unwrap_or(30.0), 1.0) } @@ -75,9 +77,12 @@ impl FFmpegDecoder { let duration_str = json["format"]["duration"].as_str().unwrap_or("0"); let duration_sec: f64 = duration_str.parse().unwrap_or(0.0); let duration_ms = (duration_sec * 1000.0) as u64; - + let frame_count = (duration_sec * fps) as u64; - let codec = video_stream["codec_name"].as_str().unwrap_or("unknown").to_string(); + let codec = video_stream["codec_name"] + .as_str() + .unwrap_or("unknown") + .to_string(); Ok(VideoInfo { width, @@ -94,16 +99,20 @@ impl FFmpegDecoder { } pub fn start_decoding(&mut self, start_ms: u64) -> Result<()> { - self.stop()?; - + self.stop(); + let start_sec = start_ms as f64 / 1000.0; - + let mut child = Command::new("ffmpeg") .args([ - "-ss", &format!("{}", start_sec), - "-i", &self.path, - "-f", "rawvideo", - "-pix_fmt", "rgb24", + "-ss", + &format!("{}", start_sec), + "-i", + &self.path, + "-f", + "rawvideo", + "-pix_fmt", + "rgb24", "-", ]) .stdout(Stdio::piped()) @@ -111,12 +120,11 @@ impl FFmpegDecoder { .spawn() .context("Failed to start ffmpeg")?; - let stdout = child.stdout.take() - .context("Failed to capture stdout")?; - + let stdout = child.stdout.take().context("Failed to capture stdout")?; + self.process = Some(child); self.stdout = Some(BufReader::new(stdout)); - + Ok(()) } @@ -135,7 +143,7 @@ impl FFmpegDecoder { pub fn read_frame(&mut self) -> Result>> { let frame_size = (self.info.width * self.info.height * 3) as usize; let mut buffer = vec![0u8; frame_size]; - + if let Some(ref mut reader) = self.stdout { match reader.read_exact(&mut buffer) { Ok(_) => Ok(Some(buffer)), diff --git a/src/player/mod.rs b/src/player/mod.rs index 13c5537..07fd7f5 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -1,11 +1,11 @@ //! Player module -//! +//! //! Video playback, frame decoding, and rendering -pub mod video; pub mod ffmpeg; pub mod renderer; pub mod state; +pub mod video; -pub use video::Video; -pub use state::{PlayerState, PlaybackState}; +pub use state::{PlaybackState, PlayerState}; +pub use video::VideoPlayer; diff --git a/src/player/renderer.rs b/src/player/renderer.rs index c53e400..e8b48a5 100644 --- a/src/player/renderer.rs +++ b/src/player/renderer.rs @@ -3,58 +3,42 @@ use anyhow::Result; use sdl2::pixels::PixelFormatEnum; use sdl2::rect::Rect; -use std::sync::{Arc, Mutex}; pub struct Renderer { sdl: sdl2::Sdl, - video_subsystem: sdl2::VideoSubsystem, - window: sdl2::video::Window, canvas: sdl2::render::Canvas, - texture_creator: sdl2::render::TextureCreator, - texture: Option, width: u32, height: u32, } impl Renderer { pub fn new(title: &str, width: u32, height: u32) -> Result { - let sdl = sdl2::init()?; - let video_subsystem = sdl.video()?; - + let sdl = sdl2::init().map_err(|e| anyhow::anyhow!("SDL init failed: {}", e))?; + let video_subsystem = sdl + .video() + .map_err(|e| anyhow::anyhow!("Video subsystem failed: {}", e))?; + let window = video_subsystem .window(title, width, height) .position_centered() - .build()?; - - let canvas = window.into_canvas().build()?; - let texture_creator = canvas.texture_creator(); - + .build() + .map_err(|e| anyhow::anyhow!("Window creation failed: {}", e))?; + + let canvas = window + .into_canvas() + .build() + .map_err(|e| anyhow::anyhow!("Canvas creation failed: {}", e))?; + Ok(Self { sdl, - video_subsystem, - window, canvas, - texture_creator, - texture: None, width, height, }) } - pub fn create_texture(&mut self, width: u32, height: u32) -> Result<()> { - self.texture = Some( - self.texture_creator - .create_texture_streaming(PixelFormatEnum::RGB24, width, height)? - ); - self.width = width; - self.height = height; - Ok(()) - } - - pub fn update_texture(&mut self, data: &[u8]) -> Result<()> { - if let Some(ref mut texture) = self.texture { - texture.update(None, data, self.width as usize * 3)?; - } + pub fn update_frame(&mut self, texture: &sdl2::render::Texture) -> Result<()> { + let _ = self.canvas.copy(texture, None, None); Ok(()) } @@ -63,53 +47,43 @@ impl Renderer { self.canvas.clear(); } - pub fn draw_bbox(&mut self, x: i32, y: i32, w: u32, h: u32, label: &str) { - // Draw rectangle border - self.canvas.set_draw_color(sdl2::pixels::Color::RGB(0, 255, 0)); + pub fn draw_bbox(&mut self, x: i32, y: i32, w: u32, h: u32, _label: &str) { + self.canvas + .set_draw_color(sdl2::pixels::Color::RGB(0, 255, 0)); let _ = self.canvas.draw_rect(Rect::new(x, y, w, h)); - - // Draw label background let label_rect = Rect::new(x, y - 20, 100, 20); - self.canvas.set_draw_color(sdl2::pixels::Color::RGBA(0, 0, 0, 180)); + self.canvas + .set_draw_color(sdl2::pixels::Color::RGBA(0, 0, 0, 180)); let _ = self.canvas.fill_rect(label_rect); } pub fn present(&mut self) { - // Draw texture if available - if let Some(ref texture) = self.texture { - self.canvas.copy(texture, None, None).ok(); - } self.canvas.present(); } - pub fn set_fullscreen(&mut self, fullscreen: bool) -> Result<()> { - if fullscreen { - self.window.set_fullscreen(sdl2::video::FullscreenType::Desktop)?; - } else { - self.window.set_fullscreen(sdl2::video::FullscreenType::Off)?; - } - Ok(()) - } - - pub fn resize(&mut self, width: u32, height: u32) -> Result<()> { - self.window.set_size(width, height)?; - Ok(()) - } - pub fn poll_events(&mut self) -> Vec { let mut events = Vec::new(); - let pump = self.sdl.event_pump(); - if let Ok(pump) = pump { + if let Ok(mut pump) = self.sdl.event_pump() { for event in pump.poll_iter() { events.push(event); } } events } + + pub fn canvas(&mut self) -> &mut sdl2::render::Canvas { + &mut self.canvas + } + + pub fn width(&self) -> u32 { + self.width + } + + pub fn height(&self) -> u32 { + self.height + } } impl Drop for Renderer { - fn drop(&mut self) { - // Cleanup handled automatically - } + fn drop(&mut self) {} } diff --git a/src/player/video.rs b/src/player/video.rs index 0e111d0..32f5973 100644 --- a/src/player/video.rs +++ b/src/player/video.rs @@ -1,9 +1,9 @@ //! 視頻播放核心實現 use anyhow::{Context, Result}; +use std::collections::VecDeque; use std::path::Path; use std::sync::{Arc, Mutex}; -use std::collections::VecDeque; use crate::player::ffmpeg::{FFmpegDecoder, VideoInfo}; @@ -44,12 +44,12 @@ impl VideoPlayer { pub fn open(&mut self, path: &str) -> Result<()> { let decoder = FFmpegDecoder::new(Path::new(path)) .with_context(|| format!("Failed to open video: {}", path))?; - + self.info = Some(decoder.get_info()); self.decoder = Some(decoder); self.state = PlayState::Stopped; self.current_frame = 0; - + Ok(()) } @@ -73,8 +73,8 @@ impl VideoPlayer { } pub fn seek_frame(&mut self, frame: u64) -> Result<()> { - if let Some(ref decoder) = self.decoder { - if let Some(info) = &self.info { + if let Some(ref mut decoder) = self.decoder { + if let Some(ref info) = self.info { let timestamp_ms = (frame * 1000) / info.fps as u64; decoder.seek(timestamp_ms)?; self.current_frame = frame; @@ -84,9 +84,9 @@ impl VideoPlayer { } pub fn seek_time(&mut self, ms: u64) -> Result<()> { - if let Some(ref decoder) = self.decoder { + if let Some(ref mut decoder) = self.decoder { decoder.seek(ms)?; - if let Some(info) = &self.info { + if let Some(ref info) = self.info { self.current_frame = (ms * info.fps as u64) / 1000; } } diff --git a/src/web/bridge.rs b/src/web/bridge.rs index 24a7667..500a402 100644 --- a/src/web/bridge.rs +++ b/src/web/bridge.rs @@ -13,25 +13,25 @@ pub enum BridgeMessage { SeekTime(f64), StepForward, StepBackward, - + // UI state SetVolume(f32), SetMuted(bool), SetSpeed(f32), - + // Overlay toggles ToggleSubtitle, ToggleYolo, ToggleChunks, - + // Zoom/Pan Zoom(f32), Pan(f32, f32), ResetView, - + // File operations OpenFile(String), - + // Queries GetState, GetSubtitle(f64),