diff --git a/pixelpwnr.json b/pixelpwnr.json new file mode 100644 index 0000000..4028b5a --- /dev/null +++ b/pixelpwnr.json @@ -0,0 +1,30 @@ +{ + "host": "127.0.0.1:1337", + "threads": null, + "binary": true, + "flush": false, + "entries": [ + { + "name": "Bad Apple", + "source": "/home/ditin2/Projects/pixelpwnr/video/badapple/*.png", + "fps": 30, + "width": 150, + "height": 150, + "x": 0, + "y": 0, + "threads": 8, + "enabled": true + }, + { + "name": "Bad Apple but bigger", + "source": "/home/ditin2/Projects/pixelpwnr/video/badapple/*.png", + "fps": 30, + "width": 256, + "height": 256, + "x": 200, + "y": 200, + "threads": 8, + "enabled": true + } + ] +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6cab5c7 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectConfig { + pub host: String, + pub threads: Option, + pub binary: bool, + pub flush: bool, + pub entries: Vec, +} + +impl Default for ProjectConfig { + fn default() -> Self { + Self { + host: "127.0.0.1:1234".to_string(), + threads: None, + binary: false, + flush: true, + entries: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VideoEntry { + pub name: String, + pub source: String, + pub fps: u32, + pub width: u16, + pub height: u16, + pub x: u16, + pub y: u16, + pub threads: Option, + pub enabled: bool, +} + +impl Default for VideoEntry { + fn default() -> Self { + Self { + name: "Video".to_string(), + source: String::new(), + fps: 30, + width: 64, + height: 64, + x: 0, + y: 0, + threads: None, + enabled: true, + } + } +} diff --git a/src/gui.rs b/src/gui.rs new file mode 100644 index 0000000..36268b8 --- /dev/null +++ b/src/gui.rs @@ -0,0 +1,461 @@ +mod config; + +use config::{ProjectConfig, VideoEntry}; +use eframe::{egui, App, Frame, NativeOptions}; +use glob::glob; +use serde::Deserialize; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; + +struct GuiApp { + config_path: String, + status_path: String, + config: ProjectConfig, + child: Option, + status: String, + frame_stats: Vec>, + frame_totals: Vec>, + last_sources: Vec, +} + +impl GuiApp { + fn new() -> Self { + Self { + config_path: "pixelpwnr.json".to_string(), + status_path: "pixelpwnr.json.status.json".to_string(), + config: ProjectConfig::default(), + child: None, + status: String::new(), + frame_stats: Vec::new(), + frame_totals: Vec::new(), + last_sources: Vec::new(), + } + } + + fn is_running(&self) -> bool { + self.child.is_some() + } + + fn refresh_children(&mut self) { + if let Some(process) = &mut self.child { + match process.try_wait() { + Ok(Some(_)) => { + self.child = None; + } + Ok(None) => {} + Err(err) => { + self.status = format!("Process status error: {err}"); + } + } + } + } + + fn load_config(&mut self) { + match fs::read_to_string(&self.config_path) { + Ok(data) => match serde_json::from_str::(&data) { + Ok(config) => { + self.config = config; + self.status = "Config loaded.".to_string(); + } + Err(err) => { + self.status = format!("Failed to parse config: {err}"); + } + }, + Err(err) => { + self.status = format!("Failed to read config: {err}"); + } + } + } + + fn save_config(&mut self) { + match serde_json::to_string_pretty(&self.config) { + Ok(data) => match fs::write(&self.config_path, data) { + Ok(()) => self.status = "Config saved.".to_string(), + Err(err) => self.status = format!("Failed to write config: {err}"), + }, + Err(err) => { + self.status = format!("Failed to serialize config: {err}"); + } + } + } + + fn stop_all(&mut self) { + if let Some(mut process) = self.child.take() { + let mut interrupted = send_interrupt(&process); + if interrupted { + let start = std::time::Instant::now(); + loop { + match process.try_wait() { + Ok(Some(_)) => { + interrupted = false; + break; + } + Ok(None) => { + if start.elapsed().as_millis() > 1500 { + break; + } + } + Err(_) => break, + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + + if interrupted { + let _ = process.kill(); + } + let _ = process.wait(); + } + self.frame_stats.clear(); + self.frame_totals.clear(); + self.last_sources.clear(); + let _ = fs::remove_file(&self.status_path); + self.status = "Stopped all clients.".to_string(); + } + + fn run_all(&mut self) { + if self.config.host.trim().is_empty() { + self.status = "Host is required.".to_string(); + return; + } + + if self.config.entries.iter().all(|entry| !entry.enabled) { + self.status = "Enable at least one video entry.".to_string(); + return; + } + + self.stop_all(); + let exe = match resolve_pixelpwnr_binary() { + Some(path) => path, + None => { + self.status = "pixelpwnr binary not found. Build it first.".to_string(); + return; + } + }; + let mut command = Command::new(&exe); + command.arg("--config").arg(&self.config_path); + command.arg("--status").arg(&self.status_path); + if !self.config.host.trim().is_empty() { + command.arg(&self.config.host); + } + + match command.spawn() { + Ok(child) => { + self.child = Some(child); + self.status = "Started client.".to_string(); + } + Err(err) => { + self.status = format!("Failed to start client: {err}"); + } + } + } +} + +#[derive(Deserialize)] +struct StatusFile { + entries: Vec, +} + +#[derive(Deserialize)] +struct StatusEntry { + fps: f64, + current: usize, + total: usize, +} + +impl App for GuiApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut Frame) { + self.refresh_children(); + self.refresh_status(); + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + + egui::TopBottomPanel::top("top").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.heading("pixelpwnr"); + ui.separator(); + if ui.button("Run").clicked() { + self.run_all(); + } + if ui.button("Stop").clicked() { + self.stop_all(); + } + if self.is_running() { + ui.label("running"); + } else { + ui.label("stopped"); + } + }); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label("Config:"); + if ui.text_edit_singleline(&mut self.config_path).changed() { + self.status_path = format!("{}.status.json", self.config_path); + } + if ui.button("Load").clicked() { + self.load_config(); + } + if ui.button("Save").clicked() { + self.save_config(); + } + }); + + if !self.status.is_empty() { + ui.label(&self.status); + } + + ui.separator(); + ui.heading("Global"); + egui::Grid::new("global_settings") + .num_columns(2) + .striped(true) + .show(ui, |ui| { + ui.label("Host"); + ui.text_edit_singleline(&mut self.config.host); + ui.end_row(); + + ui.label("Threads"); + let mut threads_str = self + .config + .threads + .map(|threads| threads.to_string()) + .unwrap_or_default(); + if ui.text_edit_singleline(&mut threads_str).changed() { + self.config.threads = threads_str.trim().parse().ok(); + } + ui.end_row(); + + ui.label("Binary"); + ui.checkbox(&mut self.config.binary, "Use PB protocol"); + ui.end_row(); + + ui.label("Flush"); + ui.checkbox(&mut self.config.flush, "Flush per pixel"); + ui.end_row(); + }); + + ui.separator(); + ui.heading("Videos"); + + let mut remove_index = None; + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + let sources: Vec = self + .config + .entries + .iter() + .map(|entry| entry.source.clone()) + .collect(); + self.refresh_frame_totals(&sources); + for (index, entry) in self.config.entries.iter_mut().enumerate() { + ui.group(|ui| { + ui.horizontal(|ui| { + ui.checkbox(&mut entry.enabled, "Enabled"); + ui.label(&entry.name); + if let Some(stat) = + self.frame_stats.get(index).and_then(|stat| stat.clone()) + { + ui.label(format!( + "{:.1} fps ({}/{})", + stat.fps, stat.current, stat.total + )); + } else if let Some(total) = + self.frame_totals.get(index).and_then(|total| *total) + { + ui.label(format!("-- fps (--/{total})")); + } else { + ui.label("-- fps (--/--)"); + } + if ui.button("Remove").clicked() { + remove_index = Some(index); + } + }); + + egui::Grid::new(format!("entry_{index}")) + .num_columns(2) + .striped(true) + .show(ui, |ui| { + ui.label("Name"); + ui.text_edit_singleline(&mut entry.name); + ui.end_row(); + + ui.label("Frames folder"); + ui.text_edit_singleline(&mut entry.source); + ui.end_row(); + + ui.label("FPS"); + ui.add(egui::DragValue::new(&mut entry.fps).clamp_range(1..=240)); + ui.end_row(); + + ui.label("Width"); + ui.add(egui::DragValue::new(&mut entry.width).clamp_range(1..=4096)); + ui.end_row(); + + ui.label("Height"); + ui.add(egui::DragValue::new(&mut entry.height).clamp_range(1..=4096)); + ui.end_row(); + + ui.label("X"); + ui.add(egui::DragValue::new(&mut entry.x).clamp_range(0..=65535)); + ui.end_row(); + + ui.label("Y"); + ui.add(egui::DragValue::new(&mut entry.y).clamp_range(0..=65535)); + ui.end_row(); + + ui.label("Threads"); + let mut threads_str = entry + .threads + .map(|threads| threads.to_string()) + .unwrap_or_default(); + if ui.text_edit_singleline(&mut threads_str).changed() { + entry.threads = threads_str.trim().parse().ok(); + } + ui.end_row(); + }); + }); + } + }); + + if let Some(index) = remove_index { + self.config.entries.remove(index); + self.status = "Entry removed.".to_string(); + } + + if ui.button("Add video").clicked() { + self.config.entries.push(VideoEntry::default()); + } + }); + } +} + +impl GuiApp { + fn refresh_status(&mut self) { + let data = match fs::read_to_string(&self.status_path) { + Ok(data) => data, + Err(_) => return, + }; + let status: StatusFile = match serde_json::from_str(&data) { + Ok(status) => status, + Err(_) => return, + }; + self.frame_stats = status + .entries + .into_iter() + .map(|entry| { + Some(FrameStat { + fps: entry.fps, + current: entry.current, + total: entry.total, + }) + }) + .collect(); + } + + fn refresh_frame_totals(&mut self, sources: &[String]) { + self.last_sources.resize(sources.len(), String::new()); + self.frame_totals.resize(sources.len(), None); + self.last_sources.truncate(sources.len()); + self.frame_totals.truncate(sources.len()); + + for (index, source) in sources.iter().enumerate() { + if self.last_sources[index] == *source { + continue; + } + self.last_sources[index] = source.clone(); + self.frame_totals[index] = count_frames(source); + } + } +} + +#[derive(Clone)] +struct FrameStat { + fps: f64, + current: usize, + total: usize, +} + +fn count_frames(source: &str) -> Option { + if has_glob_pattern(source) { + let count = glob(source) + .ok()? + .filter_map(Result::ok) + .filter(|path| path.is_file()) + .count(); + return Some(count); + } + + let path = Path::new(source); + if path.is_dir() { + let count = fs::read_dir(path) + .ok()? + .filter_map(Result::ok) + .filter(|entry| entry.path().is_file()) + .count(); + return Some(count); + } + if path.is_file() { + return Some(1); + } + None +} + +fn has_glob_pattern(path: &str) -> bool { + path.contains('*') || path.contains('?') || path.contains('[') +} + +fn resolve_pixelpwnr_binary() -> Option { + let filename = if cfg!(windows) { + "pixelpwnr.exe" + } else { + "pixelpwnr" + }; + + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + let sibling = parent.join(filename); + if sibling.exists() { + return Some(sibling); + } + } + } + + if let Ok(cwd) = std::env::current_dir() { + let debug = cwd.join("target").join("debug").join(filename); + if debug.exists() { + return Some(debug); + } + let release = cwd.join("target").join("release").join(filename); + if release.exists() { + return Some(release); + } + } + + None +} + +fn send_interrupt(child: &Child) -> bool { + #[cfg(unix)] + { + let pid = child.id() as i32; + unsafe { libc::kill(pid, libc::SIGINT) == 0 } + } + + #[cfg(not(unix))] + { + let _ = child; + false + } +} + +fn main() { + let options = NativeOptions::default(); + let _ = eframe::run_native( + "pixelpwnr", + options, + Box::new(|_cc| Box::new(GuiApp::new())), + ); +}