Compare commits
10 Commits
a8cf099e24
...
13a4810cbf
| Author | SHA1 | Date | |
|---|---|---|---|
| 13a4810cbf | |||
| 8c5aa4f948 | |||
|
|
38ce0f0c43 | ||
|
|
e7a0fed049 | ||
|
|
708cd2794f | ||
|
|
46fd802c6b | ||
|
|
7461fb4831 | ||
|
|
6c72e84a55 | ||
|
|
b945034f71 | ||
|
|
f8a89223c9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
|||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
/testserver
|
/testserver
|
||||||
.*.swp
|
.*.swp
|
||||||
|
video/*
|
||||||
|
|||||||
4057
Cargo.lock
generated
4057
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pixelpwnr"
|
name = "pixelpwnr"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
authors = ["Tim Visée <timvisee@gmail.com>"]
|
authors = ["Tim Visée <timvisee@gmail.com>"]
|
||||||
license = "GPL-3.0"
|
license = "GPL-3.0"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -15,10 +16,24 @@ categories = [
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
bufstream = "0.1"
|
bufstream = "0.1"
|
||||||
clap = { version = "4.4", features = [ "derive" ] }
|
clap = { version = "4.4", features = [ "derive" ] }
|
||||||
|
ctrlc = "3.4"
|
||||||
|
eframe = "0.26"
|
||||||
|
glob = "0.3"
|
||||||
image = "0.23"
|
image = "0.23"
|
||||||
|
libc = "0.2"
|
||||||
num_cpus = "1.13.1"
|
num_cpus = "1.13.1"
|
||||||
regex = "1.5"
|
regex = "1.5"
|
||||||
rayon = "1.5.1"
|
rayon = "1.5.1"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pixelpwnr"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pixelpwnr-gui"
|
||||||
|
path = "src/gui.rs"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -1,4 +1,5 @@
|
|||||||
# pixelpwnr
|
# pixelpwnr
|
||||||
|
|
||||||
A quick [pixelflut][pixelflut] ([video][pixelflut-video]) client in
|
A quick [pixelflut][pixelflut] ([video][pixelflut-video]) client in
|
||||||
[Rust][rust] for use at [34C3][34C3], that _pwns_ whole pixelflut panels.
|
[Rust][rust] for use at [34C3][34C3], that _pwns_ whole pixelflut panels.
|
||||||
|
|
||||||
@@ -6,12 +7,10 @@ For a high performance pixelflut client and server implementations, see:
|
|||||||
- [pixelpwnr-server][pixelpwnr-server]: server
|
- [pixelpwnr-server][pixelpwnr-server]: server
|
||||||
- [pixelpwnr-cast][pixelpwnr-cast]: cast your screen to a pixelflut server
|
- [pixelpwnr-cast][pixelpwnr-cast]: cast your screen to a pixelflut server
|
||||||
|
|
||||||
**Note:** This is a prototype project. Some things may not work correctly.
|
|
||||||
Or some things may work slow.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Many concurrent drawing pipes, fast multithreading
|
* Many concurrent drawing pipes, fast multithreading
|
||||||
* Animated images, with multiple frame images
|
* Animated images, with GIFs or multiple frame images
|
||||||
* Control over render sizes and offset
|
* Control over render sizes and offset
|
||||||
* Automatic image sizing and formatting
|
* Automatic image sizing and formatting
|
||||||
* Blazingly fast [binary protocol](https://github.com/timvisee/pixelpwnr-server#the-binary-px-command) (`PB` with `--binary`)
|
* Blazingly fast [binary protocol](https://github.com/timvisee/pixelpwnr-server#the-binary-px-command) (`PB` with `--binary`)
|
||||||
@@ -19,6 +18,7 @@ Or some things may work slow.
|
|||||||
* Linux, Windows and macOS
|
* Linux, Windows and macOS
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Pixelflut a simple image:
|
Pixelflut a simple image:
|
||||||
```bash
|
```bash
|
||||||
# Flut a simple image.
|
# Flut a simple image.
|
||||||
@@ -48,6 +48,7 @@ Use the `--help` flag, or see the [help](#help) section for all available
|
|||||||
options.
|
options.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
For installation, Git and Rust cargo are required.
|
For installation, Git and Rust cargo are required.
|
||||||
Install the latest version of Rust with [rustup][rustup].
|
Install the latest version of Rust with [rustup][rustup].
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ cargo build --release
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Performance & speed optimization
|
## Performance & speed optimization
|
||||||
|
|
||||||
There are many things that affect how quickly pixels can be painted on a
|
There are many things that affect how quickly pixels can be painted on a
|
||||||
pixelflut server.
|
pixelflut server.
|
||||||
Some of them are:
|
Some of them are:
|
||||||
@@ -104,37 +106,30 @@ Things that improve painting performance:
|
|||||||
- Use multiple machines (servers) with multiple `pixelpwnr` instances to push
|
- Use multiple machines (servers) with multiple `pixelpwnr` instances to push
|
||||||
pixels to the screen.
|
pixels to the screen.
|
||||||
|
|
||||||
## Future improvements
|
|
||||||
This application is still in the prototyping phase, and many things can be
|
|
||||||
improved for significantly better performance and usability.
|
|
||||||
See the [TODO](TODO.md) file for a list of future improvements.
|
|
||||||
|
|
||||||
## Help
|
## Help
|
||||||
|
|
||||||
```text
|
```text
|
||||||
pixelpwnr --help
|
$ pixelpwnr --help
|
||||||
|
|
||||||
pixelpwnr 0.1
|
Insanely fast pixelflut client for images and animations
|
||||||
Tim Visee <timvisee@gmail.com>
|
|
||||||
A quick pixelflut client, that pwns pixelflut panels.
|
|
||||||
|
|
||||||
USAGE:
|
Usage: pixelpwnr [OPTIONS] --image <PATH>... <HOST>
|
||||||
pixelpwnr [OPTIONS] <HOST> --image <PATH>...
|
|
||||||
|
|
||||||
FLAGS:
|
Arguments:
|
||||||
--help Prints help information
|
<HOST> The host to pwn "host:port"
|
||||||
-V, --version Prints version information
|
|
||||||
|
|
||||||
OPTIONS:
|
Options:
|
||||||
-i, --image <PATH>... Image paths
|
--help Show this help
|
||||||
-w, --width <PIXELS> Draw width (def: screen width)
|
-i, --image <PATH>... Image path(s)
|
||||||
-h, --height <PIXELS> Draw height (def: screen height)
|
-w, --width <PIXELS> Draw width [default: screen width]
|
||||||
-x <PIXELS> Draw X offset (def: 0)
|
-h, --height <PIXELS> Draw height [default: screen height]
|
||||||
-y <PIXELS> Draw Y offset (def: 0)
|
-x <PIXELS> Draw X offset [default: 0]
|
||||||
-c, --count <COUNT> Number of concurrent threads (def: CPUs)
|
-y <PIXELS> Draw Y offset [default: 0]
|
||||||
-r, --fps <RATE> Frames per second with multiple images (def: 1)
|
-c, --count <COUNT> Number of concurrent threads [default: number of CPUs]
|
||||||
|
-r, --fps <RATE> Frames per second with multiple images [default: 1]
|
||||||
ARGS:
|
-b, --binary Use binary mode to set pixels (`PB` protocol extension) [default: off]
|
||||||
<HOST> The host to pwn "host:port"
|
-f, --flush <ENABLED> Flush socket after each pixel [default: true] [default: true] [possible values: true, false]
|
||||||
|
-V, --version Print version
|
||||||
```
|
```
|
||||||
|
|
||||||
## Relevant projects
|
## Relevant projects
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
extern crate clap;
|
|
||||||
extern crate num_cpus;
|
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -13,14 +10,15 @@ pub struct Arguments {
|
|||||||
help: Option<bool>,
|
help: Option<bool>,
|
||||||
|
|
||||||
/// The host to pwn "host:port"
|
/// The host to pwn "host:port"
|
||||||
host: String,
|
#[arg(required_unless_present = "config")]
|
||||||
|
host: Option<String>,
|
||||||
|
|
||||||
/// Image path(s)
|
/// Image path(s)
|
||||||
#[arg(
|
#[arg(
|
||||||
short,
|
short,
|
||||||
long,
|
long,
|
||||||
value_name = "PATH",
|
value_name = "PATH",
|
||||||
required = true,
|
required_unless_present = "config",
|
||||||
alias = "images",
|
alias = "images",
|
||||||
num_args(1..)
|
num_args(1..)
|
||||||
)]
|
)]
|
||||||
@@ -40,6 +38,18 @@ pub struct Arguments {
|
|||||||
#[arg(short, value_name = "PIXELS", default_value_t = 0)]
|
#[arg(short, value_name = "PIXELS", default_value_t = 0)]
|
||||||
y: u16,
|
y: u16,
|
||||||
|
|
||||||
|
/// Path to a JSON config file
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
config: Option<String>,
|
||||||
|
|
||||||
|
/// Path to write JSON status updates
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
status: Option<String>,
|
||||||
|
|
||||||
|
/// Tile images across the screen, ignoring offsets
|
||||||
|
#[arg(short, long)]
|
||||||
|
tile: bool,
|
||||||
|
|
||||||
/// Number of concurrent threads [default: number of CPUs]
|
/// Number of concurrent threads [default: number of CPUs]
|
||||||
#[arg(short, long, aliases = ["thread", "threads"])]
|
#[arg(short, long, aliases = ["thread", "threads"])]
|
||||||
count: Option<usize>,
|
count: Option<usize>,
|
||||||
@@ -52,9 +62,9 @@ pub struct Arguments {
|
|||||||
#[arg(short, long, alias = "bin")]
|
#[arg(short, long, alias = "bin")]
|
||||||
binary: bool,
|
binary: bool,
|
||||||
|
|
||||||
/// Do not flush socket after each pixel [default: on]
|
/// Flush socket after each pixel [default: true]
|
||||||
#[arg(short, long)]
|
#[arg(short, long, action = clap::ArgAction::Set, value_name = "ENABLED", default_value_t = true)]
|
||||||
no_flush: bool,
|
flush: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CLI argument handler.
|
/// CLI argument handler.
|
||||||
@@ -71,7 +81,22 @@ impl ArgHandler {
|
|||||||
|
|
||||||
/// Get the host property.
|
/// Get the host property.
|
||||||
pub fn host(&self) -> &str {
|
pub fn host(&self) -> &str {
|
||||||
self.data.host.as_str()
|
self.data.host.as_deref().expect("Host is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the optional host property.
|
||||||
|
pub fn host_optional(&self) -> Option<&str> {
|
||||||
|
self.data.host.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the config path.
|
||||||
|
pub fn config_path(&self) -> Option<&str> {
|
||||||
|
self.data.config.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the status path.
|
||||||
|
pub fn status_path(&self) -> Option<&str> {
|
||||||
|
self.data.status.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the thread count.
|
/// Get the thread count.
|
||||||
@@ -102,6 +127,11 @@ impl ArgHandler {
|
|||||||
(self.data.x, self.data.y)
|
(self.data.x, self.data.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether to tile images across the screen.
|
||||||
|
pub fn tile(&self) -> bool {
|
||||||
|
self.data.tile
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the FPS.
|
/// Get the FPS.
|
||||||
pub fn fps(&self) -> u32 {
|
pub fn fps(&self) -> u32 {
|
||||||
self.data.fps
|
self.data.fps
|
||||||
@@ -112,8 +142,8 @@ impl ArgHandler {
|
|||||||
self.data.binary
|
self.data.binary
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether to prevent flushing after each pixel.
|
/// Whether to flush after each pixel.
|
||||||
pub fn no_flush(&self) -> bool {
|
pub fn flush(&self) -> bool {
|
||||||
self.data.no_flush
|
self.data.flush
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,48 @@
|
|||||||
|
use glob::glob;
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
use std::path::Path;
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::thread::sleep;
|
use std::thread::sleep;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use image;
|
use crate::pix::canvas::Canvas;
|
||||||
use image::imageops::FilterType;
|
use image::imageops::FilterType;
|
||||||
use image::DynamicImage;
|
use image::{DynamicImage, RgbaImage};
|
||||||
|
|
||||||
use pix::canvas::Canvas;
|
enum ImageMode {
|
||||||
|
Direct { size: (u16, u16) },
|
||||||
|
Tiled { tile_size: (u16, u16), canvas_size: (u16, u16) },
|
||||||
|
}
|
||||||
|
|
||||||
/// A manager that manages all images to print.
|
/// A manager that manages all images to print.
|
||||||
pub struct ImageManager {
|
pub struct ImageManager {
|
||||||
images: Vec<DynamicImage>,
|
frames: Vec<PathBuf>,
|
||||||
// Define whether the first image has been drawn
|
mode: ImageMode,
|
||||||
|
preprocessed: bool,
|
||||||
|
cache_dir: Option<PathBuf>,
|
||||||
|
cached_single: Option<DynamicImage>,
|
||||||
|
/// Define whether the first image has been drawn
|
||||||
first: bool,
|
first: bool,
|
||||||
index: isize,
|
index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImageManager {
|
impl ImageManager {
|
||||||
/// Intantiate the image manager.
|
/// Intantiate the image manager.
|
||||||
pub fn from(images: Vec<DynamicImage>) -> ImageManager {
|
fn from(
|
||||||
|
frames: Vec<PathBuf>,
|
||||||
|
mode: ImageMode,
|
||||||
|
preprocessed: bool,
|
||||||
|
cache_dir: Option<PathBuf>,
|
||||||
|
) -> ImageManager {
|
||||||
ImageManager {
|
ImageManager {
|
||||||
images,
|
frames,
|
||||||
|
mode,
|
||||||
|
preprocessed,
|
||||||
|
cache_dir,
|
||||||
|
cached_single: None,
|
||||||
first: false,
|
first: false,
|
||||||
index: 0,
|
index: 0,
|
||||||
}
|
}
|
||||||
@@ -30,45 +51,83 @@ impl ImageManager {
|
|||||||
/// Instantiate the image manager, and load the images from the given paths.
|
/// Instantiate the image manager, and load the images from the given paths.
|
||||||
pub fn load(paths: &[&str], size: (u16, u16)) -> ImageManager {
|
pub fn load(paths: &[&str], size: (u16, u16)) -> ImageManager {
|
||||||
// Show a status message
|
// Show a status message
|
||||||
println!("Load and process {} image(s)...", paths.len());
|
println!("Load and process {} path(s)...", paths.len());
|
||||||
|
|
||||||
// Load the images from the paths
|
// Load the images from the paths
|
||||||
let image_manager = ImageManager::from(
|
let frames: Vec<PathBuf> = paths
|
||||||
paths
|
.par_iter()
|
||||||
.par_iter()
|
.flat_map(|path| load_image_frames(path))
|
||||||
.map(|path| load_image(path, size))
|
.collect();
|
||||||
.collect(),
|
let mode = ImageMode::Direct { size };
|
||||||
);
|
let (frames, preprocessed, cache_dir) = preprocess_frames(&frames, &mode);
|
||||||
|
let image_manager = ImageManager::from(frames, mode, preprocessed, cache_dir);
|
||||||
|
|
||||||
// TODO: process the image slices
|
// TODO: process the image slices
|
||||||
|
|
||||||
// We succeeded
|
// We succeeded
|
||||||
println!("All images have been loaded successfully");
|
println!(
|
||||||
|
"Loaded {} frame(s) from {} path(s).",
|
||||||
|
image_manager.frames.len(),
|
||||||
|
paths.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
image_manager
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instantiate the image manager, and load and tile the images from the given paths.
|
||||||
|
pub fn load_tiled(
|
||||||
|
paths: &[&str],
|
||||||
|
tile_size: (u16, u16),
|
||||||
|
canvas_size: (u16, u16),
|
||||||
|
) -> ImageManager {
|
||||||
|
// Show a status message
|
||||||
|
println!("Load and process {} path(s)...", paths.len());
|
||||||
|
|
||||||
|
let frames: Vec<PathBuf> = paths
|
||||||
|
.par_iter()
|
||||||
|
.flat_map(|path| load_image_frames(path))
|
||||||
|
.collect();
|
||||||
|
let mode = ImageMode::Tiled {
|
||||||
|
tile_size,
|
||||||
|
canvas_size,
|
||||||
|
};
|
||||||
|
let (frames, preprocessed, cache_dir) = preprocess_frames(&frames, &mode);
|
||||||
|
let image_manager = ImageManager::from(frames, mode, preprocessed, cache_dir);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Loaded {} frame(s) from {} path(s).",
|
||||||
|
image_manager.frames.len(),
|
||||||
|
paths.len()
|
||||||
|
);
|
||||||
|
|
||||||
image_manager
|
image_manager
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tick the image
|
/// Tick the image
|
||||||
pub fn tick(&mut self, canvas: &mut Canvas) {
|
///
|
||||||
|
/// Returns the desired duration for othis frame.
|
||||||
|
pub fn tick(&mut self, canvas: &mut Canvas) -> Option<Duration> {
|
||||||
// Get the image index bound
|
// Get the image index bound
|
||||||
let bound = self.images.len();
|
let bound = self.frames.len();
|
||||||
|
|
||||||
// Just return if the bound is one, as nothing should be updated
|
// Just return if the bound is one, as nothing should be updated
|
||||||
if self.first && bound == 1 {
|
if self.first && bound == 1 {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the image to use
|
// Get the image to use
|
||||||
let image = &mut self.images[self.index as usize % bound];
|
let mut image = self.load_frame(self.index % bound);
|
||||||
|
|
||||||
// Update the image on the canvas
|
// Update the image on the canvas
|
||||||
canvas.update_image(image);
|
canvas.update_image(&mut image);
|
||||||
|
|
||||||
// Increase the index
|
// Increase the index
|
||||||
self.index += 1;
|
self.index += 1;
|
||||||
|
|
||||||
// We have rendered the first image
|
// We have rendered the first image
|
||||||
self.first = true;
|
self.first = true;
|
||||||
|
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start working in the image manager.
|
/// Start working in the image manager.
|
||||||
@@ -76,30 +135,233 @@ impl ImageManager {
|
|||||||
/// This will start walking through all image frames,
|
/// This will start walking through all image frames,
|
||||||
/// and pushes each frame to all painters,
|
/// and pushes each frame to all painters,
|
||||||
/// with the specified frames per second.
|
/// with the specified frames per second.
|
||||||
pub fn work(&mut self, canvas: &mut Canvas, fps: u32) {
|
pub fn work<F>(
|
||||||
loop {
|
&mut self,
|
||||||
// Tick to use the next image
|
canvas: &mut Canvas,
|
||||||
self.tick(canvas);
|
fps: u32,
|
||||||
|
shutdown: &AtomicBool,
|
||||||
|
mut on_frame: F,
|
||||||
|
)
|
||||||
|
where
|
||||||
|
F: FnMut(usize, usize),
|
||||||
|
{
|
||||||
|
let fps = fps.max(1);
|
||||||
|
let frame_ms = 1000f64 / (fps as f64);
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let mut next_tick = 0f64;
|
||||||
|
|
||||||
// Sleep until we need to show the next image
|
loop {
|
||||||
sleep(Duration::from_millis((1000f32 / (fps as f32)) as u64));
|
if shutdown.load(Ordering::SeqCst) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
let target_index = (elapsed_ms / frame_ms).floor() as usize;
|
||||||
|
if target_index != self.index {
|
||||||
|
self.index = target_index;
|
||||||
|
self.tick(canvas);
|
||||||
|
let total = self.frames.len();
|
||||||
|
let current = (self.index % total) + 1;
|
||||||
|
on_frame(current, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
next_tick += frame_ms;
|
||||||
|
let sleep_ms = (next_tick - elapsed_ms).max(1.0) as u64;
|
||||||
|
sleep(Duration::from_millis(sleep_ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn frame_count(&self) -> usize {
|
||||||
|
self.frames.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_frame(&mut self, index: usize) -> DynamicImage {
|
||||||
|
if self.frames.len() == 1 {
|
||||||
|
if let Some(image) = self.cached_single.as_ref() {
|
||||||
|
return image.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = &self.frames[index];
|
||||||
|
let image = if self.preprocessed {
|
||||||
|
image::open(path).unwrap()
|
||||||
|
} else {
|
||||||
|
match self.mode {
|
||||||
|
ImageMode::Direct { size } => image::open(path)
|
||||||
|
.unwrap()
|
||||||
|
.resize_exact(size.0 as u32, size.1 as u32, FilterType::Gaussian),
|
||||||
|
ImageMode::Tiled {
|
||||||
|
tile_size,
|
||||||
|
canvas_size,
|
||||||
|
} => tile_image(
|
||||||
|
image::open(path).unwrap(),
|
||||||
|
(tile_size.0 as u32, tile_size.1 as u32),
|
||||||
|
(canvas_size.0 as u32, canvas_size.1 as u32),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.frames.len() == 1 {
|
||||||
|
self.cached_single = Some(image.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static CACHE_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
fn preprocess_frames(
|
||||||
|
frames: &[PathBuf],
|
||||||
|
mode: &ImageMode,
|
||||||
|
) -> (Vec<PathBuf>, bool, Option<PathBuf>) {
|
||||||
|
if frames.len() <= 1 {
|
||||||
|
return (frames.to_vec(), false, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let counter = CACHE_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let stamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
let cache_dir = std::env::temp_dir().join(format!(
|
||||||
|
"pixelpwnr_cache_{}_{}_{}",
|
||||||
|
process::id(),
|
||||||
|
stamp,
|
||||||
|
counter
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&cache_dir).expect("failed to create cache directory");
|
||||||
|
match *mode {
|
||||||
|
ImageMode::Direct { size } => println!(
|
||||||
|
"Resizing {} frame(s) to {}x{} (cache: {})",
|
||||||
|
frames.len(),
|
||||||
|
size.0,
|
||||||
|
size.1,
|
||||||
|
cache_dir.display()
|
||||||
|
),
|
||||||
|
ImageMode::Tiled {
|
||||||
|
tile_size,
|
||||||
|
canvas_size,
|
||||||
|
} => println!(
|
||||||
|
"Resizing {} frame(s) to tile {}x{} over {}x{} (cache: {})",
|
||||||
|
frames.len(),
|
||||||
|
tile_size.0,
|
||||||
|
tile_size.1,
|
||||||
|
canvas_size.0,
|
||||||
|
canvas_size.1,
|
||||||
|
cache_dir.display()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = frames.len();
|
||||||
|
let progress = AtomicUsize::new(0);
|
||||||
|
let output_paths: Vec<PathBuf> = frames
|
||||||
|
.par_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, path)| {
|
||||||
|
let output = cache_dir.join(format!("{:06}.png", index));
|
||||||
|
let processed = match *mode {
|
||||||
|
ImageMode::Direct { size } => image::open(path)
|
||||||
|
.unwrap()
|
||||||
|
.resize_exact(size.0 as u32, size.1 as u32, FilterType::Gaussian),
|
||||||
|
ImageMode::Tiled {
|
||||||
|
tile_size,
|
||||||
|
canvas_size,
|
||||||
|
} => tile_image(
|
||||||
|
image::open(path).unwrap(),
|
||||||
|
(tile_size.0 as u32, tile_size.1 as u32),
|
||||||
|
(canvas_size.0 as u32, canvas_size.1 as u32),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
processed
|
||||||
|
.save(&output)
|
||||||
|
.expect("failed to save cached frame");
|
||||||
|
let done = progress.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
if done == total || done % 100 == 0 {
|
||||||
|
println!("Resized {done}/{total} frame(s)...");
|
||||||
|
}
|
||||||
|
output
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
println!("Cached {} frame(s).", output_paths.len());
|
||||||
|
|
||||||
|
(output_paths, true, Some(cache_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ImageManager {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(cache_dir) = &self.cache_dir {
|
||||||
|
match fs::remove_dir_all(cache_dir) {
|
||||||
|
Ok(()) => println!("Removed cache directory {}", cache_dir.display()),
|
||||||
|
Err(err) => eprintln!("Failed to remove cache directory: {err}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the image at the given path, and size it correctly
|
fn load_image_frames(path: &str) -> Vec<PathBuf> {
|
||||||
fn load_image(path: &str, size: (u16, u16)) -> DynamicImage {
|
if has_glob_pattern(path) {
|
||||||
// Create a path instance
|
let mut entries: Vec<PathBuf> = glob(path)
|
||||||
|
.expect("failed to read frames glob")
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|entry| entry.is_file())
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
panic!("The given frames glob matched no files");
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
let path = Path::new(&path);
|
let path = Path::new(&path);
|
||||||
|
|
||||||
// Check whether the path exists
|
if path.is_dir() {
|
||||||
|
let mut entries: Vec<PathBuf> = fs::read_dir(path)
|
||||||
|
.expect("failed to read frames directory")
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.filter(|entry| entry.is_file())
|
||||||
|
.collect();
|
||||||
|
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
panic!("The given frames directory is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
panic!("The given path does not exist or is not a file");
|
panic!("The given path does not exist or is not a file");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the image
|
vec![path.to_path_buf()]
|
||||||
let image = image::open(path).unwrap();
|
}
|
||||||
|
|
||||||
// Resize the image to fit the screen
|
fn has_glob_pattern(path: &str) -> bool {
|
||||||
image.resize_exact(size.0 as u32, size.1 as u32, FilterType::Gaussian)
|
path.contains('*') || path.contains('?') || path.contains('[')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tile_image(image: DynamicImage, tile_size: (u32, u32), canvas_size: (u32, u32)) -> DynamicImage {
|
||||||
|
let tile = image
|
||||||
|
.resize_exact(tile_size.0, tile_size.1, FilterType::Gaussian)
|
||||||
|
.to_rgba8();
|
||||||
|
let mut tiled = RgbaImage::new(canvas_size.0, canvas_size.1);
|
||||||
|
let tile_w = tile.width();
|
||||||
|
let tile_h = tile.height();
|
||||||
|
|
||||||
|
let mut y = 0;
|
||||||
|
while y < canvas_size.1 {
|
||||||
|
let mut x = 0;
|
||||||
|
while x < canvas_size.0 {
|
||||||
|
image::imageops::overlay(&mut tiled, &tile, x, y);
|
||||||
|
x += tile_w;
|
||||||
|
}
|
||||||
|
y += tile_h;
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicImage::ImageRgba8(tiled)
|
||||||
}
|
}
|
||||||
|
|||||||
169
src/main.rs
169
src/main.rs
@@ -1,9 +1,6 @@
|
|||||||
extern crate clap;
|
mod args;
|
||||||
extern crate image;
|
|
||||||
extern crate rayon;
|
|
||||||
|
|
||||||
mod arg_handler;
|
|
||||||
mod color;
|
mod color;
|
||||||
|
mod config;
|
||||||
mod image_manager;
|
mod image_manager;
|
||||||
mod painter;
|
mod painter;
|
||||||
mod pix;
|
mod pix;
|
||||||
@@ -11,10 +8,15 @@ mod rect;
|
|||||||
|
|
||||||
use std::io::Error;
|
use std::io::Error;
|
||||||
|
|
||||||
use arg_handler::ArgHandler;
|
use args::ArgHandler;
|
||||||
|
use config::ProjectConfig;
|
||||||
use image_manager::ImageManager;
|
use image_manager::ImageManager;
|
||||||
use pix::canvas::Canvas;
|
use pix::canvas::Canvas;
|
||||||
use pix::client::Client;
|
use pix::client::Client;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// Main application entrypoint.
|
/// Main application entrypoint.
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -30,28 +32,171 @@ fn start(arg_handler: &ArgHandler) {
|
|||||||
// Start
|
// Start
|
||||||
println!("Starting... (use CTRL+C to stop)");
|
println!("Starting... (use CTRL+C to stop)");
|
||||||
|
|
||||||
|
let shutdown = setup_shutdown_handler();
|
||||||
|
|
||||||
|
if let Some(config_path) = arg_handler.config_path() {
|
||||||
|
start_from_config(arg_handler, config_path, shutdown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Gather facts about the host
|
// Gather facts about the host
|
||||||
let screen_size =
|
let screen_size =
|
||||||
gather_host_facts(arg_handler).expect("Failed to gather facts about pixelflut server");
|
gather_host_facts(arg_handler).expect("Failed to gather facts about pixelflut server");
|
||||||
|
|
||||||
// Determine the size to use
|
let tile_size = arg_handler.size(Some(screen_size));
|
||||||
let size = arg_handler.size(Some(screen_size));
|
let (size, offset) = if arg_handler.tile() {
|
||||||
|
(screen_size, (0, 0))
|
||||||
|
} else {
|
||||||
|
(tile_size, arg_handler.offset())
|
||||||
|
};
|
||||||
|
|
||||||
// Create a new pixelflut canvas
|
// Create a new pixelflut canvas
|
||||||
let mut canvas = Canvas::new(
|
let mut canvas = Canvas::new(
|
||||||
arg_handler.host(),
|
arg_handler.host(),
|
||||||
arg_handler.count(),
|
arg_handler.count(),
|
||||||
size,
|
size,
|
||||||
arg_handler.offset(),
|
offset,
|
||||||
arg_handler.binary(),
|
arg_handler.binary(),
|
||||||
!arg_handler.no_flush(),
|
arg_handler.flush(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load the image manager
|
// Load the image manager
|
||||||
let mut image_manager = ImageManager::load(&arg_handler.image_paths(), size);
|
let mut image_manager = if arg_handler.tile() {
|
||||||
|
ImageManager::load_tiled(&arg_handler.image_paths(), tile_size, size)
|
||||||
|
} else {
|
||||||
|
ImageManager::load(&arg_handler.image_paths(), size)
|
||||||
|
};
|
||||||
|
|
||||||
// Start the work in the image manager, to walk through the frames
|
// Start the work in the image manager, to walk through the frames
|
||||||
image_manager.work(&mut canvas, arg_handler.fps());
|
image_manager.work(&mut canvas, arg_handler.fps(), &shutdown, |_current, _total| {});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
struct StatusEntry {
|
||||||
|
name: String,
|
||||||
|
fps: f64,
|
||||||
|
current: usize,
|
||||||
|
total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct StatusFile {
|
||||||
|
entries: Vec<StatusEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_from_config(arg_handler: &ArgHandler, config_path: &str, shutdown: Arc<AtomicBool>) {
|
||||||
|
let config = load_config(config_path);
|
||||||
|
let host = arg_handler
|
||||||
|
.host_optional()
|
||||||
|
.map(|host| host.to_string())
|
||||||
|
.unwrap_or_else(|| config.host.clone());
|
||||||
|
if host.trim().is_empty() {
|
||||||
|
panic!("No host specified in CLI or config");
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_threads = config.threads.unwrap_or_else(|| arg_handler.count());
|
||||||
|
let binary = config.binary;
|
||||||
|
let flush = config.flush;
|
||||||
|
|
||||||
|
let status_path = arg_handler.status_path().map(|path| path.to_string());
|
||||||
|
let stats: Arc<std::sync::Mutex<Vec<StatusEntry>>> = Arc::new(std::sync::Mutex::new(
|
||||||
|
config
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| StatusEntry {
|
||||||
|
name: entry.name.clone(),
|
||||||
|
fps: 0.0,
|
||||||
|
current: 0,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
));
|
||||||
|
if let Some(status_path) = status_path.clone() {
|
||||||
|
let stats = Arc::clone(&stats);
|
||||||
|
let shutdown = Arc::clone(&shutdown);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
while !shutdown.load(Ordering::SeqCst) {
|
||||||
|
if let Ok(locked) = stats.lock() {
|
||||||
|
let payload = StatusFile {
|
||||||
|
entries: locked.clone(),
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(&payload) {
|
||||||
|
let _ = std::fs::write(&status_path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for (index, entry) in config.entries.into_iter().enumerate() {
|
||||||
|
if !entry.enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let host = host.clone();
|
||||||
|
let threads = entry.threads.unwrap_or(default_threads);
|
||||||
|
let stats = Arc::clone(&stats);
|
||||||
|
let shutdown = Arc::clone(&shutdown);
|
||||||
|
handles.push(std::thread::spawn(move || {
|
||||||
|
let mut canvas = Canvas::new(
|
||||||
|
&host,
|
||||||
|
threads,
|
||||||
|
(entry.width, entry.height),
|
||||||
|
(entry.x, entry.y),
|
||||||
|
binary,
|
||||||
|
flush,
|
||||||
|
);
|
||||||
|
let mut image_manager = ImageManager::load(&[entry.source.as_str()], (entry.width, entry.height));
|
||||||
|
let total = image_manager.frame_count();
|
||||||
|
if let Ok(mut locked) = stats.lock() {
|
||||||
|
if let Some(stat) = locked.get_mut(index) {
|
||||||
|
stat.total = total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut frames = 0usize;
|
||||||
|
let mut last_report = Instant::now();
|
||||||
|
image_manager.work(&mut canvas, entry.fps, &shutdown, |current, total| {
|
||||||
|
frames += 1;
|
||||||
|
if let Ok(mut locked) = stats.lock() {
|
||||||
|
if let Some(stat) = locked.get_mut(index) {
|
||||||
|
stat.current = current;
|
||||||
|
stat.total = total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let elapsed = last_report.elapsed();
|
||||||
|
if elapsed >= Duration::from_secs(1) {
|
||||||
|
let fps = frames as f64 / elapsed.as_secs_f64();
|
||||||
|
if let Ok(mut locked) = stats.lock() {
|
||||||
|
if let Some(stat) = locked.get_mut(index) {
|
||||||
|
stat.fps = fps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frames = 0;
|
||||||
|
last_report = Instant::now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for handle in handles {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_shutdown_handler() -> Arc<AtomicBool> {
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let shutdown_handler = Arc::clone(&shutdown);
|
||||||
|
ctrlc::set_handler(move || {
|
||||||
|
shutdown_handler.store(true, Ordering::SeqCst);
|
||||||
|
})
|
||||||
|
.expect("Failed to set CTRL+C handler");
|
||||||
|
shutdown
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_config(path: &str) -> ProjectConfig {
|
||||||
|
let data = std::fs::read_to_string(path).expect("Failed to read config file");
|
||||||
|
serde_json::from_str::<ProjectConfig>(&data).expect("Failed to parse config file")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gather important facts about the host.
|
/// Gather important facts about the host.
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
extern crate image;
|
|
||||||
|
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::mpsc::Sender;
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
|
|
||||||
use rect::Rect;
|
use crate::rect::Rect;
|
||||||
|
|
||||||
/// A handle to a painter thread.
|
/// A handle to a painter thread.
|
||||||
///
|
///
|
||||||
@@ -30,18 +28,19 @@ impl Handle {
|
|||||||
|
|
||||||
/// Push an image update.
|
/// Push an image update.
|
||||||
pub fn update_image(&self, full_image: &mut DynamicImage) {
|
pub fn update_image(&self, full_image: &mut DynamicImage) {
|
||||||
// Crop the image to the area
|
// Crop without mutating the source image so each painter sees full data.
|
||||||
let image = full_image.crop(
|
let image = image::imageops::crop_imm(
|
||||||
|
full_image,
|
||||||
self.area.x as u32,
|
self.area.x as u32,
|
||||||
self.area.y as u32,
|
self.area.y as u32,
|
||||||
self.area.w as u32,
|
self.area.w as u32,
|
||||||
self.area.h as u32,
|
self.area.h as u32,
|
||||||
);
|
)
|
||||||
|
.to_image();
|
||||||
|
let image = DynamicImage::ImageRgba8(image);
|
||||||
|
|
||||||
// Push a new image to the thread
|
// Push a new image to the thread
|
||||||
// TODO: return this result
|
// TODO: return this result
|
||||||
self.image_sender
|
let _ = self.image_sender.send(image);
|
||||||
.send(image)
|
|
||||||
.expect("Failed to send image update to painter");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ use std::sync::mpsc::Receiver;
|
|||||||
|
|
||||||
use image::{DynamicImage, Pixel};
|
use image::{DynamicImage, Pixel};
|
||||||
|
|
||||||
use color::Color;
|
use crate::color::Color;
|
||||||
use pix::client::Client;
|
use crate::pix::client::Client;
|
||||||
use rect::Rect;
|
use crate::rect::Rect;
|
||||||
|
|
||||||
/// A painter that paints on a pixelflut panel.
|
/// A painter that paints on a pixelflut panel.
|
||||||
pub struct Painter {
|
pub struct Painter {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
|
|
||||||
use painter::handle::Handle;
|
use crate::painter::handle::Handle;
|
||||||
use painter::painter::Painter;
|
use crate::painter::painter::Painter;
|
||||||
use pix::client::Client;
|
use crate::pix::client::Client;
|
||||||
use rect::Rect;
|
use crate::rect::Rect;
|
||||||
|
|
||||||
/// A pixflut instance
|
/// A pixflut instance
|
||||||
pub struct Canvas {
|
pub struct Canvas {
|
||||||
@@ -50,16 +50,42 @@ impl Canvas {
|
|||||||
|
|
||||||
/// Spawn the painters for this canvas
|
/// Spawn the painters for this canvas
|
||||||
fn spawn_painters(&mut self, binary: bool, flush: bool) {
|
fn spawn_painters(&mut self, binary: bool, flush: bool) {
|
||||||
// Spawn some painters
|
let grid = (self.painter_count as f64).sqrt() as usize;
|
||||||
for i in 0..self.painter_count {
|
if grid * grid == self.painter_count {
|
||||||
// Determine the slice width
|
let tile_w = self.size.0 / (grid as u16);
|
||||||
let width = self.size.0 / (self.painter_count as u16);
|
let tile_h = self.size.1 / (grid as u16);
|
||||||
|
|
||||||
// Define the area to paint per thread
|
for row in 0..grid {
|
||||||
let painter_area = Rect::from((i as u16) * width, 0, width, self.size.1);
|
for col in 0..grid {
|
||||||
|
let x = (col as u16) * tile_w;
|
||||||
|
let y = (row as u16) * tile_h;
|
||||||
|
let w = if col == grid - 1 {
|
||||||
|
self.size.0 - x
|
||||||
|
} else {
|
||||||
|
tile_w
|
||||||
|
};
|
||||||
|
let h = if row == grid - 1 {
|
||||||
|
self.size.1 - y
|
||||||
|
} else {
|
||||||
|
tile_h
|
||||||
|
};
|
||||||
|
|
||||||
// Spawn the painter
|
let painter_area = Rect::from(x, y, w, h);
|
||||||
self.spawn_painter(painter_area, binary, flush);
|
self.spawn_painter(painter_area, binary, flush);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Spawn some painters
|
||||||
|
for i in 0..self.painter_count {
|
||||||
|
// Determine the slice width
|
||||||
|
let width = self.size.0 / (self.painter_count as u16);
|
||||||
|
|
||||||
|
// Define the area to paint per thread
|
||||||
|
let painter_area = Rect::from((i as u16) * width, 0, width, self.size.1);
|
||||||
|
|
||||||
|
// Spawn the painter
|
||||||
|
self.spawn_painter(painter_area, binary, flush);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
extern crate bufstream;
|
|
||||||
extern crate regex;
|
|
||||||
|
|
||||||
use std::io::prelude::*;
|
use std::io::prelude::*;
|
||||||
use std::io::{Error, ErrorKind};
|
use std::io::{Error, ErrorKind};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
|
|
||||||
use self::bufstream::BufStream;
|
use bufstream::BufStream;
|
||||||
use self::regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
use color::Color;
|
use crate::color::Color;
|
||||||
|
|
||||||
// The default buffer size for reading the client stream.
|
// The default buffer size for reading the client stream.
|
||||||
// - Big enough so we don't have to expand
|
// - Big enough so we don't have to expand
|
||||||
|
|||||||
Reference in New Issue
Block a user