749 lines
26 KiB
Rust
Executable File
749 lines
26 KiB
Rust
Executable File
#![forbid(unsafe_code)]
|
|
|
|
use std::{path::PathBuf, time::Duration};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use gstreamer as gst;
|
|
use gstreamer::prelude::*;
|
|
use gstreamer_app as gst_app;
|
|
use lesavka_common::lesavka::{
|
|
AudioEncoding, AudioPacket, UpstreamMediaBundle, VideoPacket, relay_client::RelayClient,
|
|
};
|
|
use tokio::sync::mpsc;
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
use tonic::Request;
|
|
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
|
|
|
|
const DEFAULT_SERVER: &str = "http://127.0.0.1:50051";
|
|
const DEFAULT_SAMPLE_RATE: u32 = 48_000;
|
|
const DEFAULT_CHANNELS: u32 = 2;
|
|
const DEFAULT_JPEG_QUALITY: i32 = 82;
|
|
const MARKER_BITS: usize = 32;
|
|
const MARKER_COLUMNS: usize = 16;
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct Args {
|
|
server: String,
|
|
width: usize,
|
|
height: usize,
|
|
fps: u32,
|
|
duration: Duration,
|
|
jpeg_quality: i32,
|
|
session_id: u64,
|
|
artifact_dir: Option<PathBuf>,
|
|
print_every: u64,
|
|
max_frame_bytes: usize,
|
|
tls_ca: Option<PathBuf>,
|
|
tls_client_cert: Option<PathBuf>,
|
|
tls_client_key: Option<PathBuf>,
|
|
tls_domain: Option<String>,
|
|
}
|
|
|
|
impl Args {
|
|
fn parse() -> Result<Self> {
|
|
let mut args = Self {
|
|
server: DEFAULT_SERVER.to_string(),
|
|
width: 1280,
|
|
height: 720,
|
|
fps: 30,
|
|
duration: Duration::from_secs(300),
|
|
jpeg_quality: DEFAULT_JPEG_QUALITY,
|
|
session_id: unix_millis(),
|
|
artifact_dir: None,
|
|
print_every: 150,
|
|
max_frame_bytes: env_usize("LESAVKA_SYNTHETIC_MAX_FRAME_BYTES").unwrap_or(0),
|
|
tls_ca: env_path("LESAVKA_TLS_CA").or_else(|| default_pki_path("ca.crt")),
|
|
tls_client_cert: env_path("LESAVKA_TLS_CLIENT_CERT")
|
|
.or_else(|| default_pki_path("client.crt")),
|
|
tls_client_key: env_path("LESAVKA_TLS_CLIENT_KEY")
|
|
.or_else(|| default_pki_path("client.key")),
|
|
tls_domain: std::env::var("LESAVKA_TLS_DOMAIN")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty()),
|
|
};
|
|
let mut it = std::env::args().skip(1);
|
|
while let Some(flag) = it.next() {
|
|
match flag.as_str() {
|
|
"--server" => args.server = next_value(&mut it, &flag)?,
|
|
"--width" => args.width = parse_next(&mut it, &flag)?,
|
|
"--height" => args.height = parse_next(&mut it, &flag)?,
|
|
"--fps" => args.fps = parse_next(&mut it, &flag)?,
|
|
"--duration" => {
|
|
let seconds: f64 = parse_next(&mut it, &flag)?;
|
|
args.duration = Duration::from_secs_f64(seconds.max(0.0));
|
|
}
|
|
"--jpeg-quality" => args.jpeg_quality = parse_next(&mut it, &flag)?,
|
|
"--session-id" => args.session_id = parse_next(&mut it, &flag)?,
|
|
"--artifact-dir" => {
|
|
args.artifact_dir = Some(PathBuf::from(next_value(&mut it, &flag)?))
|
|
}
|
|
"--print-every" => args.print_every = parse_next(&mut it, &flag)?,
|
|
"--max-frame-bytes" => args.max_frame_bytes = parse_next(&mut it, &flag)?,
|
|
"--tls-ca" => args.tls_ca = Some(PathBuf::from(next_value(&mut it, &flag)?)),
|
|
"--tls-client-cert" => {
|
|
args.tls_client_cert = Some(PathBuf::from(next_value(&mut it, &flag)?))
|
|
}
|
|
"--tls-client-key" => {
|
|
args.tls_client_key = Some(PathBuf::from(next_value(&mut it, &flag)?))
|
|
}
|
|
"--tls-domain" => args.tls_domain = Some(next_value(&mut it, &flag)?),
|
|
"--mode" => {
|
|
let value = next_value(&mut it, &flag)?;
|
|
let (width, height, fps) = parse_mode(&value)?;
|
|
args.width = width;
|
|
args.height = height;
|
|
args.fps = fps;
|
|
}
|
|
"--help" | "-h" => {
|
|
print_help();
|
|
std::process::exit(0);
|
|
}
|
|
other => bail!("unknown argument {other:?}; pass --help for usage"),
|
|
}
|
|
}
|
|
if args.width == 0 || args.height == 0 || args.fps == 0 {
|
|
bail!("width, height, and fps must be positive");
|
|
}
|
|
args.jpeg_quality = args.jpeg_quality.clamp(1, 100);
|
|
Ok(args)
|
|
}
|
|
|
|
fn frame_step_us(&self) -> u64 {
|
|
(1_000_000_u64 / u64::from(self.fps)).max(1)
|
|
}
|
|
|
|
fn total_frames(&self) -> u64 {
|
|
let frames = self.duration.as_secs_f64() * f64::from(self.fps);
|
|
frames.ceil().max(1.0) as u64
|
|
}
|
|
}
|
|
|
|
struct MjpegEncoder {
|
|
src: gst_app::AppSrc,
|
|
sink: gst_app::AppSink,
|
|
pipeline: gst::Pipeline,
|
|
width: usize,
|
|
height: usize,
|
|
frame_step_us: u64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
struct EncodeStats {
|
|
frames: u64,
|
|
total_bytes: u128,
|
|
min_bytes: usize,
|
|
max_bytes: usize,
|
|
oversize_frames: u64,
|
|
}
|
|
|
|
impl EncodeStats {
|
|
fn record(&mut self, bytes: usize, max_frame_bytes: usize) {
|
|
self.frames = self.frames.saturating_add(1);
|
|
self.total_bytes = self.total_bytes.saturating_add(bytes as u128);
|
|
self.min_bytes = if self.frames == 1 {
|
|
bytes
|
|
} else {
|
|
self.min_bytes.min(bytes)
|
|
};
|
|
self.max_bytes = self.max_bytes.max(bytes);
|
|
if max_frame_bytes > 0 && bytes > max_frame_bytes {
|
|
self.oversize_frames = self.oversize_frames.saturating_add(1);
|
|
}
|
|
}
|
|
|
|
fn mean_bytes(&self) -> usize {
|
|
if self.frames == 0 {
|
|
0
|
|
} else {
|
|
(self.total_bytes / u128::from(self.frames)).min(usize::MAX as u128) as usize
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MjpegEncoder {
|
|
fn new(args: &Args) -> Result<Self> {
|
|
gst::init().context("gst init")?;
|
|
let width = args.width as i32;
|
|
let height = args.height as i32;
|
|
let fps = args.fps as i32;
|
|
let raw_caps = gst::Caps::builder("video/x-raw")
|
|
.field("format", "RGB")
|
|
.field("width", width)
|
|
.field("height", height)
|
|
.field("framerate", gst::Fraction::new(fps, 1))
|
|
.build();
|
|
let jpeg_caps = gst::Caps::builder("image/jpeg")
|
|
.field("parsed", true)
|
|
.field("width", width)
|
|
.field("height", height)
|
|
.field("framerate", gst::Fraction::new(fps, 1))
|
|
.build();
|
|
let pipeline = gst::Pipeline::new();
|
|
let src = gst::ElementFactory::make("appsrc")
|
|
.name("lesavka_synthetic_uplink_src")
|
|
.build()?
|
|
.downcast::<gst_app::AppSrc>()
|
|
.expect("appsrc");
|
|
src.set_is_live(false);
|
|
src.set_format(gst::Format::Time);
|
|
src.set_property("do-timestamp", false);
|
|
src.set_caps(Some(&raw_caps));
|
|
let convert = gst::ElementFactory::make("videoconvert").build()?;
|
|
let encoder = gst::ElementFactory::make("jpegenc")
|
|
.property("quality", args.jpeg_quality)
|
|
.build()?;
|
|
let capsfilter = gst::ElementFactory::make("capsfilter")
|
|
.property("caps", &jpeg_caps)
|
|
.build()?;
|
|
let sink = gst::ElementFactory::make("appsink")
|
|
.name("lesavka_synthetic_uplink_sink")
|
|
.property("sync", false)
|
|
.property("emit-signals", false)
|
|
.property("max-buffers", 8u32)
|
|
.build()?
|
|
.downcast::<gst_app::AppSink>()
|
|
.expect("appsink");
|
|
pipeline.add_many([
|
|
src.upcast_ref(),
|
|
&convert,
|
|
&encoder,
|
|
&capsfilter,
|
|
sink.upcast_ref(),
|
|
])?;
|
|
gst::Element::link_many([
|
|
src.upcast_ref(),
|
|
&convert,
|
|
&encoder,
|
|
&capsfilter,
|
|
sink.upcast_ref(),
|
|
])?;
|
|
pipeline
|
|
.set_state(gst::State::Playing)
|
|
.context("starting synthetic MJPEG encoder")?;
|
|
Ok(Self {
|
|
src,
|
|
sink,
|
|
pipeline,
|
|
width: args.width,
|
|
height: args.height,
|
|
frame_step_us: args.frame_step_us(),
|
|
})
|
|
}
|
|
|
|
fn encode(&mut self, sequence: u64) -> Result<Vec<u8>> {
|
|
let pts_us = sequence.saturating_mul(self.frame_step_us);
|
|
let mut buffer =
|
|
gst::Buffer::from_slice(synthetic_rgb_frame(self.width, self.height, sequence));
|
|
if let Some(meta) = buffer.get_mut() {
|
|
let pts = gst::ClockTime::from_useconds(pts_us);
|
|
meta.set_pts(Some(pts));
|
|
meta.set_dts(Some(pts));
|
|
meta.set_duration(Some(gst::ClockTime::from_useconds(self.frame_step_us)));
|
|
}
|
|
self.src
|
|
.push_buffer(buffer)
|
|
.context("encoding synthetic frame")?;
|
|
let sample = self
|
|
.sink
|
|
.pull_sample()
|
|
.context("pulling encoded synthetic frame")?;
|
|
let buffer = sample
|
|
.buffer()
|
|
.context("encoded synthetic frame had no buffer")?;
|
|
let map = buffer
|
|
.map_readable()
|
|
.context("mapping encoded synthetic frame")?;
|
|
Ok(map.as_slice().to_vec())
|
|
}
|
|
}
|
|
|
|
impl Drop for MjpegEncoder {
|
|
fn drop(&mut self) {
|
|
let _ = self.src.end_of_stream();
|
|
let _ = self.pipeline.set_state(gst::State::Null);
|
|
}
|
|
}
|
|
|
|
async fn connect_channel(args: &Args) -> Result<Channel> {
|
|
let mut endpoint =
|
|
tonic::transport::Channel::from_shared(args.server.clone())?.tcp_nodelay(true);
|
|
if is_https(&args.server) {
|
|
endpoint = endpoint
|
|
.tls_config(client_tls_config(args)?)
|
|
.context("configuring synthetic uplink TLS")?;
|
|
}
|
|
endpoint
|
|
.connect()
|
|
.await
|
|
.with_context(|| format!("connecting to {}", args.server))
|
|
}
|
|
|
|
fn client_tls_config(args: &Args) -> Result<ClientTlsConfig> {
|
|
let mut tls = ClientTlsConfig::new().domain_name(
|
|
args.tls_domain
|
|
.clone()
|
|
.or_else(|| host_from_uri(&args.server))
|
|
.unwrap_or_else(|| "lesavka-server".to_string()),
|
|
);
|
|
let ca_path = args
|
|
.tls_ca
|
|
.as_ref()
|
|
.context("https synthetic uplink requires --tls-ca or LESAVKA_TLS_CA")?;
|
|
let cert_path = args
|
|
.tls_client_cert
|
|
.as_ref()
|
|
.context("https synthetic uplink requires --tls-client-cert or LESAVKA_TLS_CLIENT_CERT")?;
|
|
let key_path = args
|
|
.tls_client_key
|
|
.as_ref()
|
|
.context("https synthetic uplink requires --tls-client-key or LESAVKA_TLS_CLIENT_KEY")?;
|
|
let ca = std::fs::read(ca_path)
|
|
.with_context(|| format!("reading TLS CA certificate {}", ca_path.display()))?;
|
|
tls = tls.ca_certificate(Certificate::from_pem(ca));
|
|
let cert = std::fs::read(cert_path)
|
|
.with_context(|| format!("reading TLS client certificate {}", cert_path.display()))?;
|
|
let key = std::fs::read(key_path)
|
|
.with_context(|| format!("reading TLS client key {}", key_path.display()))?;
|
|
Ok(tls.identity(Identity::from_pem(cert, key)))
|
|
}
|
|
|
|
fn is_https(server: &str) -> bool {
|
|
server.trim_start().starts_with("https://")
|
|
}
|
|
|
|
fn host_from_uri(server: &str) -> Option<String> {
|
|
let rest = server.split_once("://")?.1;
|
|
let host_port = rest.split('/').next().unwrap_or(rest);
|
|
let host = host_port
|
|
.rsplit_once('@')
|
|
.map(|(_, host)| host)
|
|
.unwrap_or(host_port);
|
|
if host.starts_with('[') {
|
|
return host
|
|
.split_once(']')
|
|
.map(|(value, _)| value.trim_start_matches('[').to_string());
|
|
}
|
|
Some(host.split(':').next().unwrap_or(host).to_string()).filter(|host| !host.is_empty())
|
|
}
|
|
|
|
fn env_path(name: &str) -> Option<PathBuf> {
|
|
std::env::var_os(name)
|
|
.filter(|value| !value.is_empty())
|
|
.map(PathBuf::from)
|
|
}
|
|
|
|
fn default_pki_path(file_name: &str) -> Option<PathBuf> {
|
|
let home = std::env::var_os("HOME")?;
|
|
Some(
|
|
PathBuf::from(home)
|
|
.join(".config")
|
|
.join("lesavka")
|
|
.join("pki")
|
|
.join(file_name),
|
|
)
|
|
}
|
|
|
|
#[tokio::main(flavor = "current_thread")]
|
|
async fn main() -> Result<()> {
|
|
let args = Args::parse()?;
|
|
if let Some(dir) = &args.artifact_dir {
|
|
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
|
std::fs::write(
|
|
dir.join("command.txt"),
|
|
std::env::args().collect::<Vec<_>>().join(" ") + "\n",
|
|
)?;
|
|
write_summary(&args, None)?;
|
|
}
|
|
|
|
let channel = connect_channel(&args).await?;
|
|
let mut client = RelayClient::new(channel);
|
|
let (tx, rx) = mpsc::channel::<UpstreamMediaBundle>(8);
|
|
let response_task = tokio::spawn(async move {
|
|
let response = client
|
|
.stream_webcam_media(Request::new(ReceiverStream::new(rx)))
|
|
.await
|
|
.context("opening StreamWebcamMedia")?;
|
|
let mut inbound = response.into_inner();
|
|
while inbound
|
|
.message()
|
|
.await
|
|
.context("reading StreamWebcamMedia response")?
|
|
.is_some()
|
|
{}
|
|
Ok::<(), anyhow::Error>(())
|
|
});
|
|
|
|
let mut encoder = MjpegEncoder::new(&args)?;
|
|
let mut encode_stats = EncodeStats::default();
|
|
let frame_step = Duration::from_micros(args.frame_step_us());
|
|
let started = tokio::time::Instant::now() + Duration::from_millis(250);
|
|
let total_frames = args.total_frames();
|
|
eprintln!(
|
|
"lesavka synthetic uplink: mode={}x{}@{} frames={} server={} session={}",
|
|
args.width, args.height, args.fps, total_frames, args.server, args.session_id
|
|
);
|
|
for sequence in 0..total_frames {
|
|
tokio::time::sleep_until(started + duration_mul(frame_step, sequence)).await;
|
|
let pts_us = sequence.saturating_mul(args.frame_step_us());
|
|
let encoded = encoder.encode(sequence)?;
|
|
encode_stats.record(encoded.len(), args.max_frame_bytes);
|
|
if args.max_frame_bytes > 0 && encoded.len() > args.max_frame_bytes {
|
|
write_summary(&args, Some(&encode_stats))?;
|
|
bail!(
|
|
"encoded synthetic frame {sequence} is {} bytes, above --max-frame-bytes {}; lower --jpeg-quality or use a more compressible synthetic pattern before trusting the RCT probe",
|
|
encoded.len(),
|
|
args.max_frame_bytes
|
|
);
|
|
}
|
|
let bundle = synthetic_bundle(&args, sequence, pts_us, encoded);
|
|
if tx.send(bundle).await.is_err() {
|
|
let response_result = response_task
|
|
.await
|
|
.context("joining StreamWebcamMedia task after early close")?;
|
|
match response_result {
|
|
Ok(()) => bail!(
|
|
"StreamWebcamMedia closed before accepting synthetic frame {sequence}; disconnect or pause any live Lesavka client upstream before running the isolated RCT probe"
|
|
),
|
|
Err(err) => {
|
|
return Err(err)
|
|
.context("StreamWebcamMedia closed before accepting synthetic frame");
|
|
}
|
|
}
|
|
}
|
|
if args.print_every > 0 && sequence > 0 && sequence % args.print_every == 0 {
|
|
eprintln!("sent synthetic frame {sequence}/{total_frames}");
|
|
}
|
|
}
|
|
drop(tx);
|
|
response_task
|
|
.await
|
|
.context("joining StreamWebcamMedia task")??;
|
|
write_summary(&args, Some(&encode_stats))?;
|
|
eprintln!("lesavka synthetic uplink complete: frames={total_frames}");
|
|
Ok(())
|
|
}
|
|
|
|
fn synthetic_bundle(args: &Args, sequence: u64, pts_us: u64, data: Vec<u8>) -> UpstreamMediaBundle {
|
|
let video = VideoPacket {
|
|
id: 0,
|
|
pts: pts_us,
|
|
data,
|
|
seq: sequence,
|
|
effective_fps: args.fps,
|
|
client_capture_pts_us: pts_us,
|
|
client_send_pts_us: pts_us,
|
|
..Default::default()
|
|
};
|
|
let audio = AudioPacket {
|
|
id: 0,
|
|
pts: pts_us,
|
|
data: silence_pcm(args.frame_step_us()),
|
|
seq: sequence,
|
|
client_capture_pts_us: pts_us,
|
|
client_send_pts_us: pts_us,
|
|
encoding: AudioEncoding::PcmS16le as i32,
|
|
sample_rate: DEFAULT_SAMPLE_RATE,
|
|
channels: DEFAULT_CHANNELS,
|
|
frame_duration_us: args.frame_step_us().min(u64::from(u32::MAX)) as u32,
|
|
..Default::default()
|
|
};
|
|
UpstreamMediaBundle {
|
|
session_id: args.session_id,
|
|
seq: sequence,
|
|
capture_start_us: pts_us,
|
|
capture_end_us: pts_us,
|
|
video: Some(video),
|
|
audio: vec![audio],
|
|
audio_sample_rate: DEFAULT_SAMPLE_RATE,
|
|
audio_channels: DEFAULT_CHANNELS,
|
|
video_width: args.width as u32,
|
|
video_height: args.height as u32,
|
|
video_fps: args.fps,
|
|
audio_encoding: AudioEncoding::PcmS16le as i32,
|
|
}
|
|
}
|
|
|
|
fn silence_pcm(duration_us: u64) -> Vec<u8> {
|
|
let samples = (u64::from(DEFAULT_SAMPLE_RATE).saturating_mul(duration_us) / 1_000_000).max(1);
|
|
let bytes = samples
|
|
.saturating_mul(u64::from(DEFAULT_CHANNELS))
|
|
.saturating_mul(std::mem::size_of::<i16>() as u64)
|
|
.min(usize::MAX as u64) as usize;
|
|
vec![0; bytes]
|
|
}
|
|
|
|
fn synthetic_rgb_frame(width: usize, height: usize, sequence: u64) -> Vec<u8> {
|
|
let mut frame = vec![0u8; width.saturating_mul(height).saturating_mul(3)];
|
|
let moving_width = (width / 10).max(32).min(width.max(1));
|
|
let moving_offset = (sequence as usize).wrapping_mul(13) % width.max(1);
|
|
for y in 0..height {
|
|
for x in 0..width {
|
|
let value = synthetic_luma(
|
|
(width, height),
|
|
x,
|
|
y,
|
|
sequence,
|
|
(moving_width, moving_offset),
|
|
);
|
|
let offset = (y * width + x) * 3;
|
|
frame[offset] = value;
|
|
frame[offset + 1] = value;
|
|
frame[offset + 2] = value;
|
|
}
|
|
}
|
|
draw_sequence_marker(&mut frame, width, height, sequence);
|
|
frame
|
|
}
|
|
|
|
fn synthetic_luma(
|
|
frame_size: (usize, usize),
|
|
x: usize,
|
|
y: usize,
|
|
sequence: u64,
|
|
moving_bar: (usize, usize),
|
|
) -> u8 {
|
|
let (width, height) = frame_size;
|
|
let (moving_width, moving_offset) = moving_bar;
|
|
let width = width.max(1);
|
|
let height = height.max(1);
|
|
let block_w = (width / 24).max(24);
|
|
let block_h = (height / 18).max(18);
|
|
let base = 44
|
|
+ (x.saturating_mul(72) / width)
|
|
+ (y.saturating_mul(52) / height)
|
|
+ ((sequence as usize).saturating_mul(3) % 28);
|
|
let checker = if (((x / block_w) + (y / block_h) + (sequence as usize / 5)) & 1) == 0 {
|
|
30
|
|
} else {
|
|
0
|
|
};
|
|
let mut value = (base + checker).min(238) as u8;
|
|
let moving = (x + width - moving_offset) % width;
|
|
if moving < moving_width {
|
|
value = (220usize.saturating_sub(y.saturating_mul(54) / height)).min(255) as u8;
|
|
} else if moving < moving_width + 4 {
|
|
value = 28;
|
|
}
|
|
let center_x = width / 2;
|
|
let center_y = height / 2;
|
|
if x.abs_diff(center_x) < width / 9 && y.abs_diff(center_y) < height / 12 {
|
|
value = 255u8.saturating_sub(value / 2);
|
|
}
|
|
value
|
|
}
|
|
|
|
fn marker_cell(width: usize, height: usize) -> usize {
|
|
(width.min(height) / 80).clamp(6, 16)
|
|
}
|
|
|
|
fn draw_sequence_marker(frame: &mut [u8], width: usize, height: usize, sequence: u64) {
|
|
let cell = marker_cell(width, height);
|
|
let rows = MARKER_BITS.div_ceil(MARKER_COLUMNS);
|
|
if width < (MARKER_COLUMNS + 4) * cell || height < (rows + 4) * cell {
|
|
return;
|
|
}
|
|
let x0 = 2 * cell;
|
|
let y0 = 2 * cell;
|
|
fill_rect(
|
|
frame,
|
|
width,
|
|
cell,
|
|
cell,
|
|
(MARKER_COLUMNS + 2) * cell,
|
|
(rows + 2) * cell,
|
|
32,
|
|
);
|
|
fill_rect(frame, width, x0 - cell, y0 - cell, cell, cell, 255);
|
|
fill_rect(
|
|
frame,
|
|
width,
|
|
x0 + MARKER_COLUMNS * cell,
|
|
y0 - cell,
|
|
cell,
|
|
cell,
|
|
0,
|
|
);
|
|
for bit in 0..MARKER_BITS {
|
|
let col = bit % MARKER_COLUMNS;
|
|
let row = bit / MARKER_COLUMNS;
|
|
let value = if ((sequence >> bit) & 1) != 0 { 255 } else { 0 };
|
|
fill_rect(
|
|
frame,
|
|
width,
|
|
x0 + col * cell,
|
|
y0 + row * cell,
|
|
cell,
|
|
cell,
|
|
value,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn fill_rect(frame: &mut [u8], width: usize, x0: usize, y0: usize, w: usize, h: usize, value: u8) {
|
|
let pixels = frame.len() / 3;
|
|
let height = pixels / width.max(1);
|
|
let x1 = (x0 + w).min(width);
|
|
let y1 = (y0 + h).min(height);
|
|
for y in y0..y1 {
|
|
for x in x0..x1 {
|
|
let offset = (y * width + x) * 3;
|
|
if let Some(pixel) = frame.get_mut(offset..offset + 3) {
|
|
pixel[0] = value;
|
|
pixel[1] = value;
|
|
pixel[2] = value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_mode(value: &str) -> Result<(usize, usize, u32)> {
|
|
let (size, fps) = value
|
|
.split_once('@')
|
|
.with_context(|| format!("mode must look like WIDTHxHEIGHT@FPS, got {value:?}"))?;
|
|
let (width, height) = size
|
|
.split_once('x')
|
|
.with_context(|| format!("mode must look like WIDTHxHEIGHT@FPS, got {value:?}"))?;
|
|
Ok((width.parse()?, height.parse()?, fps.parse()?))
|
|
}
|
|
|
|
fn next_value(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
|
|
it.next()
|
|
.with_context(|| format!("{flag} requires a value"))
|
|
}
|
|
|
|
fn parse_next<T>(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<T>
|
|
where
|
|
T: std::str::FromStr,
|
|
T::Err: std::error::Error + Send + Sync + 'static,
|
|
{
|
|
Ok(next_value(it, flag)?.parse()?)
|
|
}
|
|
|
|
fn env_usize(name: &str) -> Option<usize> {
|
|
std::env::var(name)
|
|
.ok()
|
|
.and_then(|value| value.trim().parse::<usize>().ok())
|
|
}
|
|
|
|
fn duration_mul(duration: Duration, count: u64) -> Duration {
|
|
Duration::from_nanos(
|
|
duration
|
|
.as_nanos()
|
|
.saturating_mul(u128::from(count))
|
|
.min(u128::from(u64::MAX)) as u64,
|
|
)
|
|
}
|
|
|
|
fn unix_millis() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis()
|
|
.min(u128::from(u64::MAX)) as u64
|
|
}
|
|
|
|
fn write_summary(args: &Args, stats: Option<&EncodeStats>) -> Result<()> {
|
|
if let Some(dir) = &args.artifact_dir {
|
|
std::fs::write(
|
|
dir.join("summary.json"),
|
|
args_summary_json(args, stats) + "\n",
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn json_usize_or_null(value: Option<usize>) -> String {
|
|
value
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "null".to_string())
|
|
}
|
|
|
|
fn args_summary_json(args: &Args, stats: Option<&EncodeStats>) -> String {
|
|
let frames = stats.map(|stats| stats.frames).unwrap_or(0);
|
|
let min_bytes =
|
|
json_usize_or_null(stats.and_then(|stats| (stats.frames > 0).then_some(stats.min_bytes)));
|
|
let max_bytes =
|
|
json_usize_or_null(stats.and_then(|stats| (stats.frames > 0).then_some(stats.max_bytes)));
|
|
let mean_bytes = json_usize_or_null(
|
|
stats.and_then(|stats| (stats.frames > 0).then_some(stats.mean_bytes())),
|
|
);
|
|
let oversize_frames = stats.map(|stats| stats.oversize_frames).unwrap_or(0);
|
|
format!(
|
|
"{{\"schema\":\"lesavka.synthetic-uplink.v1\",\"server\":{server:?},\"width\":{width},\"height\":{height},\"fps\":{fps},\"duration_s\":{duration:.3},\"session_id\":{session},\"tls\":{tls},\"jpeg_quality\":{quality},\"max_frame_bytes\":{max_frame_bytes},\"encoded_frames\":{frames},\"encoded_min_bytes\":{min_bytes},\"encoded_max_bytes\":{max_bytes},\"encoded_mean_bytes\":{mean_bytes},\"encoded_oversize_frames\":{oversize_frames}}}",
|
|
server = args.server,
|
|
width = args.width,
|
|
height = args.height,
|
|
fps = args.fps,
|
|
duration = args.duration.as_secs_f64(),
|
|
session = args.session_id,
|
|
tls = is_https(&args.server),
|
|
quality = args.jpeg_quality,
|
|
max_frame_bytes = args.max_frame_bytes,
|
|
frames = frames,
|
|
min_bytes = min_bytes,
|
|
max_bytes = max_bytes,
|
|
mean_bytes = mean_bytes,
|
|
oversize_frames = oversize_frames,
|
|
)
|
|
}
|
|
|
|
fn print_help() {
|
|
println!(
|
|
"lesavka-synthetic-uplink\n\n\
|
|
Sends sequence-coded synthetic MJPEG plus silent PCM through StreamWebcamMedia.\n\n\
|
|
Options:\n\
|
|
--server URL gRPC endpoint, default {DEFAULT_SERVER}\n\
|
|
--mode WIDTHxHEIGHT@FPS shorthand for width/height/fps\n\
|
|
--width N --height N --fps N\n\
|
|
--duration SECONDS default 300\n\
|
|
--jpeg-quality N default {DEFAULT_JPEG_QUALITY}\n\
|
|
--max-frame-bytes N fail fast if an encoded frame exceeds N bytes\n\
|
|
--artifact-dir PATH write command/summary metadata\n\
|
|
--print-every N progress interval in frames"
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_args(width: usize, height: usize, fps: u32) -> Args {
|
|
Args {
|
|
server: DEFAULT_SERVER.to_string(),
|
|
width,
|
|
height,
|
|
fps,
|
|
duration: Duration::from_secs(1),
|
|
jpeg_quality: DEFAULT_JPEG_QUALITY,
|
|
session_id: 1,
|
|
artifact_dir: None,
|
|
print_every: 0,
|
|
max_frame_bytes: 232_106,
|
|
tls_ca: None,
|
|
tls_client_cert: None,
|
|
tls_client_key: None,
|
|
tls_domain: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn synthetic_frames_fit_safe_720p_and_1080p_isochronous_budget() {
|
|
for (width, height, fps) in [(1280, 720, 30), (1920, 1080, 30)] {
|
|
let args = test_args(width, height, fps);
|
|
let mut encoder = MjpegEncoder::new(&args).expect("synthetic encoder");
|
|
for sequence in [0, 1, 30, 120, 300] {
|
|
let encoded = encoder.encode(sequence).expect("encode frame");
|
|
assert!(
|
|
encoded.len() <= args.max_frame_bytes,
|
|
"{}x{}@{} synthetic frame {sequence} encoded to {} bytes, above {}",
|
|
width,
|
|
height,
|
|
fps,
|
|
encoded.len(),
|
|
args.max_frame_bytes
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|