feat(video): add codec and resolution controls
This commit is contained in:
@@ -186,6 +186,19 @@ const VIDEO_KEYFRAME_INTERVAL_FRAMES: u32 = 120;
|
||||
const VIDEO_BITRATE_BPS: u32 = 900_000;
|
||||
const VIDEO_PLI_MIN_INTERVAL_MS: u128 = 250;
|
||||
|
||||
fn parse_video_codec(codec: &str) -> wzp_proto::CodecId {
|
||||
match codec.to_ascii_lowercase().as_str() {
|
||||
"h265" | "h265main" | "hevc" => wzp_proto::CodecId::H265Main,
|
||||
"av1" | "av1main" => wzp_proto::CodecId::Av1Main,
|
||||
_ => wzp_proto::CodecId::H264Baseline,
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_video_dimension(value: u32, fallback: u32) -> u32 {
|
||||
let value = if value == 0 { fallback } else { value };
|
||||
value.clamp(160, 1920) & !1
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct VideoContinuity {
|
||||
expected_seq: Option<u32>,
|
||||
@@ -684,17 +697,26 @@ impl CallEngine {
|
||||
app: tauri::AppHandle,
|
||||
active_quality: Arc<std::sync::Mutex<wzp_proto::QualityProfile>>,
|
||||
peer_max_quality: Arc<std::sync::Mutex<Option<wzp_proto::QualityProfile>>>,
|
||||
video_codec_preference: String,
|
||||
video_width: u32,
|
||||
video_height: u32,
|
||||
event_cb: F,
|
||||
) -> Result<Self, anyhow::Error>
|
||||
where
|
||||
F: Fn(&str, &str) + Send + Sync + 'static,
|
||||
{
|
||||
let call_t0 = std::time::Instant::now();
|
||||
let preferred_video_codec = parse_video_codec(&video_codec_preference);
|
||||
let video_width = clamp_video_dimension(video_width, 1280);
|
||||
let video_height = clamp_video_dimension(video_height, 720);
|
||||
info!(
|
||||
%relay, %room, %alias, %quality,
|
||||
has_reuse = reuse_endpoint.is_some(),
|
||||
has_pre_connected = pre_connected_transport.is_some(),
|
||||
is_direct_p2p,
|
||||
video_codec = ?preferred_video_codec,
|
||||
video_width,
|
||||
video_height,
|
||||
t_ms = 0u128,
|
||||
"CallEngine::start (android) invoked"
|
||||
);
|
||||
@@ -788,24 +810,28 @@ impl CallEngine {
|
||||
"remote": transport.remote_address().to_string(),
|
||||
}),
|
||||
);
|
||||
let hs =
|
||||
match wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
|
||||
.await
|
||||
{
|
||||
Ok(hs) => hs,
|
||||
Err(e) => {
|
||||
error!("perform_handshake failed: {e}");
|
||||
crate::emit_call_debug(
|
||||
&app,
|
||||
"connect:handshake_failed",
|
||||
serde_json::json!({
|
||||
"t_ms": call_t0.elapsed().as_millis(),
|
||||
"error": e.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let hs = match wzp_client::handshake::perform_handshake_with_video_codecs(
|
||||
&*transport,
|
||||
&seed.0,
|
||||
Some(&alias),
|
||||
vec![preferred_video_codec],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(hs) => hs,
|
||||
Err(e) => {
|
||||
error!("perform_handshake failed: {e}");
|
||||
crate::emit_call_debug(
|
||||
&app,
|
||||
"connect:handshake_failed",
|
||||
serde_json::json!({
|
||||
"t_ms": call_t0.elapsed().as_millis(),
|
||||
"error": e.to_string(),
|
||||
}),
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
crate::emit_call_debug(
|
||||
&app,
|
||||
"connect:handshake_done",
|
||||
@@ -829,7 +855,7 @@ impl CallEngine {
|
||||
t_ms = call_t0.elapsed().as_millis(),
|
||||
"first-join diag: direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)"
|
||||
);
|
||||
(Some(wzp_proto::CodecId::H264Baseline), transport)
|
||||
(Some(preferred_video_codec), transport)
|
||||
};
|
||||
crate::emit_call_debug(
|
||||
&app,
|
||||
@@ -838,6 +864,8 @@ impl CallEngine {
|
||||
"t_ms": call_t0.elapsed().as_millis(),
|
||||
"codec": _negotiated_video_codec.map(|c| format!("{:?}", c)),
|
||||
"enabled": _negotiated_video_codec.is_some(),
|
||||
"width": video_width,
|
||||
"height": video_height,
|
||||
"direct_p2p": is_direct_p2p,
|
||||
}),
|
||||
);
|
||||
@@ -1496,13 +1524,15 @@ impl CallEngine {
|
||||
serde_json::json!({
|
||||
"t_ms": recv_t0.elapsed().as_millis() as u64,
|
||||
"codec": format!("{:?}", codec_id),
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"width": video_width,
|
||||
"height": video_height,
|
||||
"platform": "android",
|
||||
}),
|
||||
);
|
||||
match wzp_video::factory::create_video_decoder(
|
||||
codec_id, 1280, 720,
|
||||
codec_id,
|
||||
video_width,
|
||||
video_height,
|
||||
) {
|
||||
Ok(d) => {
|
||||
info!(codec = ?codec_id, "video decoder created (android)");
|
||||
@@ -2020,8 +2050,8 @@ impl CallEngine {
|
||||
|
||||
// Video send task (Android) — mirror of the desktop branch. Only
|
||||
// spawns when a video codec is available. Relay calls negotiate this
|
||||
// in the media handshake; direct P2P uses the common H264 baseline
|
||||
// codec because the relay handshake is intentionally skipped.
|
||||
// in the media handshake; direct P2P uses the local debug codec
|
||||
// preference because the relay handshake is intentionally skipped.
|
||||
let camera_tx = if let Some(vid_codec) = _negotiated_video_codec {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<wzp_video::encoder::VideoFrame>(4);
|
||||
let vid_transport = transport.clone();
|
||||
@@ -2046,16 +2076,16 @@ impl CallEngine {
|
||||
serde_json::json!({
|
||||
"t_ms": vid_t0.elapsed().as_millis() as u64,
|
||||
"codec": format!("{:?}", vid_codec),
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"width": video_width,
|
||||
"height": video_height,
|
||||
"bitrate_bps": VIDEO_BITRATE_BPS,
|
||||
"platform": "android",
|
||||
}),
|
||||
);
|
||||
let mut encoder = match wzp_video::factory::create_video_encoder(
|
||||
vid_codec,
|
||||
1280,
|
||||
720,
|
||||
video_width,
|
||||
video_height,
|
||||
VIDEO_BITRATE_BPS,
|
||||
) {
|
||||
Ok(e) => {
|
||||
@@ -2428,19 +2458,28 @@ impl CallEngine {
|
||||
_app: tauri::AppHandle,
|
||||
active_quality: Arc<std::sync::Mutex<wzp_proto::QualityProfile>>,
|
||||
peer_max_quality: Arc<std::sync::Mutex<Option<wzp_proto::QualityProfile>>>,
|
||||
video_codec_preference: String,
|
||||
video_width: u32,
|
||||
video_height: u32,
|
||||
event_cb: F,
|
||||
) -> Result<Self, anyhow::Error>
|
||||
where
|
||||
F: Fn(&str, &str) + Send + Sync + 'static,
|
||||
{
|
||||
let call_t0 = Instant::now();
|
||||
let preferred_video_codec = parse_video_codec(&video_codec_preference);
|
||||
let video_width = clamp_video_dimension(video_width, 1280);
|
||||
let video_height = clamp_video_dimension(video_height, 720);
|
||||
info!(
|
||||
%relay, %room, %alias, %quality,
|
||||
has_reuse = reuse_endpoint.is_some(),
|
||||
has_pre_connected = pre_connected_transport.is_some(),
|
||||
is_direct_p2p,
|
||||
video_codec = ?preferred_video_codec,
|
||||
video_width,
|
||||
video_height,
|
||||
"CallEngine::start (desktop) invoked"
|
||||
);
|
||||
let call_t0 = Instant::now();
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let relay_addr: SocketAddr = relay.parse()?;
|
||||
@@ -2498,13 +2537,17 @@ impl CallEngine {
|
||||
// PRD lands, media goes plaintext-over-QUIC-TLS to the relay.
|
||||
let (_negotiated_video_codec, transport): (_, Arc<dyn wzp_proto::MediaTransport>) =
|
||||
if !is_direct_p2p {
|
||||
let hs =
|
||||
wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("perform_handshake failed: {e}");
|
||||
e
|
||||
})?;
|
||||
let hs = wzp_client::handshake::perform_handshake_with_video_codecs(
|
||||
&*transport,
|
||||
&seed.0,
|
||||
Some(&alias),
|
||||
vec![preferred_video_codec],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("perform_handshake failed: {e}");
|
||||
e
|
||||
})?;
|
||||
crate::emit_call_debug(
|
||||
&_app,
|
||||
"connect:handshake_done",
|
||||
@@ -2518,7 +2561,7 @@ impl CallEngine {
|
||||
(hs.video_codec, transport)
|
||||
} else {
|
||||
info!("direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)");
|
||||
(Some(wzp_proto::CodecId::H264Baseline), transport)
|
||||
(Some(preferred_video_codec), transport)
|
||||
};
|
||||
crate::emit_call_debug(
|
||||
&_app,
|
||||
@@ -2527,6 +2570,8 @@ impl CallEngine {
|
||||
"t_ms": call_t0.elapsed().as_millis(),
|
||||
"codec": _negotiated_video_codec.map(|c| format!("{:?}", c)),
|
||||
"enabled": _negotiated_video_codec.is_some(),
|
||||
"width": video_width,
|
||||
"height": video_height,
|
||||
"direct_p2p": is_direct_p2p,
|
||||
}),
|
||||
);
|
||||
@@ -2970,13 +3015,15 @@ impl CallEngine {
|
||||
serde_json::json!({
|
||||
"t_ms": recv_t0.elapsed().as_millis() as u64,
|
||||
"codec": format!("{:?}", codec_id),
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"width": video_width,
|
||||
"height": video_height,
|
||||
"platform": "desktop",
|
||||
}),
|
||||
);
|
||||
match wzp_video::factory::create_video_decoder(
|
||||
codec_id, 1280, 720,
|
||||
codec_id,
|
||||
video_width,
|
||||
video_height,
|
||||
) {
|
||||
Ok(d) => {
|
||||
info!(codec = ?codec_id, "video decoder created");
|
||||
@@ -3339,8 +3386,8 @@ impl CallEngine {
|
||||
));
|
||||
|
||||
// Video send task — active when a video codec is available. Relay calls
|
||||
// negotiate this in the media handshake; direct P2P uses the common H264
|
||||
// baseline codec because the relay handshake is intentionally skipped.
|
||||
// negotiate this in the media handshake; direct P2P uses the local debug
|
||||
// codec preference because the relay handshake is intentionally skipped.
|
||||
let camera_tx = if let Some(vid_codec) = _negotiated_video_codec {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<wzp_video::encoder::VideoFrame>(4);
|
||||
let vid_transport = transport.clone();
|
||||
@@ -3365,16 +3412,16 @@ impl CallEngine {
|
||||
serde_json::json!({
|
||||
"t_ms": vid_t0.elapsed().as_millis() as u64,
|
||||
"codec": format!("{:?}", vid_codec),
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"width": video_width,
|
||||
"height": video_height,
|
||||
"bitrate_bps": VIDEO_BITRATE_BPS,
|
||||
"platform": "desktop",
|
||||
}),
|
||||
);
|
||||
let mut encoder = match wzp_video::factory::create_video_encoder(
|
||||
vid_codec,
|
||||
1280,
|
||||
720,
|
||||
video_width,
|
||||
video_height,
|
||||
VIDEO_BITRATE_BPS,
|
||||
) {
|
||||
Ok(e) => {
|
||||
|
||||
@@ -137,13 +137,15 @@ pub(crate) fn i420_to_jpeg_bytes(data: &[u8], width: u32, height: u32) -> Option
|
||||
let u = data[y_size + uv_idx] as f32 - 128.0;
|
||||
let v = data[y_size + uv_size + uv_idx] as f32 - 128.0;
|
||||
let out = (row * w + col) * 3;
|
||||
rgb[out] = (y + 1.402 * v).clamp(0.0, 255.0) as u8;
|
||||
rgb[out] = (y + 1.402 * v).clamp(0.0, 255.0) as u8;
|
||||
rgb[out + 1] = (y - 0.344 * u - 0.714 * v).clamp(0.0, 255.0) as u8;
|
||||
rgb[out + 2] = (y + 1.772 * u).clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
let img = DynamicImage::ImageRgb8(ImageBuffer::<Rgb<u8>, Vec<u8>>::from_raw(width, height, rgb)?);
|
||||
let img = DynamicImage::ImageRgb8(ImageBuffer::<Rgb<u8>, Vec<u8>>::from_raw(
|
||||
width, height, rgb,
|
||||
)?);
|
||||
let mut buf = std::io::Cursor::new(Vec::<u8>::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Jpeg).ok()?;
|
||||
Some(buf.into_inner())
|
||||
@@ -217,8 +219,10 @@ fn rgb_to_i420(rgb: &[u8], w: usize, h: usize) -> Vec<u8> {
|
||||
out[row * w + col] = (0.299 * r + 0.587 * g + 0.114 * b).clamp(0.0, 255.0) as u8;
|
||||
if row % 2 == 0 && col % 2 == 0 {
|
||||
let uv = (row / 2) * (w / 2) + col / 2;
|
||||
out[y_size + uv] = (-0.169 * r - 0.331 * g + 0.500 * b + 128.0).clamp(0.0, 255.0) as u8;
|
||||
out[y_size + uv_size + uv] = (0.500 * r - 0.419 * g - 0.081 * b + 128.0).clamp(0.0, 255.0) as u8;
|
||||
out[y_size + uv] =
|
||||
(-0.169 * r - 0.331 * g + 0.500 * b + 128.0).clamp(0.0, 255.0) as u8;
|
||||
out[y_size + uv_size + uv] =
|
||||
(0.500 * r - 0.419 * g - 0.081 * b + 128.0).clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,7 +391,7 @@ mod video_tests {
|
||||
fn solid_rgb_frame(w: usize, h: usize, r: u8, g: u8, b: u8) -> Vec<u8> {
|
||||
let mut rgb = vec![0u8; w * h * 3];
|
||||
for i in 0..w * h {
|
||||
rgb[i * 3] = r;
|
||||
rgb[i * 3] = r;
|
||||
rgb[i * 3 + 1] = g;
|
||||
rgb[i * 3 + 2] = b;
|
||||
}
|
||||
@@ -439,8 +443,14 @@ mod video_tests {
|
||||
let s = b64.unwrap();
|
||||
assert!(!s.is_empty());
|
||||
// JPEG base64 starts with '/9j/' (FFD8FF marker).
|
||||
let decoded = base64::engine::general_purpose::STANDARD.decode(&s).unwrap();
|
||||
assert_eq!(&decoded[0..2], &[0xFF, 0xD8], "output must start with JPEG SOI marker");
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(&s)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&decoded[0..2],
|
||||
&[0xFF, 0xD8],
|
||||
"output must start with JPEG SOI marker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -463,13 +473,18 @@ mod video_tests {
|
||||
let yuv = rgb_to_i420(&rgb, 64, 64);
|
||||
|
||||
let b64 = i420_to_jpeg_b64(&yuv, 64, 64).expect("should produce JPEG");
|
||||
let jpeg = base64::engine::general_purpose::STANDARD.decode(&b64).unwrap();
|
||||
let jpeg = base64::engine::general_purpose::STANDARD
|
||||
.decode(&b64)
|
||||
.unwrap();
|
||||
|
||||
let img = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg).unwrap();
|
||||
let rgb_img = img.to_rgb8();
|
||||
let px = rgb_img.get_pixel(32, 32);
|
||||
let (r, g, b) = (px[0], px[1], px[2]);
|
||||
assert!(r > g && r > b, "red frame: expected R dominant, got R={r} G={g} B={b}");
|
||||
assert!(
|
||||
r > g && r > b,
|
||||
"red frame: expected R dominant, got R={r} G={g} B={b}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -746,8 +761,14 @@ async fn connect(
|
||||
// Enable birthday attack for hard NAT traversal. Adds ~3s to
|
||||
// call setup when peer has symmetric NAT.
|
||||
birthday_attack: Option<bool>,
|
||||
video_codec: Option<String>,
|
||||
video_width: Option<u32>,
|
||||
video_height: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
let force_direct = direct_only.unwrap_or(false);
|
||||
let video_codec = video_codec.unwrap_or_else(|| "h264".to_string());
|
||||
let video_width = video_width.unwrap_or(1280);
|
||||
let video_height = video_height.unwrap_or(720);
|
||||
let enable_birthday = birthday_attack.unwrap_or(false);
|
||||
emit_call_debug(
|
||||
&app,
|
||||
@@ -760,6 +781,9 @@ async fn connect(
|
||||
"peer_mapped_addr": peer_mapped_addr,
|
||||
"direct_only": force_direct,
|
||||
"birthday_attack": enable_birthday,
|
||||
"video_codec": video_codec,
|
||||
"video_width": video_width,
|
||||
"video_height": video_height,
|
||||
}),
|
||||
);
|
||||
let mut engine_lock = state.engine.lock().await;
|
||||
@@ -1218,6 +1242,9 @@ async fn connect(
|
||||
app_for_engine,
|
||||
active_quality,
|
||||
peer_max_quality,
|
||||
video_codec,
|
||||
video_width,
|
||||
video_height,
|
||||
move |event_kind, message| {
|
||||
let _ = app_clone.emit(
|
||||
"call-event",
|
||||
@@ -2129,7 +2156,9 @@ fn do_register_signal(
|
||||
"peer_loss_pct": local_loss_pct, "peer_rtt_ms": local_rtt_ms,
|
||||
}),
|
||||
);
|
||||
if let Err(e) = handle_upgrade_proposal(&*transport, &call_id, &proposal_id).await {
|
||||
if let Err(e) =
|
||||
handle_upgrade_proposal(&*transport, &call_id, &proposal_id).await
|
||||
{
|
||||
tracing::warn!("failed to send UpgradeResponse: {e}");
|
||||
}
|
||||
}
|
||||
@@ -2150,8 +2179,14 @@ fn do_register_signal(
|
||||
}),
|
||||
);
|
||||
if let Err(e) = handle_upgrade_response(
|
||||
&*transport, &signal_state, &call_id, &proposal_id, accepted,
|
||||
).await {
|
||||
&*transport,
|
||||
&signal_state,
|
||||
&call_id,
|
||||
&proposal_id,
|
||||
accepted,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("failed to handle UpgradeResponse: {e}");
|
||||
}
|
||||
}
|
||||
@@ -3426,7 +3461,9 @@ mod signal_tests {
|
||||
#[tokio::test]
|
||||
async fn upgrade_proposal_auto_accepts() {
|
||||
let transport = LoopbackTransport::new();
|
||||
handle_upgrade_proposal(&*transport, "c1", "p1").await.unwrap();
|
||||
handle_upgrade_proposal(&*transport, "c1", "p1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let sent = transport.take_sent();
|
||||
assert_eq!(sent.len(), 1);
|
||||
@@ -3453,8 +3490,11 @@ mod signal_tests {
|
||||
let signal_state = empty_signal_state();
|
||||
{
|
||||
let sig = signal_state.lock().await;
|
||||
*sig.pending_upgrade.lock().unwrap() =
|
||||
Some(("c1".into(), "p1".into(), wzp_proto::QualityProfile::STUDIO_48K));
|
||||
*sig.pending_upgrade.lock().unwrap() = Some((
|
||||
"c1".into(),
|
||||
"p1".into(),
|
||||
wzp_proto::QualityProfile::STUDIO_48K,
|
||||
));
|
||||
}
|
||||
|
||||
handle_upgrade_response(&*transport, &signal_state, "c1", "p1", true)
|
||||
|
||||
Reference in New Issue
Block a user