- commit
- c5535b99fc3427f72254c22cbd4514203e4fa503
- parent
- 8c5a03f1778b05c1d5fd11e193ee5ad1dafc12bb
- author
- Dami <dami.etoile@gmail.com>
| M | src-tauri/ | 486 | +----------------------------- |
| A | src-tauri/ | 483 | ++++++++++++++++++++++++++++++ |
| M | src-tauri/ | 1 | + |
@@ -5,6 +5,13 @@ use super::state::{
55 CLIENT_INPUT_QUEUE_CAPACITY, FRAME_ALPN, INPUT_ALPN, MediaFeedbackSender,66 development_direct_node_available,77 };8+pub use crate::media::frame_channel::FramePayload;9+use crate::media::frame_channel::{10+ CLIENT_FRAME_CHANNEL_CAPACITY, FrameEnvelopeMetadata, byte_to_codec,11+ close_generation_connection, emit_frame_error, encode_frame_envelope, next_media_generation,12+ release_frame_channel_slot, release_frame_channel_slot_for_generation, take_generation_owned,13+ take_generation_owned_triple, try_reserve_frame_channel_slot, validate_legacy_media_header,14+};815 use crate::media::moq_catalog::subscribe_goq_video_track;916 1017 use crate::media::network_diagnostics::PathMode;@@ -26,14 +33,13 @@ use sigil_protocol::{
2633 AudioFlags, AudioPacket, AudioPacketHeader, CONTROL_ALPN_V1, Capability, ClientHello,2734 FrameFlags, INPUT_ALPN_V1, InputEvent, InvitationGrants, KeyframeRequestReasonV3,2835 MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_DELIVERY_TIMEOUT_MS, MAX_MEDIA_OBJECT_ID_V3,29- MAX_MEDIA_PAYLOAD_LEN, MAX_VIDEO_DIMENSION, MAX_VIDEO_PIXELS, MEDIA_ALPN_V1, MEDIA_ALPN_V2,30- MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, MediaCodec, MediaControlRequestV3, MediaFeedbackFlags,31- MediaFeedbackReportV1, MediaFrame, MediaObjectV3, PointerPosition, PointerSurfaceDimensions,32- ProtocolError, RELATIVE_POINTER_DELTA_MAX, RELATIVE_POINTER_DELTA_MIN,33- decode_media_frame_object, media_moq_broadcast_name, read_adaptive_bitrate_decision_v1,34- read_host_hello, read_input_ack, read_media_frame, read_media_object, read_media_object_v3,35- write_client_hello, write_input_event, write_media_control_request_v3,36- write_media_feedback_report_v1,36+ MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1, MediaCodec,37+ MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame, MediaObjectV3,38+ PointerPosition, PointerSurfaceDimensions, ProtocolError, RELATIVE_POINTER_DELTA_MAX,39+ RELATIVE_POINTER_DELTA_MIN, decode_media_frame_object, media_moq_broadcast_name,40+ read_adaptive_bitrate_decision_v1, read_host_hello, read_input_ack, read_media_frame,41+ read_media_object, read_media_object_v3, write_client_hello, write_input_event,42+ write_media_control_request_v3, write_media_feedback_report_v1,3743 };3844 use std::collections::{BTreeMap, VecDeque};3945 use std::io::Cursor;@@ -47,44 +53,6 @@ use tauri::{
4753 4854 // ─── Client commands ──────────────────────────────────────────────────────────4955 50-fn byte_to_codec(value: u8) -> &'static str {51- match value {52- 1 => "h264",53- 2 => "h265",54- 3 => "av1",55- _ => "h264",56- }57-}58-59-60-pub struct FramePayload {61- pub generation: u64,62- pub width: u32,63- pub height: u32,64- pub data: String,65- pub keyframe: bool,66- pub codec: String,67- pub capture_timestamp_micros: Option<u64>,68- pub pts_micros: Option<i64>,69- pub discontinuity: bool,70-}71-72-73-struct FrameErrorPayload {74- generation: u64,75- error: String,76-}77-78-fn emit_frame_error(app: &AppHandle, generation: u64, error: impl Into<String>) {79- let _ = app.emit(80- "frame-error",81- FrameErrorPayload {82- generation,83- error: error.into(),84- },85- );86-}87-8856 const LEGACY_MEDIA_IDLE_TIMEOUT: Duration = Duration::from_secs(10);8957 const CLIENT_MEDIA_OBJECT_CAPACITY: usize = 4;9058 const CLIENT_MEDIA_OBJECT_READ_TIMEOUT: Duration = Duration::from_secs(1);@@ -97,10 +65,6 @@ const CLIENT_MOQ_OBJECT_READ_TIMEOUT: Duration =
9765 // Sigil's external encoder can take 500 ms to reach its next configured IDR.9866 // Allow another 500 ms for a relay path to deliver the superseding group.9967 const CLIENT_MOQ_GROUP_RECOVERY_TIMEOUT: Duration = Duration::from_secs(1);100-// Absorb brief webview→Rust acknowledgment jitter without allowing Tauri IPC101-// to grow without bound. Four 60 fps frames cap this handoff at about 67 ms;102-// WebCodecs has a separate, stricter decode-queue bound in the frontend.103-const CLIENT_FRAME_CHANNEL_CAPACITY: usize = 4;10468 // Three 20 ms Opus packets cap the Rust→webview handoff at 60 ms. The10569 // AudioWorklet owns a separate fixed ring and never feeds back into transport.10670 const CLIENT_FRAME_STATS_INTERVAL: Duration = Duration::from_millis(250);@@ -114,14 +78,6 @@ const CLIENT_FRAME_TIMING_WINDOW: Duration = Duration::from_secs(5);
11478 // timer-boundary samples while keeping every rate window strictly bounded.11579 const CLIENT_FRAME_RATE_SAMPLE_CAPACITY: usize = 256;11680 const CLIENT_FRAME_TIMING_SAMPLE_CAPACITY: usize = 512;117-const FRAME_CHANNEL_MAGIC: [u8; 4] = *b"SGFR";118-const FRAME_CHANNEL_VERSION: u8 = 1;119-const FRAME_CHANNEL_HEADER_LEN: usize = 40;120-const FRAME_CHANNEL_FLAG_KEYFRAME: u8 = 1 << 0;121-const FRAME_CHANNEL_FLAG_DISCONTINUITY: u8 = 1 << 1;122-const FRAME_CHANNEL_FLAG_CODEC_CONFIG: u8 = 1 << 2;123-const FRAME_CHANNEL_OPTIONAL_U64_NONE: u64 = u64::MAX;124-const FRAME_CHANNEL_OPTIONAL_I64_NONE: i64 = i64::MIN;12581 const AUDIO_CHANNEL_MAGIC: [u8; 4] = *b"SGAC";12682 const AUDIO_CHANNEL_VERSION: u16 = 1;12783 const AUDIO_CHANNEL_HEADER_LEN: usize = 24;@@ -418,41 +374,6 @@ fn lock_network_diagnostics(
418374 .unwrap_or_else(std::sync::PoisonError::into_inner)419375 }420376 421-fn close_generation_connection<T>(422- connection: Option<(u64, T)>,423- close: impl FnOnce(T),424-) -> Option<u64> {425- connection.map(|(generation, connection)| {426- close(connection);427- generation428- })429-}430-431-fn take_generation_owned<T>(slot: &mut Option<(u64, T)>, expected_generation: u64) -> Option<T> {432- if slot433- .as_ref()434- .is_some_and(|(generation, _)| *generation == expected_generation)435- {436- slot.take().map(|(_, value)| value)437- } else {438- None439- }440-}441-442-fn take_generation_owned_triple<T, U>(443- slot: &mut Option<(u64, T, U)>,444- expected_generation: u64,445-) -> Option<(T, U)> {446- if slot447- .as_ref()448- .is_some_and(|(generation, _, _)| *generation == expected_generation)449- {450- slot.take().map(|(_, value, companion)| (value, companion))451- } else {452- None453- }454-}455-456377 async fn retire_media_feedback_generation(app: &AppHandle, generation: u64) -> bool {457378 let state = app.state::<AppState>();458379 let connection = {@@ -586,128 +507,6 @@ struct FrameStatsPayload {
586507 frontend_dropped_frames: u64,587508 }588509 589-struct FrameEnvelopeMetadata<'a> {590- width: u32,591- height: u32,592- codec: &'a str,593- keyframe: bool,594- discontinuity: bool,595- codec_config: bool,596- sequence: Option<u64>,597- capture_timestamp_micros: Option<u64>,598- pts_micros: Option<i64>,599-}600-601-fn encode_frame_envelope(602- metadata: FrameEnvelopeMetadata<'_>,603- payload: &[u8],604-) -> Result<Vec<u8>, String> {605- validate_legacy_media_header(metadata.width, metadata.height, payload.len())?;606- if metadata.codec_config && !metadata.keyframe {607- return Err("Frame codec configuration requires a keyframe".to_string());608- }609- if metadata.sequence == Some(FRAME_CHANNEL_OPTIONAL_U64_NONE) {610- return Err("Frame sequence collides with the channel sentinel".to_string());611- }612- if metadata.capture_timestamp_micros == Some(FRAME_CHANNEL_OPTIONAL_U64_NONE) {613- return Err("Capture timestamp collides with the channel sentinel".to_string());614- }615- if metadata.pts_micros == Some(FRAME_CHANNEL_OPTIONAL_I64_NONE) {616- return Err("Frame PTS collides with the channel sentinel".to_string());617- }618- let width = u16::try_from(metadata.width).map_err(|_| {619- format!(620- "Frame width does not fit channel envelope: {}",621- metadata.width622- )623- })?;624- let height = u16::try_from(metadata.height).map_err(|_| {625- format!(626- "Frame height does not fit channel envelope: {}",627- metadata.height628- )629- })?;630- let payload_len = u32::try_from(payload.len()).map_err(|_| {631- format!(632- "Frame payload does not fit channel envelope: {}",633- payload.len()634- )635- })?;636- let codec = match metadata.codec {637- "h264" => 1,638- "h265" => 2,639- "av1" => 3,640- other => return Err(format!("Unsupported frame channel codec: {other}")),641- };642- let mut flags = 0_u8;643- if metadata.keyframe {644- flags |= FRAME_CHANNEL_FLAG_KEYFRAME;645- }646- if metadata.discontinuity {647- flags |= FRAME_CHANNEL_FLAG_DISCONTINUITY;648- }649- if metadata.codec_config {650- flags |= FRAME_CHANNEL_FLAG_CODEC_CONFIG;651- }652-653- let mut envelope = Vec::with_capacity(FRAME_CHANNEL_HEADER_LEN + payload.len());654- envelope.extend_from_slice(&FRAME_CHANNEL_MAGIC);655- envelope.push(FRAME_CHANNEL_VERSION);656- envelope.push(codec);657- envelope.push(flags);658- envelope.push(0); // Reserved; the parser rejects non-zero values.659- envelope.extend_from_slice(&width.to_be_bytes());660- envelope.extend_from_slice(&height.to_be_bytes());661- envelope.extend_from_slice(&payload_len.to_be_bytes());662- envelope.extend_from_slice(663- &metadata664- .sequence665- .unwrap_or(FRAME_CHANNEL_OPTIONAL_U64_NONE)666- .to_be_bytes(),667- );668- envelope.extend_from_slice(669- &metadata670- .capture_timestamp_micros671- .unwrap_or(FRAME_CHANNEL_OPTIONAL_U64_NONE)672- .to_be_bytes(),673- );674- envelope.extend_from_slice(675- &metadata676- .pts_micros677- .unwrap_or(FRAME_CHANNEL_OPTIONAL_I64_NONE)678- .to_be_bytes(),679- );680- envelope.extend_from_slice(payload);681- debug_assert_eq!(envelope.len(), FRAME_CHANNEL_HEADER_LEN + payload.len());682- Ok(envelope)683-}684-685-fn try_reserve_frame_channel_slot(in_flight: &AtomicUsize) -> bool {686- in_flight687- .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {688- (current < CLIENT_FRAME_CHANNEL_CAPACITY).then_some(current + 1)689- })690- .is_ok()691-}692-693-fn release_frame_channel_slot(in_flight: &AtomicUsize) {694- let _ = in_flight.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {695- current.checked_sub(1)696- });697-}698-699-fn release_frame_channel_slot_for_generation(700- in_flight: &AtomicUsize,701- current_generation: u64,702- generation: u64,703-) -> bool {704- if generation == 0 || current_generation != generation {705- return false;706- }707- release_frame_channel_slot(in_flight);708- true709-}710-711510 fn lock_audio_deliveries(712511 deliveries: &StdMutex<AudioDeliveryState>,713512 ) -> StdMutexGuard<'_, AudioDeliveryState> {@@ -725,15 +524,6 @@ fn next_audio_generation(counter: &AtomicU64) -> Result<u64, String> {
725524 .map_err(|_| "Audio connection generation overflowed".to_string())726525 }727526 728-fn next_media_generation(counter: &AtomicU64) -> Result<u64, String> {729- counter730- .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {731- current.checked_add(1)732- })733- .map(|previous| previous + 1)734- .map_err(|_| "Media connection generation overflowed".to_string())735-}736-737527 fn audio_generation_is_current(counter: &AtomicU64, generation: u64) -> bool {738528 counter.load(Ordering::SeqCst) == generation739529 }@@ -3976,23 +3766,6 @@ pub async fn iroh_client_connect(
39763766 Ok(result)39773767 }39783768 3979-fn validate_legacy_media_header(width: u32, height: u32, payload_len: usize) -> Result<(), String> {3980- if width == 03981- || height == 03982- || width > u32::from(MAX_VIDEO_DIMENSION)3983- || height > u32::from(MAX_VIDEO_DIMENSION)3984- || width.saturating_mul(height) > MAX_VIDEO_PIXELS3985- {3986- return Err(format!("Invalid legacy media dimensions: {width}x{height}"));3987- }3988- if payload_len == 0 || payload_len > MAX_MEDIA_PAYLOAD_LEN {3989- return Err(format!(3990- "Invalid legacy media payload length: {payload_len}"3991- ));3992- }3993- Ok(())3994-}3995-39963769 fn sequence_gap(previous: u64, current: u64) -> Result<u64, String> {39973770 let expected = previous39983771 .checked_add(1)@@ -4542,187 +4315,6 @@ mod tests {
45424315 assert!(parse_keyframe_request_reason("please").is_err());45434316 }45444317 4545- 4546- fn binary_frame_envelope_has_exact_stable_layout() {4547- let payload = [0, 0, 0, 1, 0x65];4548- let envelope = encode_frame_envelope(4549- FrameEnvelopeMetadata {4550- width: 1280,4551- height: 800,4552- codec: "h264",4553- keyframe: true,4554- discontinuity: true,4555- codec_config: true,4556- sequence: Some(42),4557- capture_timestamp_micros: Some(123_456),4558- pts_micros: Some(98_765),4559- },4560- &payload,4561- )4562- .unwrap();4563-4564- assert_eq!(envelope.len(), FRAME_CHANNEL_HEADER_LEN + payload.len());4565- assert_eq!(&envelope[0..4], b"SGFR");4566- assert_eq!(envelope[4], 1);4567- assert_eq!(envelope[5], 1);4568- assert_eq!(envelope[6], 0b111);4569- assert_eq!(envelope[7], 0);4570- assert_eq!(&envelope[8..10], &1280_u16.to_be_bytes());4571- assert_eq!(&envelope[10..12], &800_u16.to_be_bytes());4572- assert_eq!(&envelope[12..16], &5_u32.to_be_bytes());4573- assert_eq!(&envelope[16..24], &42_u64.to_be_bytes());4574- assert_eq!(&envelope[24..32], &123_456_u64.to_be_bytes());4575- assert_eq!(&envelope[32..40], &98_765_i64.to_be_bytes());4576- assert_eq!(&envelope[40..], payload);4577- }4578-4579- 4580- fn binary_frame_envelope_uses_explicit_optional_sentinels() {4581- let envelope = encode_frame_envelope(4582- FrameEnvelopeMetadata {4583- width: 1,4584- height: 1,4585- codec: "av1",4586- keyframe: false,4587- discontinuity: false,4588- codec_config: false,4589- sequence: None,4590- capture_timestamp_micros: None,4591- pts_micros: None,4592- },4593- &[1],4594- )4595- .unwrap();4596- assert_eq!(envelope[5], 3);4597- assert_eq!(&envelope[16..24], &u64::MAX.to_be_bytes());4598- assert_eq!(&envelope[24..32], &u64::MAX.to_be_bytes());4599- assert_eq!(&envelope[32..40], &i64::MIN.to_be_bytes());4600- }4601-4602- 4603- fn binary_frame_envelope_rejects_invalid_metadata_before_sending() {4604- let metadata = |codec| FrameEnvelopeMetadata {4605- width: 1280,4606- height: 800,4607- codec,4608- keyframe: false,4609- discontinuity: false,4610- codec_config: false,4611- sequence: None,4612- capture_timestamp_micros: None,4613- pts_micros: None,4614- };4615- assert!(encode_frame_envelope(metadata("vp9"), &[1]).is_err());4616- assert!(encode_frame_envelope(metadata("h264"), &[]).is_err());4617- assert!(4618- encode_frame_envelope(4619- FrameEnvelopeMetadata {4620- codec_config: true,4621- ..metadata("h264")4622- },4623- &[1]4624- )4625- .is_err()4626- );4627- assert!(4628- encode_frame_envelope(4629- FrameEnvelopeMetadata {4630- sequence: Some(u64::MAX),4631- ..metadata("h264")4632- },4633- &[1]4634- )4635- .is_err()4636- );4637- assert!(4638- encode_frame_envelope(4639- FrameEnvelopeMetadata {4640- pts_micros: Some(i64::MIN),4641- ..metadata("h264")4642- },4643- &[1]4644- )4645- .is_err()4646- );4647- assert!(4648- encode_frame_envelope(4649- FrameEnvelopeMetadata {4650- width: u32::from(MAX_VIDEO_DIMENSION) + 1,4651- ..metadata("h264")4652- },4653- &[1]4654- )4655- .is_err()4656- );4657- }4658-4659- 4660- fn frame_channel_slots_are_bounded_and_cannot_underflow() {4661- let in_flight = AtomicUsize::new(0);4662- release_frame_channel_slot(&in_flight);4663- assert_eq!(in_flight.load(Ordering::SeqCst), 0);4664-4665- for expected in 1..=CLIENT_FRAME_CHANNEL_CAPACITY {4666- assert!(try_reserve_frame_channel_slot(&in_flight));4667- assert_eq!(in_flight.load(Ordering::SeqCst), expected);4668- }4669- assert!(!try_reserve_frame_channel_slot(&in_flight));4670- assert_eq!(4671- in_flight.load(Ordering::SeqCst),4672- CLIENT_FRAME_CHANNEL_CAPACITY4673- );4674-4675- for expected in (0..CLIENT_FRAME_CHANNEL_CAPACITY).rev() {4676- release_frame_channel_slot(&in_flight);4677- assert_eq!(in_flight.load(Ordering::SeqCst), expected);4678- }4679- release_frame_channel_slot(&in_flight);4680- assert_eq!(in_flight.load(Ordering::SeqCst), 0);4681- }4682-4683- 4684- fn frame_acknowledgments_release_only_the_matching_generation() {4685- let in_flight = AtomicUsize::new(2);4686- let generation = 9;4687-4688- assert!(!release_frame_channel_slot_for_generation(4689- &in_flight, generation, 84690- ));4691- assert!(!release_frame_channel_slot_for_generation(4692- &in_flight, generation, 04693- ));4694- assert_eq!(in_flight.load(Ordering::SeqCst), 2);4695- assert!(release_frame_channel_slot_for_generation(4696- &in_flight, generation, 94697- ));4698- assert_eq!(in_flight.load(Ordering::SeqCst), 1);4699- }4700-4701- 4702- fn global_frame_events_serialize_their_media_generation() {4703- let frame = serde_json::to_value(FramePayload {4704- generation: 17,4705- width: 1280,4706- height: 800,4707- data: "jpeg".to_string(),4708- keyframe: true,4709- codec: "h264".to_string(),4710- capture_timestamp_micros: Some(1),4711- pts_micros: Some(2),4712- discontinuity: false,4713- })4714- .unwrap();4715- let error = serde_json::to_value(FrameErrorPayload {4716- generation: 17,4717- error: "closed".to_string(),4718- })4719- .unwrap();4720-4721- assert_eq!(frame["generation"], 17);4722- assert_eq!(error["generation"], 17);4723- assert_eq!(error["error"], "closed");4724- }4725-47264318 fn audio_packet(sequence: u64) -> AudioPacket {47274319 let payload = vec![0xf8, 0xff, 0xfe];47284320 AudioPacket::new(@@ -5024,47 +4616,6 @@ mod tests {
50244616 active.store(false, Ordering::SeqCst);50254617 }50264618 5027- 5028- fn generation_connection_close_retires_exactly_one_owned_handle() {5029- let closes = AtomicUsize::new(0);5030- let retired = close_generation_connection(Some((7, "media")), |connection| {5031- assert_eq!(connection, "media");5032- closes.fetch_add(1, Ordering::SeqCst);5033- });5034- assert_eq!(retired, Some(7));5035- assert_eq!(closes.load(Ordering::SeqCst), 1);5036-5037- let absent: Option<(u64, &str)> = None;5038- assert_eq!(5039- close_generation_connection(absent, |_| {5040- closes.fetch_add(1, Ordering::SeqCst);5041- }),5042- None5043- );5044- assert_eq!(closes.load(Ordering::SeqCst), 1);5045- }5046-5047- 5048- fn generation_owned_retirement_cannot_take_a_replacement_session() {5049- let mut slot = Some((12, "replacement"));5050- assert_eq!(take_generation_owned(&mut slot, 11), None);5051- assert_eq!(slot, Some((12, "replacement")));5052- assert_eq!(take_generation_owned(&mut slot, 12), Some("replacement"));5053- assert_eq!(slot, None);5054-5055- let mut feedback = Some((22, "replacement connection", "replacement sender"));5056- assert_eq!(take_generation_owned_triple(&mut feedback, 21), None);5057- assert_eq!(5058- feedback,5059- Some((22, "replacement connection", "replacement sender"))5060- );5061- assert_eq!(5062- take_generation_owned_triple(&mut feedback, 22),5063- Some(("replacement connection", "replacement sender"))5064- );5065- assert_eq!(feedback, None);5066- }5067-50684619 50694620 fn pointer_feedback_channel_has_explicit_bounded_terminal_envelopes() {50704621 let position = serde_json::to_value(PointerFeedbackPayload::Position {@@ -5328,15 +4879,6 @@ mod tests {
53284879 assert_eq!(resumed.max_ms, Some(16.0));53294880 }53304881 5331- 5332- fn legacy_media_header_is_bounded_before_allocation() {5333- assert!(validate_legacy_media_header(1280, 800, 1024).is_ok());5334- assert!(validate_legacy_media_header(0, 800, 1024).is_err());5335- assert!(validate_legacy_media_header(1280, 800, 0).is_err());5336- assert!(validate_legacy_media_header(1280, 800, MAX_MEDIA_PAYLOAD_LEN + 1).is_err());5337- assert!(validate_legacy_media_header(u32::MAX, u32::MAX, 1024).is_err());5338- }5339-53404882 53414883 fn sequence_checks_reject_duplicates_regressions_and_overflow() {53424884 assert_eq!(sequence_gap(41, 42).unwrap(), 0);@@ -0,0 +1,483 @@
1+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};2+3+use serde::Serialize;4+use sigil_protocol::{MAX_MEDIA_PAYLOAD_LEN, MAX_VIDEO_DIMENSION, MAX_VIDEO_PIXELS};5+use tauri::{AppHandle, Emitter};6+7+pub(crate) fn byte_to_codec(value: u8) -> &'static str {8+ match value {9+ 1 => "h264",10+ 2 => "h265",11+ 3 => "av1",12+ _ => "h264",13+ }14+}15+16+17+pub struct FramePayload {18+ pub generation: u64,19+ pub width: u32,20+ pub height: u32,21+ pub data: String,22+ pub keyframe: bool,23+ pub codec: String,24+ pub capture_timestamp_micros: Option<u64>,25+ pub pts_micros: Option<i64>,26+ pub discontinuity: bool,27+}28+29+30+struct FrameErrorPayload {31+ generation: u64,32+ error: String,33+}34+35+pub(crate) fn emit_frame_error(app: &AppHandle, generation: u64, error: impl Into<String>) {36+ let _ = app.emit(37+ "frame-error",38+ FrameErrorPayload {39+ generation,40+ error: error.into(),41+ },42+ );43+}44+45+// Absorb brief webview→Rust acknowledgment jitter without allowing Tauri IPC46+// to grow without bound. Four 60 fps frames cap this handoff at about 67 ms;47+// WebCodecs has a separate, stricter decode-queue bound in the frontend.48+pub(crate) const CLIENT_FRAME_CHANNEL_CAPACITY: usize = 4;49+50+const FRAME_CHANNEL_MAGIC: [u8; 4] = *b"SGFR";51+const FRAME_CHANNEL_VERSION: u8 = 1;52+const FRAME_CHANNEL_HEADER_LEN: usize = 40;53+const FRAME_CHANNEL_FLAG_KEYFRAME: u8 = 1 << 0;54+const FRAME_CHANNEL_FLAG_DISCONTINUITY: u8 = 1 << 1;55+const FRAME_CHANNEL_FLAG_CODEC_CONFIG: u8 = 1 << 2;56+const FRAME_CHANNEL_OPTIONAL_U64_NONE: u64 = u64::MAX;57+const FRAME_CHANNEL_OPTIONAL_I64_NONE: i64 = i64::MIN;58+59+pub(crate) fn close_generation_connection<T>(60+ connection: Option<(u64, T)>,61+ close: impl FnOnce(T),62+) -> Option<u64> {63+ connection.map(|(generation, connection)| {64+ close(connection);65+ generation66+ })67+}68+69+pub(crate) fn take_generation_owned<T>(70+ slot: &mut Option<(u64, T)>,71+ expected_generation: u64,72+) -> Option<T> {73+ if slot74+ .as_ref()75+ .is_some_and(|(generation, _)| *generation == expected_generation)76+ {77+ slot.take().map(|(_, value)| value)78+ } else {79+ None80+ }81+}82+83+pub(crate) fn take_generation_owned_triple<T, U>(84+ slot: &mut Option<(u64, T, U)>,85+ expected_generation: u64,86+) -> Option<(T, U)> {87+ if slot88+ .as_ref()89+ .is_some_and(|(generation, _, _)| *generation == expected_generation)90+ {91+ slot.take().map(|(_, value, companion)| (value, companion))92+ } else {93+ None94+ }95+}96+97+pub(crate) struct FrameEnvelopeMetadata<'a> {98+ pub(crate) width: u32,99+ pub(crate) height: u32,100+ pub(crate) codec: &'a str,101+ pub(crate) keyframe: bool,102+ pub(crate) discontinuity: bool,103+ pub(crate) codec_config: bool,104+ pub(crate) sequence: Option<u64>,105+ pub(crate) capture_timestamp_micros: Option<u64>,106+ pub(crate) pts_micros: Option<i64>,107+}108+109+pub(crate) fn encode_frame_envelope(110+ metadata: FrameEnvelopeMetadata<'_>,111+ payload: &[u8],112+) -> Result<Vec<u8>, String> {113+ validate_legacy_media_header(metadata.width, metadata.height, payload.len())?;114+ if metadata.codec_config && !metadata.keyframe {115+ return Err("Frame codec configuration requires a keyframe".to_string());116+ }117+ if metadata.sequence == Some(FRAME_CHANNEL_OPTIONAL_U64_NONE) {118+ return Err("Frame sequence collides with the channel sentinel".to_string());119+ }120+ if metadata.capture_timestamp_micros == Some(FRAME_CHANNEL_OPTIONAL_U64_NONE) {121+ return Err("Capture timestamp collides with the channel sentinel".to_string());122+ }123+ if metadata.pts_micros == Some(FRAME_CHANNEL_OPTIONAL_I64_NONE) {124+ return Err("Frame PTS collides with the channel sentinel".to_string());125+ }126+ let width = u16::try_from(metadata.width).map_err(|_| {127+ format!(128+ "Frame width does not fit channel envelope: {}",129+ metadata.width130+ )131+ })?;132+ let height = u16::try_from(metadata.height).map_err(|_| {133+ format!(134+ "Frame height does not fit channel envelope: {}",135+ metadata.height136+ )137+ })?;138+ let payload_len = u32::try_from(payload.len()).map_err(|_| {139+ format!(140+ "Frame payload does not fit channel envelope: {}",141+ payload.len()142+ )143+ })?;144+ let codec = match metadata.codec {145+ "h264" => 1,146+ "h265" => 2,147+ "av1" => 3,148+ other => return Err(format!("Unsupported frame channel codec: {other}")),149+ };150+ let mut flags = 0_u8;151+ if metadata.keyframe {152+ flags |= FRAME_CHANNEL_FLAG_KEYFRAME;153+ }154+ if metadata.discontinuity {155+ flags |= FRAME_CHANNEL_FLAG_DISCONTINUITY;156+ }157+ if metadata.codec_config {158+ flags |= FRAME_CHANNEL_FLAG_CODEC_CONFIG;159+ }160+161+ let mut envelope = Vec::with_capacity(FRAME_CHANNEL_HEADER_LEN + payload.len());162+ envelope.extend_from_slice(&FRAME_CHANNEL_MAGIC);163+ envelope.push(FRAME_CHANNEL_VERSION);164+ envelope.push(codec);165+ envelope.push(flags);166+ envelope.push(0); // Reserved; the parser rejects non-zero values.167+ envelope.extend_from_slice(&width.to_be_bytes());168+ envelope.extend_from_slice(&height.to_be_bytes());169+ envelope.extend_from_slice(&payload_len.to_be_bytes());170+ envelope.extend_from_slice(171+ &metadata172+ .sequence173+ .unwrap_or(FRAME_CHANNEL_OPTIONAL_U64_NONE)174+ .to_be_bytes(),175+ );176+ envelope.extend_from_slice(177+ &metadata178+ .capture_timestamp_micros179+ .unwrap_or(FRAME_CHANNEL_OPTIONAL_U64_NONE)180+ .to_be_bytes(),181+ );182+ envelope.extend_from_slice(183+ &metadata184+ .pts_micros185+ .unwrap_or(FRAME_CHANNEL_OPTIONAL_I64_NONE)186+ .to_be_bytes(),187+ );188+ envelope.extend_from_slice(payload);189+ debug_assert_eq!(envelope.len(), FRAME_CHANNEL_HEADER_LEN + payload.len());190+ Ok(envelope)191+}192+193+pub(crate) fn try_reserve_frame_channel_slot(in_flight: &AtomicUsize) -> bool {194+ in_flight195+ .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {196+ (current < CLIENT_FRAME_CHANNEL_CAPACITY).then_some(current + 1)197+ })198+ .is_ok()199+}200+201+pub(crate) fn release_frame_channel_slot(in_flight: &AtomicUsize) {202+ let _ = in_flight.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {203+ current.checked_sub(1)204+ });205+}206+207+pub(crate) fn release_frame_channel_slot_for_generation(208+ in_flight: &AtomicUsize,209+ current_generation: u64,210+ generation: u64,211+) -> bool {212+ if generation == 0 || current_generation != generation {213+ return false;214+ }215+ release_frame_channel_slot(in_flight);216+ true217+}218+219+pub(crate) fn next_media_generation(counter: &AtomicU64) -> Result<u64, String> {220+ counter221+ .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {222+ current.checked_add(1)223+ })224+ .map(|previous| previous + 1)225+ .map_err(|_| "Media connection generation overflowed".to_string())226+}227+228+pub(crate) fn validate_legacy_media_header(229+ width: u32,230+ height: u32,231+ payload_len: usize,232+) -> Result<(), String> {233+ if width == 0234+ || height == 0235+ || width > u32::from(MAX_VIDEO_DIMENSION)236+ || height > u32::from(MAX_VIDEO_DIMENSION)237+ || width.saturating_mul(height) > MAX_VIDEO_PIXELS238+ {239+ return Err(format!("Invalid legacy media dimensions: {width}x{height}"));240+ }241+ if payload_len == 0 || payload_len > MAX_MEDIA_PAYLOAD_LEN {242+ return Err(format!(243+ "Invalid legacy media payload length: {payload_len}"244+ ));245+ }246+ Ok(())247+}248+249+250+mod tests {251+ use super::*;252+253+ 254+ fn binary_frame_envelope_has_exact_stable_layout() {255+ let payload = [0, 0, 0, 1, 0x65];256+ let envelope = encode_frame_envelope(257+ FrameEnvelopeMetadata {258+ width: 1280,259+ height: 800,260+ codec: "h264",261+ keyframe: true,262+ discontinuity: true,263+ codec_config: true,264+ sequence: Some(42),265+ capture_timestamp_micros: Some(123_456),266+ pts_micros: Some(98_765),267+ },268+ &payload,269+ )270+ .unwrap();271+272+ assert_eq!(envelope.len(), FRAME_CHANNEL_HEADER_LEN + payload.len());273+ assert_eq!(&envelope[0..4], b"SGFR");274+ assert_eq!(envelope[4], 1);275+ assert_eq!(envelope[5], 1);276+ assert_eq!(envelope[6], 0b111);277+ assert_eq!(envelope[7], 0);278+ assert_eq!(&envelope[8..10], &1280_u16.to_be_bytes());279+ assert_eq!(&envelope[10..12], &800_u16.to_be_bytes());280+ assert_eq!(&envelope[12..16], &5_u32.to_be_bytes());281+ assert_eq!(&envelope[16..24], &42_u64.to_be_bytes());282+ assert_eq!(&envelope[24..32], &123_456_u64.to_be_bytes());283+ assert_eq!(&envelope[32..40], &98_765_i64.to_be_bytes());284+ assert_eq!(&envelope[40..], payload);285+ }286+287+ 288+ fn binary_frame_envelope_uses_explicit_optional_sentinels() {289+ let envelope = encode_frame_envelope(290+ FrameEnvelopeMetadata {291+ width: 1,292+ height: 1,293+ codec: "av1",294+ keyframe: false,295+ discontinuity: false,296+ codec_config: false,297+ sequence: None,298+ capture_timestamp_micros: None,299+ pts_micros: None,300+ },301+ &[1],302+ )303+ .unwrap();304+ assert_eq!(envelope[5], 3);305+ assert_eq!(&envelope[16..24], &u64::MAX.to_be_bytes());306+ assert_eq!(&envelope[24..32], &u64::MAX.to_be_bytes());307+ assert_eq!(&envelope[32..40], &i64::MIN.to_be_bytes());308+ }309+310+ 311+ fn binary_frame_envelope_rejects_invalid_metadata_before_sending() {312+ let metadata = |codec| FrameEnvelopeMetadata {313+ width: 1280,314+ height: 800,315+ codec,316+ keyframe: false,317+ discontinuity: false,318+ codec_config: false,319+ sequence: None,320+ capture_timestamp_micros: None,321+ pts_micros: None,322+ };323+ assert!(encode_frame_envelope(metadata("vp9"), &[1]).is_err());324+ assert!(encode_frame_envelope(metadata("h264"), &[]).is_err());325+ assert!(326+ encode_frame_envelope(327+ FrameEnvelopeMetadata {328+ codec_config: true,329+ ..metadata("h264")330+ },331+ &[1]332+ )333+ .is_err()334+ );335+ assert!(336+ encode_frame_envelope(337+ FrameEnvelopeMetadata {338+ sequence: Some(u64::MAX),339+ ..metadata("h264")340+ },341+ &[1]342+ )343+ .is_err()344+ );345+ assert!(346+ encode_frame_envelope(347+ FrameEnvelopeMetadata {348+ pts_micros: Some(i64::MIN),349+ ..metadata("h264")350+ },351+ &[1]352+ )353+ .is_err()354+ );355+ assert!(356+ encode_frame_envelope(357+ FrameEnvelopeMetadata {358+ width: u32::from(MAX_VIDEO_DIMENSION) + 1,359+ ..metadata("h264")360+ },361+ &[1]362+ )363+ .is_err()364+ );365+ }366+367+ 368+ fn frame_channel_slots_are_bounded_and_cannot_underflow() {369+ let in_flight = AtomicUsize::new(0);370+ release_frame_channel_slot(&in_flight);371+ assert_eq!(in_flight.load(Ordering::SeqCst), 0);372+373+ for expected in 1..=CLIENT_FRAME_CHANNEL_CAPACITY {374+ assert!(try_reserve_frame_channel_slot(&in_flight));375+ assert_eq!(in_flight.load(Ordering::SeqCst), expected);376+ }377+ assert!(!try_reserve_frame_channel_slot(&in_flight));378+ assert_eq!(379+ in_flight.load(Ordering::SeqCst),380+ CLIENT_FRAME_CHANNEL_CAPACITY381+ );382+383+ for expected in (0..CLIENT_FRAME_CHANNEL_CAPACITY).rev() {384+ release_frame_channel_slot(&in_flight);385+ assert_eq!(in_flight.load(Ordering::SeqCst), expected);386+ }387+ release_frame_channel_slot(&in_flight);388+ assert_eq!(in_flight.load(Ordering::SeqCst), 0);389+ }390+391+ 392+ fn frame_acknowledgments_release_only_the_matching_generation() {393+ let in_flight = AtomicUsize::new(2);394+ let generation = 9;395+396+ assert!(!release_frame_channel_slot_for_generation(397+ &in_flight, generation, 8398+ ));399+ assert!(!release_frame_channel_slot_for_generation(400+ &in_flight, generation, 0401+ ));402+ assert_eq!(in_flight.load(Ordering::SeqCst), 2);403+ assert!(release_frame_channel_slot_for_generation(404+ &in_flight, generation, 9405+ ));406+ assert_eq!(in_flight.load(Ordering::SeqCst), 1);407+ }408+409+ 410+ fn global_frame_events_serialize_their_media_generation() {411+ let frame = serde_json::to_value(FramePayload {412+ generation: 17,413+ width: 1280,414+ height: 800,415+ data: "jpeg".to_string(),416+ keyframe: true,417+ codec: "h264".to_string(),418+ capture_timestamp_micros: Some(1),419+ pts_micros: Some(2),420+ discontinuity: false,421+ })422+ .unwrap();423+ let error = serde_json::to_value(FrameErrorPayload {424+ generation: 17,425+ error: "closed".to_string(),426+ })427+ .unwrap();428+429+ assert_eq!(frame["generation"], 17);430+ assert_eq!(error["generation"], 17);431+ assert_eq!(error["error"], "closed");432+ }433+434+ 435+ fn generation_connection_close_retires_exactly_one_owned_handle() {436+ let closes = AtomicUsize::new(0);437+ let retired = close_generation_connection(Some((7, "media")), |connection| {438+ assert_eq!(connection, "media");439+ closes.fetch_add(1, Ordering::SeqCst);440+ });441+ assert_eq!(retired, Some(7));442+ assert_eq!(closes.load(Ordering::SeqCst), 1);443+444+ let absent: Option<(u64, &str)> = None;445+ assert_eq!(446+ close_generation_connection(absent, |_| {447+ closes.fetch_add(1, Ordering::SeqCst);448+ }),449+ None450+ );451+ assert_eq!(closes.load(Ordering::SeqCst), 1);452+ }453+454+ 455+ fn generation_owned_retirement_cannot_take_a_replacement_session() {456+ let mut slot = Some((12, "replacement"));457+ assert_eq!(take_generation_owned(&mut slot, 11), None);458+ assert_eq!(slot, Some((12, "replacement")));459+ assert_eq!(take_generation_owned(&mut slot, 12), Some("replacement"));460+ assert_eq!(slot, None);461+462+ let mut feedback = Some((22, "replacement connection", "replacement sender"));463+ assert_eq!(take_generation_owned_triple(&mut feedback, 21), None);464+ assert_eq!(465+ feedback,466+ Some((22, "replacement connection", "replacement sender"))467+ );468+ assert_eq!(469+ take_generation_owned_triple(&mut feedback, 22),470+ Some(("replacement connection", "replacement sender"))471+ );472+ assert_eq!(feedback, None);473+ }474+475+ 476+ fn legacy_media_header_is_bounded_before_allocation() {477+ assert!(validate_legacy_media_header(1280, 800, 1024).is_ok());478+ assert!(validate_legacy_media_header(0, 800, 1024).is_err());479+ assert!(validate_legacy_media_header(1280, 800, 0).is_err());480+ assert!(validate_legacy_media_header(1280, 800, MAX_MEDIA_PAYLOAD_LEN + 1).is_err());481+ assert!(validate_legacy_media_header(u32::MAX, u32::MAX, 1024).is_err());482+ }483+}