c
This commit is contained in:
30
pixelpwnr.json
Normal file
30
pixelpwnr.json
Normal 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
51
src/config.rs
Normal 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
461
src/gui.rs
Normal 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())),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user