Compare commits

..

1 Commits

Author SHA1 Message Date
d77781d81d c 2025-12-27 14:13:29 +01:00
3 changed files with 542 additions and 0 deletions

30
pixelpwnr.json Normal file
View File

@@ -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
}
]
}

51
src/config.rs Normal file
View File

@@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
pub host: String,
pub threads: Option<usize>,
pub binary: bool,
pub flush: bool,
pub entries: Vec<VideoEntry>,
}
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<usize>,
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,
}
}
}

461
src/gui.rs Normal file
View File

@@ -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<Child>,
status: String,
frame_stats: Vec<Option<FrameStat>>,
frame_totals: Vec<Option<usize>>,
last_sources: Vec<String>,
}
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::<ProjectConfig>(&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<StatusEntry>,
}
#[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<String> = 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<usize> {
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<PathBuf> {
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())),
);
}