probe: keep synthetic rct media within uvc budget
This commit is contained in:
parent
a79e98ec58
commit
6cbe78e576
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_client"
|
||||
version = "0.22.50"
|
||||
version = "0.22.51"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@ -1686,7 +1686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_common"
|
||||
version = "0.22.50"
|
||||
version = "0.22.51"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@ -1698,7 +1698,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lesavka_server"
|
||||
version = "0.22.50"
|
||||
version = "0.22.51"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.22.50"
|
||||
version = "0.22.51"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.22.50"
|
||||
version = "0.22.51"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -17,6 +17,10 @@ from typing import Any
|
||||
|
||||
DEFAULT_DEVICE_LABEL = "Lesavka Composite"
|
||||
DEFAULT_MODES = "1280x720@20,1280x720@30,1920x1080@20,1920x1080@30"
|
||||
DEFAULT_JPEG_QUALITY = 82
|
||||
HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC = 8000
|
||||
DEFAULT_ISOCHRONOUS_LIMIT_PCT = 85
|
||||
DEFAULT_UVC_MAX_PACKET = 1024
|
||||
MARKER_BITS = 32
|
||||
MARKER_COLUMNS = 16
|
||||
|
||||
@ -52,6 +56,14 @@ def parse_args() -> argparse.Namespace:
|
||||
help="start RCT capture before synthetic uplink; default starts uplink first so superseded injectors fail fast",
|
||||
)
|
||||
parser.add_argument("--inject-warmup-s", type=float, default=1.25)
|
||||
parser.add_argument("--capture-finish-grace-s", type=float, default=5.0)
|
||||
parser.add_argument("--jpeg-quality", type=int, default=DEFAULT_JPEG_QUALITY)
|
||||
parser.add_argument(
|
||||
"--inject-max-frame-bytes",
|
||||
type=int,
|
||||
default=0,
|
||||
help="max encoded synthetic MJPEG bytes; default uses the safe high-speed isochronous budget for the selected fps",
|
||||
)
|
||||
parser.add_argument("--x-step", type=int, default=8)
|
||||
parser.add_argument("--y-step", type=int, default=4)
|
||||
parser.add_argument("--bands", type=int, default=24)
|
||||
@ -96,6 +108,16 @@ def mode_dimensions(args: argparse.Namespace) -> tuple[int, int, int]:
|
||||
return width, height, fps
|
||||
|
||||
|
||||
def default_inject_max_frame_bytes(fps: int) -> int:
|
||||
bytes_per_second = (
|
||||
DEFAULT_UVC_MAX_PACKET
|
||||
* HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC
|
||||
* DEFAULT_ISOCHRONOUS_LIMIT_PCT
|
||||
// 100
|
||||
)
|
||||
return max(64 * 1024, bytes_per_second // max(1, fps))
|
||||
|
||||
|
||||
def default_artifact_dir(mode: str) -> pathlib.Path:
|
||||
safe_mode = mode.replace("@", "-").replace("x", "x")
|
||||
return pathlib.Path("artifacts/synthetic-rct") / f"{safe_mode}-{timestamp()}"
|
||||
@ -107,6 +129,7 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
if not shutil.which("ssh") or not shutil.which("scp"):
|
||||
raise SystemExit("ssh and scp are required for the remote synthetic probe")
|
||||
width, height, fps = mode_dimensions(args)
|
||||
inject_max_frame_bytes = args.inject_max_frame_bytes or default_inject_max_frame_bytes(fps)
|
||||
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir(args.mode)
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{timestamp()}"
|
||||
@ -183,6 +206,10 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
str(args.duration + 2.0),
|
||||
"--artifact-dir",
|
||||
remote_inject_dir,
|
||||
"--jpeg-quality",
|
||||
str(args.jpeg_quality),
|
||||
"--max-frame-bytes",
|
||||
str(inject_max_frame_bytes),
|
||||
"--print-every",
|
||||
str(args.progress_every),
|
||||
]
|
||||
@ -197,6 +224,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
"fps": fps,
|
||||
"source": args.source,
|
||||
"duration_s": args.duration,
|
||||
"jpeg_quality": args.jpeg_quality,
|
||||
"inject_max_frame_bytes": inject_max_frame_bytes,
|
||||
"inject_host": args.inject_host,
|
||||
"rct_host": args.rct_host,
|
||||
},
|
||||
@ -230,9 +259,20 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
return capture_status, inject_process.wait()
|
||||
inject_status = inject_process.poll()
|
||||
if inject_status is not None:
|
||||
diagnosis.append(
|
||||
"synthetic uplink exited while RCT capture was still active; stopping capture because the run is not isolated"
|
||||
)
|
||||
if inject_status == 0:
|
||||
deadline = time.monotonic() + max(0.0, args.capture_finish_grace_s)
|
||||
while time.monotonic() < deadline:
|
||||
capture_status = capture_process.poll()
|
||||
if capture_status is not None:
|
||||
return capture_status, inject_status
|
||||
time.sleep(0.25)
|
||||
diagnosis.append(
|
||||
"synthetic uplink completed but RCT capture did not finish; capture likely lagged, froze, or was blocked by another consumer"
|
||||
)
|
||||
else:
|
||||
diagnosis.append(
|
||||
"synthetic uplink exited while RCT capture was still active; stopping capture because the run is not isolated or the injector failed"
|
||||
)
|
||||
print(
|
||||
f"synthetic uplink exited during capture rc={inject_status}; stopping RCT capture",
|
||||
file=sys.stderr,
|
||||
@ -274,6 +314,32 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
||||
diagnosis.append(
|
||||
"captured frames did not consistently contain synthetic markers and the injector failed; the RCT capture likely measured a mixed, previous, or live webcam stream"
|
||||
)
|
||||
fps_observed = float(capture_data.get("fps_observed") or 0.0)
|
||||
fps_requested = float(capture_data.get("fps_requested") or fps)
|
||||
if fps_observed and fps_observed < fps_requested * 0.5:
|
||||
diagnosis.append(
|
||||
f"RCT capture decoded only {fps_observed:.3f} fps from a {fps_requested:.0f} fps mode; check for a frozen UVC device or another browser/process holding the camera"
|
||||
)
|
||||
frames = int(capture_data.get("frames") or 0)
|
||||
reason_counts = capture_data.get("reason_counts") or {}
|
||||
repeats = int(reason_counts.get("frame_repeat") or 0)
|
||||
if frames > 0 and repeats >= max(3, int(frames * 0.9)):
|
||||
diagnosis.append(
|
||||
"RCT capture repeated nearly every decoded synthetic marker; the received UVC stream was stale/frozen instead of advancing"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
inject_summary = local_inject / "summary.json"
|
||||
if inject_summary.exists():
|
||||
try:
|
||||
inject_data = json.loads(inject_summary.read_text())
|
||||
oversize_frames = int(inject_data.get("encoded_oversize_frames") or 0)
|
||||
max_bytes = inject_data.get("encoded_max_bytes")
|
||||
max_frame_bytes = inject_data.get("max_frame_bytes")
|
||||
if oversize_frames:
|
||||
diagnosis.append(
|
||||
f"synthetic injector produced {oversize_frames} over-budget MJPEG frame(s), max={max_bytes} cap={max_frame_bytes}; the server will freeze instead of spooling those frames"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
summary = {
|
||||
@ -397,21 +463,27 @@ def fill_rect(frame: bytearray, width: int, height: int, x0: int, y0: int, w: in
|
||||
|
||||
def synthetic_gray(width: int, height: int, sequence: int) -> bytes:
|
||||
data = bytearray(width * height)
|
||||
moving_period = max(width // 3, 64)
|
||||
moving_width = max(width // 18, 12)
|
||||
moving_offset = (sequence * 17) % moving_period
|
||||
safe_width = max(width, 1)
|
||||
safe_height = max(height, 1)
|
||||
moving_width = min(max(width // 10, 32), safe_width)
|
||||
moving_offset = (sequence * 13) % safe_width
|
||||
center_x = width // 2
|
||||
center_y = height // 2
|
||||
block_w = max(width // 24, 24)
|
||||
block_h = max(height // 18, 18)
|
||||
for y in range(height):
|
||||
row = y * width
|
||||
for x in range(width):
|
||||
value = (x * 3 + y * 5 + sequence * 11) & 0xFF
|
||||
if (x + moving_offset) % moving_period < moving_width:
|
||||
value = min(255, value + 70)
|
||||
base = 44 + (x * 72 // safe_width) + (y * 52 // safe_height) + ((sequence * 3) % 28)
|
||||
checker = 30 if (((x // block_w) + (y // block_h) + (sequence // 5)) & 1) == 0 else 0
|
||||
value = min(238, base + checker)
|
||||
moving = (x + safe_width - moving_offset) % safe_width
|
||||
if moving < moving_width:
|
||||
value = min(255, 220 - (y * 54 // safe_height))
|
||||
elif moving < moving_width + 4:
|
||||
value = 28
|
||||
if abs(x - center_x) < width // 9 and abs(y - center_y) < height // 12:
|
||||
value = 255 - value // 2
|
||||
if y >= height // 2 and (((x // 32) + (y // 24) + sequence) & 1) == 0:
|
||||
value //= 3
|
||||
data[row + x] = value
|
||||
draw_marker(data, width, height, sequence)
|
||||
return bytes(data)
|
||||
@ -623,6 +695,7 @@ def run_capture(args: argparse.Namespace) -> int:
|
||||
previous_seq: int | None = None
|
||||
decoded_frames = 0
|
||||
reason_counts: collections.Counter[str] = collections.Counter()
|
||||
sequence_counts: collections.Counter[int] = collections.Counter()
|
||||
max_total_mae = max_upper_mae = max_lower_mae = 0.0
|
||||
worst: list[dict[str, Any]] = []
|
||||
with stderr_path.open("wb") as err, metrics_path.open("w") as metrics:
|
||||
@ -638,6 +711,7 @@ def run_capture(args: argparse.Namespace) -> int:
|
||||
decoded_seq = result["decoded_sequence"]
|
||||
if decoded_seq is not None:
|
||||
decoded_frames += 1
|
||||
sequence_counts[int(decoded_seq)] += 1
|
||||
previous_seq = int(decoded_seq)
|
||||
result.update({"frame": frame_index, "elapsed_s": round(time.monotonic() - started, 3)})
|
||||
max_total_mae = max(max_total_mae, float(result["total_mae"]))
|
||||
@ -690,6 +764,7 @@ def run_capture(args: argparse.Namespace) -> int:
|
||||
"suspicious_frames": suspicious_count,
|
||||
"suspicious_pct": round(suspicious_count / frame_index * 100.0, 3) if frame_index else 0.0,
|
||||
"reason_counts": dict(reason_counts),
|
||||
"decoded_sequence_counts": dict(sequence_counts.most_common(12)),
|
||||
"max_total_mae": round(max_total_mae, 3),
|
||||
"max_upper_mae": round(max_upper_mae, 3),
|
||||
"max_lower_mae": round(max_lower_mae, 3),
|
||||
@ -729,7 +804,7 @@ def run_self_test(args: argparse.Namespace) -> int:
|
||||
height = 180
|
||||
frames = [synthetic_gray(width, height, idx) for idx in range(6)]
|
||||
corrupt = bytearray(synthetic_gray(width, height, 6))
|
||||
fill_rect(corrupt, width, height, 0, height // 2, width, height // 4, 128)
|
||||
fill_rect(corrupt, width, height, 0, height // 2, width, height // 4, 0)
|
||||
frames.append(bytes(corrupt))
|
||||
shifted = bytearray(width * height)
|
||||
expected = synthetic_gray(width, height, 7)
|
||||
|
||||
@ -16,7 +16,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.22.50"
|
||||
version = "0.22.51"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ 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 = 90;
|
||||
const DEFAULT_JPEG_QUALITY: i32 = 82;
|
||||
const MARKER_BITS: usize = 32;
|
||||
const MARKER_COLUMNS: usize = 16;
|
||||
|
||||
@ -32,6 +32,7 @@ struct Args {
|
||||
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>,
|
||||
@ -50,6 +51,7 @@ impl Args {
|
||||
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")),
|
||||
@ -76,6 +78,7 @@ impl Args {
|
||||
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)?))
|
||||
@ -124,6 +127,39 @@ struct MjpegEncoder {
|
||||
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")?;
|
||||
@ -316,7 +352,7 @@ async fn main() -> Result<()> {
|
||||
dir.join("command.txt"),
|
||||
std::env::args().collect::<Vec<_>>().join(" ") + "\n",
|
||||
)?;
|
||||
std::fs::write(dir.join("summary.json"), args_summary_json(&args) + "\n")?;
|
||||
write_summary(&args, None)?;
|
||||
}
|
||||
|
||||
let channel = connect_channel(&args).await?;
|
||||
@ -338,6 +374,7 @@ async fn main() -> Result<()> {
|
||||
});
|
||||
|
||||
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();
|
||||
@ -349,6 +386,15 @@ async fn main() -> Result<()> {
|
||||
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
|
||||
@ -372,6 +418,7 @@ async fn main() -> Result<()> {
|
||||
response_task
|
||||
.await
|
||||
.context("joining StreamWebcamMedia task")??;
|
||||
write_summary(&args, Some(&encode_stats))?;
|
||||
eprintln!("lesavka synthetic uplink complete: frames={total_frames}");
|
||||
Ok(())
|
||||
}
|
||||
@ -427,21 +474,17 @@ fn silence_pcm(duration_us: u64) -> Vec<u8> {
|
||||
|
||||
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_period = (width / 3).max(64);
|
||||
let moving_width = (width / 18).max(12);
|
||||
let moving_offset = (sequence as usize).wrapping_mul(17) % moving_period;
|
||||
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 mut value = synthetic_luma(
|
||||
let value = synthetic_luma(
|
||||
(width, height),
|
||||
x,
|
||||
y,
|
||||
sequence,
|
||||
(moving_period, moving_width, moving_offset),
|
||||
(moving_width, moving_offset),
|
||||
);
|
||||
if y >= height / 2 && (((x / 32) + (y / 24) + sequence as usize) & 1) == 0 {
|
||||
value /= 3;
|
||||
}
|
||||
let offset = (y * width + x) * 3;
|
||||
frame[offset] = value;
|
||||
frame[offset + 1] = value;
|
||||
@ -457,14 +500,29 @@ fn synthetic_luma(
|
||||
x: usize,
|
||||
y: usize,
|
||||
sequence: u64,
|
||||
moving_bar: (usize, usize, usize),
|
||||
moving_bar: (usize, usize),
|
||||
) -> u8 {
|
||||
let (width, height) = frame_size;
|
||||
let (moving_period, moving_width, moving_offset) = moving_bar;
|
||||
let mut value = ((x as u64 * 3 + y as u64 * 5 + sequence.saturating_mul(11)) & 0xff) as u8;
|
||||
let moving = (x + moving_offset) % moving_period;
|
||||
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 = value.saturating_add(70);
|
||||
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;
|
||||
@ -561,6 +619,12 @@ where
|
||||
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
|
||||
@ -578,9 +642,34 @@ fn unix_millis() -> u64 {
|
||||
.min(u128::from(u64::MAX)) as u64
|
||||
}
|
||||
|
||||
fn args_summary_json(args: &Args) -> String {
|
||||
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}}}",
|
||||
"{{\"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,
|
||||
@ -588,6 +677,13 @@ fn args_summary_json(args: &Args) -> String {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -601,7 +697,52 @@ fn print_help() {
|
||||
--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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,9 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
||||
"--capture-only",
|
||||
"--capture-before-inject",
|
||||
"--inject-warmup-s",
|
||||
"--capture-finish-grace-s",
|
||||
"--jpeg-quality",
|
||||
"--inject-max-frame-bytes",
|
||||
"--source",
|
||||
"--mode",
|
||||
"1280x720@20,1280x720@30,1920x1080@20,1920x1080@30",
|
||||
@ -53,6 +56,9 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
||||
"suspicious_",
|
||||
"decoded_pct",
|
||||
"diagnosis",
|
||||
"encoded_oversize_frames",
|
||||
"decoded_sequence_counts",
|
||||
"synthetic uplink completed but RCT capture did not finish",
|
||||
"synthetic uplink exited before capture warmup completed",
|
||||
"max_lower_mae",
|
||||
"ffmpeg",
|
||||
@ -85,6 +91,8 @@ fn synthetic_injector_enters_the_public_bundled_media_rpc() {
|
||||
"--tls-ca",
|
||||
"--tls-client-cert",
|
||||
"--tls-client-key",
|
||||
"--max-frame-bytes",
|
||||
"encoded synthetic frame",
|
||||
"StreamWebcamMedia closed before accepting synthetic frame",
|
||||
] {
|
||||
assert!(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user