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]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.22.50"
|
version = "0.22.51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.22.50"
|
version = "0.22.51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.22.50"
|
version = "0.22.51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.22.50"
|
version = "0.22.51"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.22.50"
|
version = "0.22.51"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,10 @@ from typing import Any
|
|||||||
|
|
||||||
DEFAULT_DEVICE_LABEL = "Lesavka Composite"
|
DEFAULT_DEVICE_LABEL = "Lesavka Composite"
|
||||||
DEFAULT_MODES = "1280x720@20,1280x720@30,1920x1080@20,1920x1080@30"
|
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_BITS = 32
|
||||||
MARKER_COLUMNS = 16
|
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",
|
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("--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("--x-step", type=int, default=8)
|
||||||
parser.add_argument("--y-step", type=int, default=4)
|
parser.add_argument("--y-step", type=int, default=4)
|
||||||
parser.add_argument("--bands", type=int, default=24)
|
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
|
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:
|
def default_artifact_dir(mode: str) -> pathlib.Path:
|
||||||
safe_mode = mode.replace("@", "-").replace("x", "x")
|
safe_mode = mode.replace("@", "-").replace("x", "x")
|
||||||
return pathlib.Path("artifacts/synthetic-rct") / f"{safe_mode}-{timestamp()}"
|
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"):
|
if not shutil.which("ssh") or not shutil.which("scp"):
|
||||||
raise SystemExit("ssh and scp are required for the remote synthetic probe")
|
raise SystemExit("ssh and scp are required for the remote synthetic probe")
|
||||||
width, height, fps = mode_dimensions(args)
|
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 = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir(args.mode)
|
||||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||||
remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{timestamp()}"
|
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),
|
str(args.duration + 2.0),
|
||||||
"--artifact-dir",
|
"--artifact-dir",
|
||||||
remote_inject_dir,
|
remote_inject_dir,
|
||||||
|
"--jpeg-quality",
|
||||||
|
str(args.jpeg_quality),
|
||||||
|
"--max-frame-bytes",
|
||||||
|
str(inject_max_frame_bytes),
|
||||||
"--print-every",
|
"--print-every",
|
||||||
str(args.progress_every),
|
str(args.progress_every),
|
||||||
]
|
]
|
||||||
@ -197,6 +224,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
"fps": fps,
|
"fps": fps,
|
||||||
"source": args.source,
|
"source": args.source,
|
||||||
"duration_s": args.duration,
|
"duration_s": args.duration,
|
||||||
|
"jpeg_quality": args.jpeg_quality,
|
||||||
|
"inject_max_frame_bytes": inject_max_frame_bytes,
|
||||||
"inject_host": args.inject_host,
|
"inject_host": args.inject_host,
|
||||||
"rct_host": args.rct_host,
|
"rct_host": args.rct_host,
|
||||||
},
|
},
|
||||||
@ -230,9 +259,20 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
return capture_status, inject_process.wait()
|
return capture_status, inject_process.wait()
|
||||||
inject_status = inject_process.poll()
|
inject_status = inject_process.poll()
|
||||||
if inject_status is not None:
|
if inject_status is not None:
|
||||||
diagnosis.append(
|
if inject_status == 0:
|
||||||
"synthetic uplink exited while RCT capture was still active; stopping capture because the run is not isolated"
|
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(
|
print(
|
||||||
f"synthetic uplink exited during capture rc={inject_status}; stopping RCT capture",
|
f"synthetic uplink exited during capture rc={inject_status}; stopping RCT capture",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
@ -274,6 +314,32 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
|
|||||||
diagnosis.append(
|
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"
|
"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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
summary = {
|
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:
|
def synthetic_gray(width: int, height: int, sequence: int) -> bytes:
|
||||||
data = bytearray(width * height)
|
data = bytearray(width * height)
|
||||||
moving_period = max(width // 3, 64)
|
safe_width = max(width, 1)
|
||||||
moving_width = max(width // 18, 12)
|
safe_height = max(height, 1)
|
||||||
moving_offset = (sequence * 17) % moving_period
|
moving_width = min(max(width // 10, 32), safe_width)
|
||||||
|
moving_offset = (sequence * 13) % safe_width
|
||||||
center_x = width // 2
|
center_x = width // 2
|
||||||
center_y = height // 2
|
center_y = height // 2
|
||||||
|
block_w = max(width // 24, 24)
|
||||||
|
block_h = max(height // 18, 18)
|
||||||
for y in range(height):
|
for y in range(height):
|
||||||
row = y * width
|
row = y * width
|
||||||
for x in range(width):
|
for x in range(width):
|
||||||
value = (x * 3 + y * 5 + sequence * 11) & 0xFF
|
base = 44 + (x * 72 // safe_width) + (y * 52 // safe_height) + ((sequence * 3) % 28)
|
||||||
if (x + moving_offset) % moving_period < moving_width:
|
checker = 30 if (((x // block_w) + (y // block_h) + (sequence // 5)) & 1) == 0 else 0
|
||||||
value = min(255, value + 70)
|
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:
|
if abs(x - center_x) < width // 9 and abs(y - center_y) < height // 12:
|
||||||
value = 255 - value // 2
|
value = 255 - value // 2
|
||||||
if y >= height // 2 and (((x // 32) + (y // 24) + sequence) & 1) == 0:
|
|
||||||
value //= 3
|
|
||||||
data[row + x] = value
|
data[row + x] = value
|
||||||
draw_marker(data, width, height, sequence)
|
draw_marker(data, width, height, sequence)
|
||||||
return bytes(data)
|
return bytes(data)
|
||||||
@ -623,6 +695,7 @@ def run_capture(args: argparse.Namespace) -> int:
|
|||||||
previous_seq: int | None = None
|
previous_seq: int | None = None
|
||||||
decoded_frames = 0
|
decoded_frames = 0
|
||||||
reason_counts: collections.Counter[str] = collections.Counter()
|
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
|
max_total_mae = max_upper_mae = max_lower_mae = 0.0
|
||||||
worst: list[dict[str, Any]] = []
|
worst: list[dict[str, Any]] = []
|
||||||
with stderr_path.open("wb") as err, metrics_path.open("w") as metrics:
|
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"]
|
decoded_seq = result["decoded_sequence"]
|
||||||
if decoded_seq is not None:
|
if decoded_seq is not None:
|
||||||
decoded_frames += 1
|
decoded_frames += 1
|
||||||
|
sequence_counts[int(decoded_seq)] += 1
|
||||||
previous_seq = int(decoded_seq)
|
previous_seq = int(decoded_seq)
|
||||||
result.update({"frame": frame_index, "elapsed_s": round(time.monotonic() - started, 3)})
|
result.update({"frame": frame_index, "elapsed_s": round(time.monotonic() - started, 3)})
|
||||||
max_total_mae = max(max_total_mae, float(result["total_mae"]))
|
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_frames": suspicious_count,
|
||||||
"suspicious_pct": round(suspicious_count / frame_index * 100.0, 3) if frame_index else 0.0,
|
"suspicious_pct": round(suspicious_count / frame_index * 100.0, 3) if frame_index else 0.0,
|
||||||
"reason_counts": dict(reason_counts),
|
"reason_counts": dict(reason_counts),
|
||||||
|
"decoded_sequence_counts": dict(sequence_counts.most_common(12)),
|
||||||
"max_total_mae": round(max_total_mae, 3),
|
"max_total_mae": round(max_total_mae, 3),
|
||||||
"max_upper_mae": round(max_upper_mae, 3),
|
"max_upper_mae": round(max_upper_mae, 3),
|
||||||
"max_lower_mae": round(max_lower_mae, 3),
|
"max_lower_mae": round(max_lower_mae, 3),
|
||||||
@ -729,7 +804,7 @@ def run_self_test(args: argparse.Namespace) -> int:
|
|||||||
height = 180
|
height = 180
|
||||||
frames = [synthetic_gray(width, height, idx) for idx in range(6)]
|
frames = [synthetic_gray(width, height, idx) for idx in range(6)]
|
||||||
corrupt = bytearray(synthetic_gray(width, height, 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))
|
frames.append(bytes(corrupt))
|
||||||
shifted = bytearray(width * height)
|
shifted = bytearray(width * height)
|
||||||
expected = synthetic_gray(width, height, 7)
|
expected = synthetic_gray(width, height, 7)
|
||||||
|
|||||||
@ -16,7 +16,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.22.50"
|
version = "0.22.51"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
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_SERVER: &str = "http://127.0.0.1:50051";
|
||||||
const DEFAULT_SAMPLE_RATE: u32 = 48_000;
|
const DEFAULT_SAMPLE_RATE: u32 = 48_000;
|
||||||
const DEFAULT_CHANNELS: u32 = 2;
|
const DEFAULT_CHANNELS: u32 = 2;
|
||||||
const DEFAULT_JPEG_QUALITY: i32 = 90;
|
const DEFAULT_JPEG_QUALITY: i32 = 82;
|
||||||
const MARKER_BITS: usize = 32;
|
const MARKER_BITS: usize = 32;
|
||||||
const MARKER_COLUMNS: usize = 16;
|
const MARKER_COLUMNS: usize = 16;
|
||||||
|
|
||||||
@ -32,6 +32,7 @@ struct Args {
|
|||||||
session_id: u64,
|
session_id: u64,
|
||||||
artifact_dir: Option<PathBuf>,
|
artifact_dir: Option<PathBuf>,
|
||||||
print_every: u64,
|
print_every: u64,
|
||||||
|
max_frame_bytes: usize,
|
||||||
tls_ca: Option<PathBuf>,
|
tls_ca: Option<PathBuf>,
|
||||||
tls_client_cert: Option<PathBuf>,
|
tls_client_cert: Option<PathBuf>,
|
||||||
tls_client_key: Option<PathBuf>,
|
tls_client_key: Option<PathBuf>,
|
||||||
@ -50,6 +51,7 @@ impl Args {
|
|||||||
session_id: unix_millis(),
|
session_id: unix_millis(),
|
||||||
artifact_dir: None,
|
artifact_dir: None,
|
||||||
print_every: 150,
|
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_ca: env_path("LESAVKA_TLS_CA").or_else(|| default_pki_path("ca.crt")),
|
||||||
tls_client_cert: env_path("LESAVKA_TLS_CLIENT_CERT")
|
tls_client_cert: env_path("LESAVKA_TLS_CLIENT_CERT")
|
||||||
.or_else(|| default_pki_path("client.crt")),
|
.or_else(|| default_pki_path("client.crt")),
|
||||||
@ -76,6 +78,7 @@ impl Args {
|
|||||||
args.artifact_dir = Some(PathBuf::from(next_value(&mut it, &flag)?))
|
args.artifact_dir = Some(PathBuf::from(next_value(&mut it, &flag)?))
|
||||||
}
|
}
|
||||||
"--print-every" => args.print_every = parse_next(&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-ca" => args.tls_ca = Some(PathBuf::from(next_value(&mut it, &flag)?)),
|
||||||
"--tls-client-cert" => {
|
"--tls-client-cert" => {
|
||||||
args.tls_client_cert = Some(PathBuf::from(next_value(&mut it, &flag)?))
|
args.tls_client_cert = Some(PathBuf::from(next_value(&mut it, &flag)?))
|
||||||
@ -124,6 +127,39 @@ struct MjpegEncoder {
|
|||||||
frame_step_us: u64,
|
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 {
|
impl MjpegEncoder {
|
||||||
fn new(args: &Args) -> Result<Self> {
|
fn new(args: &Args) -> Result<Self> {
|
||||||
gst::init().context("gst init")?;
|
gst::init().context("gst init")?;
|
||||||
@ -316,7 +352,7 @@ async fn main() -> Result<()> {
|
|||||||
dir.join("command.txt"),
|
dir.join("command.txt"),
|
||||||
std::env::args().collect::<Vec<_>>().join(" ") + "\n",
|
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?;
|
let channel = connect_channel(&args).await?;
|
||||||
@ -338,6 +374,7 @@ async fn main() -> Result<()> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let mut encoder = MjpegEncoder::new(&args)?;
|
let mut encoder = MjpegEncoder::new(&args)?;
|
||||||
|
let mut encode_stats = EncodeStats::default();
|
||||||
let frame_step = Duration::from_micros(args.frame_step_us());
|
let frame_step = Duration::from_micros(args.frame_step_us());
|
||||||
let started = tokio::time::Instant::now() + Duration::from_millis(250);
|
let started = tokio::time::Instant::now() + Duration::from_millis(250);
|
||||||
let total_frames = args.total_frames();
|
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;
|
tokio::time::sleep_until(started + duration_mul(frame_step, sequence)).await;
|
||||||
let pts_us = sequence.saturating_mul(args.frame_step_us());
|
let pts_us = sequence.saturating_mul(args.frame_step_us());
|
||||||
let encoded = encoder.encode(sequence)?;
|
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);
|
let bundle = synthetic_bundle(&args, sequence, pts_us, encoded);
|
||||||
if tx.send(bundle).await.is_err() {
|
if tx.send(bundle).await.is_err() {
|
||||||
let response_result = response_task
|
let response_result = response_task
|
||||||
@ -372,6 +418,7 @@ async fn main() -> Result<()> {
|
|||||||
response_task
|
response_task
|
||||||
.await
|
.await
|
||||||
.context("joining StreamWebcamMedia task")??;
|
.context("joining StreamWebcamMedia task")??;
|
||||||
|
write_summary(&args, Some(&encode_stats))?;
|
||||||
eprintln!("lesavka synthetic uplink complete: frames={total_frames}");
|
eprintln!("lesavka synthetic uplink complete: frames={total_frames}");
|
||||||
Ok(())
|
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> {
|
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 mut frame = vec![0u8; width.saturating_mul(height).saturating_mul(3)];
|
||||||
let moving_period = (width / 3).max(64);
|
let moving_width = (width / 10).max(32).min(width.max(1));
|
||||||
let moving_width = (width / 18).max(12);
|
let moving_offset = (sequence as usize).wrapping_mul(13) % width.max(1);
|
||||||
let moving_offset = (sequence as usize).wrapping_mul(17) % moving_period;
|
|
||||||
for y in 0..height {
|
for y in 0..height {
|
||||||
for x in 0..width {
|
for x in 0..width {
|
||||||
let mut value = synthetic_luma(
|
let value = synthetic_luma(
|
||||||
(width, height),
|
(width, height),
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
sequence,
|
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;
|
let offset = (y * width + x) * 3;
|
||||||
frame[offset] = value;
|
frame[offset] = value;
|
||||||
frame[offset + 1] = value;
|
frame[offset + 1] = value;
|
||||||
@ -457,14 +500,29 @@ fn synthetic_luma(
|
|||||||
x: usize,
|
x: usize,
|
||||||
y: usize,
|
y: usize,
|
||||||
sequence: u64,
|
sequence: u64,
|
||||||
moving_bar: (usize, usize, usize),
|
moving_bar: (usize, usize),
|
||||||
) -> u8 {
|
) -> u8 {
|
||||||
let (width, height) = frame_size;
|
let (width, height) = frame_size;
|
||||||
let (moving_period, moving_width, moving_offset) = moving_bar;
|
let (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 width = width.max(1);
|
||||||
let moving = (x + moving_offset) % moving_period;
|
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 {
|
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_x = width / 2;
|
||||||
let center_y = height / 2;
|
let center_y = height / 2;
|
||||||
@ -561,6 +619,12 @@ where
|
|||||||
Ok(next_value(it, flag)?.parse()?)
|
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 {
|
fn duration_mul(duration: Duration, count: u64) -> Duration {
|
||||||
Duration::from_nanos(
|
Duration::from_nanos(
|
||||||
duration
|
duration
|
||||||
@ -578,9 +642,34 @@ fn unix_millis() -> u64 {
|
|||||||
.min(u128::from(u64::MAX)) as 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!(
|
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,
|
server = args.server,
|
||||||
width = args.width,
|
width = args.width,
|
||||||
height = args.height,
|
height = args.height,
|
||||||
@ -588,6 +677,13 @@ fn args_summary_json(args: &Args) -> String {
|
|||||||
duration = args.duration.as_secs_f64(),
|
duration = args.duration.as_secs_f64(),
|
||||||
session = args.session_id,
|
session = args.session_id,
|
||||||
tls = is_https(&args.server),
|
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\
|
--width N --height N --fps N\n\
|
||||||
--duration SECONDS default 300\n\
|
--duration SECONDS default 300\n\
|
||||||
--jpeg-quality N default {DEFAULT_JPEG_QUALITY}\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\
|
--artifact-dir PATH write command/summary metadata\n\
|
||||||
--print-every N progress interval in frames"
|
--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-only",
|
||||||
"--capture-before-inject",
|
"--capture-before-inject",
|
||||||
"--inject-warmup-s",
|
"--inject-warmup-s",
|
||||||
|
"--capture-finish-grace-s",
|
||||||
|
"--jpeg-quality",
|
||||||
|
"--inject-max-frame-bytes",
|
||||||
"--source",
|
"--source",
|
||||||
"--mode",
|
"--mode",
|
||||||
"1280x720@20,1280x720@30,1920x1080@20,1920x1080@30",
|
"1280x720@20,1280x720@30,1920x1080@20,1920x1080@30",
|
||||||
@ -53,6 +56,9 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
|
|||||||
"suspicious_",
|
"suspicious_",
|
||||||
"decoded_pct",
|
"decoded_pct",
|
||||||
"diagnosis",
|
"diagnosis",
|
||||||
|
"encoded_oversize_frames",
|
||||||
|
"decoded_sequence_counts",
|
||||||
|
"synthetic uplink completed but RCT capture did not finish",
|
||||||
"synthetic uplink exited before capture warmup completed",
|
"synthetic uplink exited before capture warmup completed",
|
||||||
"max_lower_mae",
|
"max_lower_mae",
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
@ -85,6 +91,8 @@ fn synthetic_injector_enters_the_public_bundled_media_rpc() {
|
|||||||
"--tls-ca",
|
"--tls-ca",
|
||||||
"--tls-client-cert",
|
"--tls-client-cert",
|
||||||
"--tls-client-key",
|
"--tls-client-key",
|
||||||
|
"--max-frame-bytes",
|
||||||
|
"encoded synthetic frame",
|
||||||
"StreamWebcamMedia closed before accepting synthetic frame",
|
"StreamWebcamMedia closed before accepting synthetic frame",
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user