1#[cfg(feature = "capture")]
12use std::time::Instant;
13
14use moq_mux::catalog::hang::CatalogExt;
15#[cfg(feature = "capture")]
16use moq_mux::rate::{Control, Policy};
17#[cfg(any(feature = "capture", test))]
18use moq_net::Timestamp;
19
20use crate::Error;
21#[cfg(feature = "capture")]
22use crate::Rate;
23#[cfg(feature = "capture")]
24use crate::capture;
25
26use super::Encoded;
27#[cfg(feature = "capture")]
28use super::Sink;
29#[cfg(any(feature = "capture", test))]
30use super::encoder;
31#[cfg(feature = "capture")]
32use super::encoder::Codec;
33
34#[cfg(feature = "capture")]
36const DEFAULT_FRAMERATE: Rate = Rate::integer(30);
37
38fn rendition_hint(rendition: hang::catalog::VideoConfig) -> moq_mux::catalog::VideoHint {
40 let mut hint = moq_mux::catalog::VideoHint::default();
41 hint.codec = Some(rendition.codec);
42 hint.coded_width = rendition.coded_width;
43 hint.coded_height = rendition.coded_height;
44 hint.display_aspect_width = rendition.display_aspect_width;
45 hint.display_aspect_height = rendition.display_aspect_height;
46 hint.framerate = rendition.framerate;
47 hint.bitrate = rendition.bitrate;
48 hint.optimize_for_latency = rendition.optimize_for_latency;
49 hint.container = rendition.container;
52 hint
53}
54
55enum Codecs {
58 H264 {
59 split: moq_mux::codec::h264::Split,
60 import: moq_mux::codec::h264::Import,
61 },
62 H265 {
63 split: moq_mux::codec::h265::Split,
64 import: moq_mux::codec::h265::Import,
65 },
66}
67
68pub struct Producer<E: CatalogExt = ()> {
79 codecs: Codecs,
80 _ext: std::marker::PhantomData<fn() -> E>,
81}
82
83impl<E: CatalogExt> Producer<E> {
84 pub fn new(
96 broadcast: moq_net::broadcast::Producer,
97 catalog: moq_mux::catalog::Producer<E>,
98 rendition: hang::catalog::VideoConfig,
99 ) -> Result<Self, Error> {
100 let suffix = match &rendition.codec {
101 hang::catalog::VideoCodec::H264(_) => ".avc3",
102 hang::catalog::VideoCodec::H265(_) => ".hev1",
103 other => {
104 return Err(Error::Codec(anyhow::anyhow!(
105 "{other} is not a codec this producer can publish"
106 )));
107 }
108 };
109 let track = broadcast.unique_track(suffix, catalog.track_info(hang::catalog::PRIORITY.video))?;
110 Self::with_track(track, catalog, rendition)
111 }
112
113 pub fn with_track(
118 track: moq_net::track::Producer,
119 catalog: moq_mux::catalog::Producer<E>,
120 rendition: hang::catalog::VideoConfig,
121 ) -> Result<Self, Error> {
122 let codecs = match &rendition.codec {
123 hang::catalog::VideoCodec::H264(_) => Codecs::H264 {
124 split: moq_mux::codec::h264::Split::new(),
125 import: moq_mux::codec::h264::Import::new(track, catalog.reserve(), rendition_hint(rendition))?,
126 },
127 hang::catalog::VideoCodec::H265(_) => Codecs::H265 {
128 split: moq_mux::codec::h265::Split::new(),
129 import: moq_mux::codec::h265::Import::new(track, catalog.reserve(), rendition_hint(rendition))?,
130 },
131 other => {
133 return Err(Error::Codec(anyhow::anyhow!(
134 "{other} is not a codec this producer can publish"
135 )));
136 }
137 };
138 Ok(Self {
139 codecs,
140 _ext: std::marker::PhantomData,
141 })
142 }
143
144 pub fn demand(&self) -> moq_net::track::Demand {
148 match &self.codecs {
149 Codecs::H264 { import, .. } => import.demand(),
150 Codecs::H265 { import, .. } => import.demand(),
151 }
152 }
153
154 pub fn publish(&mut self, encoded: &[Encoded]) -> Result<(), Error> {
157 for frame in encoded {
158 let timestamp = Some(frame.timestamp);
159 match &mut self.codecs {
161 Codecs::H264 { split, import } => {
162 let mut frames = split.decode(&frame.payload, timestamp)?;
163 frames.extend(split.flush(timestamp)?);
164 import.decode(frames)?;
165 }
166 Codecs::H265 { split, import } => {
167 let mut frames = split.decode(&frame.payload, timestamp)?;
168 frames.extend(split.flush(timestamp)?);
169 import.decode(frames)?;
170 }
171 }
172 }
173 Ok(())
174 }
175
176 pub fn observe_lag(&mut self, lag: std::time::Duration) -> Result<(), Error> {
178 match &mut self.codecs {
179 Codecs::H264 { import, .. } => import.observe_lag(lag)?,
180 Codecs::H265 { import, .. } => import.observe_lag(lag)?,
181 }
182 Ok(())
183 }
184
185 pub fn tick(&mut self) -> Result<(), Error> {
187 match &mut self.codecs {
188 Codecs::H264 { import, .. } => import.tick()?,
189 Codecs::H265 { import, .. } => import.tick()?,
190 }
191 Ok(())
192 }
193
194 pub fn idle(&mut self) -> Result<(), Error> {
196 match &mut self.codecs {
197 Codecs::H264 { import, .. } => import.idle()?,
198 Codecs::H265 { import, .. } => import.idle()?,
199 }
200 Ok(())
201 }
202
203 pub fn discontinuity(&mut self) -> Result<(), Error> {
211 match &mut self.codecs {
212 Codecs::H264 { import, .. } => import.discontinuity()?,
213 Codecs::H265 { import, .. } => import.discontinuity()?,
214 }
215 Ok(())
216 }
217
218 pub fn finish(&mut self) -> Result<(), Error> {
223 match &mut self.codecs {
224 Codecs::H264 { import, .. } => import.finish()?,
225 Codecs::H265 { import, .. } => import.finish()?,
226 }
227 Ok(())
228 }
229
230 pub fn abort(self, err: moq_net::Error) {
235 match self.codecs {
236 Codecs::H264 { import, .. } => import.abort(err),
237 Codecs::H265 { import, .. } => import.abort(err),
238 }
239 }
240}
241
242#[derive(Clone, Debug, Default)]
250#[non_exhaustive]
251#[cfg(feature = "capture")]
252pub struct Options {
253 pub bitrate: Option<moq_net::bandwidth::Rate>,
259 pub codec: Codec,
261 pub kind: encoder::Kind,
263 pub bandwidth: moq_net::bandwidth::Allocator,
278}
279
280#[cfg(feature = "capture")]
292pub async fn publish_capture<E: CatalogExt>(
293 broadcast: moq_net::broadcast::Producer,
294 catalog: moq_mux::catalog::Producer<E>,
295 capture: capture::Config,
296 encode: Options,
297 clock: moq_mux::Clock,
298) -> Result<(), Error> {
299 let rendition = {
304 let camera = capture::open(&capture).await?;
305 let mut probe_config = encoder::Config::new(
306 camera.width(),
307 camera.height(),
308 capture
309 .framerate
310 .or_else(|| camera.framerate())
311 .unwrap_or(DEFAULT_FRAMERATE),
312 );
313 probe_config.bitrate = encode.bitrate;
314 probe_config.codec = encode.codec;
315 probe_config.kind = encode.kind.clone();
316 probe_config.color = camera.color();
317 probe_config.probe().await?
318 };
319
320 let mut producer = Producer::new(broadcast, catalog, rendition)?;
321 let demand = producer.demand();
322
323 let result = capture_loop(&mut producer, &demand, &capture, &encode, &clock).await;
324
325 match &result {
329 Ok(()) => {
331 if let Err(err) = producer.finish() {
332 tracing::debug!(error = %err, "video track finish after capture ended");
333 }
334 }
335 Err(err) => producer.abort(moq_net::Error::Transport(err.to_string())),
337 }
338 result
339}
340
341#[cfg(all(feature = "capture", not(target_os = "macos")))]
347#[allow(dead_code)]
348fn assert_publish_capture_send(
349 broadcast: moq_net::broadcast::Producer,
350 catalog: moq_mux::catalog::Producer,
351 capture: capture::Config,
352 encode: Options,
353 clock: moq_mux::Clock,
354) {
355 fn is_send<T: Send>(_: &T) {}
356 is_send(&publish_capture(broadcast, catalog, capture, encode, clock));
357}
358
359#[cfg(feature = "capture")]
365type RateControl = Option<(moq_net::bandwidth::Consumer, Control)>;
366
367#[cfg(feature = "capture")]
372async fn next_estimate(rate: &mut RateControl) -> Option<Option<moq_net::bandwidth::Rate>> {
373 match rate {
374 Some((bandwidth, _)) => bandwidth.changed().await.ok(),
375 None => std::future::pending().await,
377 }
378}
379
380#[cfg(feature = "capture")]
386async fn apply_estimate(
387 encoder: &mut Sink,
388 rate: &mut RateControl,
389 estimate: Option<Option<moq_net::bandwidth::Rate>>,
390) {
391 let Some((_, control)) = rate.as_mut() else { return };
392
393 let Some(estimate) = estimate else {
394 tracing::debug!("bandwidth estimate ended; holding the current encoder bitrate");
395 *rate = None;
396 return;
397 };
398
399 let Some(bitrate) = control.update(estimate, Instant::now()) else {
400 return;
401 };
402
403 match encoder.set_bitrate(bitrate).await {
404 Ok(()) => tracing::debug!(bitrate = bitrate.as_bps(), "adjusted encoder bitrate"),
405 Err(Error::BitrateUnsupported(name)) => {
409 tracing::warn!(encoder = name, "encoder cannot follow the bandwidth estimate");
410 *rate = None;
411 }
412 Err(err) => tracing::warn!(error = %err, bitrate = bitrate.as_bps(), "failed to adjust encoder bitrate"),
416 }
417}
418
419#[cfg(feature = "capture")]
423fn log_track_ended(err: moq_net::Error) {
424 if matches!(err, moq_net::Error::Dropped | moq_net::Error::Closed) {
425 tracing::debug!("video track no longer announced; stopping capture");
426 } else {
427 tracing::warn!(error = %err, "video track aborted; stopping capture");
428 }
429}
430
431#[cfg(any(feature = "capture", all(test, feature = "openh264")))]
432fn capture_stopped<E: CatalogExt>(producer: &mut Producer<E>) -> Result<(), Error> {
433 producer.discontinuity()
436}
437
438#[cfg(feature = "capture")]
441async fn wait_capture<E: CatalogExt, T>(
442 producer: &mut Producer<E>,
443 demand: &moq_net::track::Demand,
444 work: impl std::future::Future<Output = Result<T, Error>>,
445) -> Result<Option<T>, Error> {
446 let mut work = std::pin::pin!(work);
447 let mut timer = tokio::time::interval(hang::catalog::stalled::DEFAULT_INTERVAL);
448 loop {
449 tokio::select! {
450 biased;
451 res = demand.unused() => {
452 if let Err(err) = res {
453 log_track_ended(err);
454 }
455 producer.idle()?;
456 return Ok(None);
457 }
458 _ = timer.tick() => producer.tick()?,
459 res = &mut work => return res.map(Some),
460 }
461 }
462}
463
464#[cfg(feature = "capture")]
474async fn capture_loop<E: CatalogExt>(
475 producer: &mut Producer<E>,
476 demand: &moq_net::track::Demand,
477 capture: &capture::Config,
478 encode: &Options,
479 clock: &moq_mux::Clock,
480) -> Result<(), Error> {
481 let mut reservation: Option<moq_net::bandwidth::Reservation> = None;
485
486 loop {
487 if let Err(err) = demand.used().await {
491 log_track_ended(err);
492 return Ok(());
493 }
494
495 let Some(mut camera) = wait_capture(producer, demand, capture::open(capture)).await? else {
497 continue;
498 };
499 let capture_epoch =
503 u64::try_from(clock.now().as_micros().saturating_sub(camera.now().as_micros())).unwrap_or(u64::MAX);
504 let framerate = capture
507 .framerate
508 .or_else(|| camera.framerate())
509 .unwrap_or(DEFAULT_FRAMERATE);
510 let mut encoder_config = encoder::Config::new(camera.width(), camera.height(), framerate);
511 encoder_config.bitrate = encode.bitrate;
512 encoder_config.codec = encode.codec;
513 encoder_config.kind = encode.kind.clone();
514 encoder_config.color = camera.color();
515 let Some(mut encoder) = wait_capture(producer, demand, Sink::open(&encoder_config)).await? else {
520 continue;
521 };
522 tracing::info!(encoder = encoder.name(), device = camera.label(), "capturing");
523
524 let ceiling = encoder_config.resolved_bitrate();
529 let reservation = reservation.get_or_insert_with(|| encode.bandwidth.reserve(demand, ceiling));
530 reservation.update(ceiling);
531
532 let mut rate = Some((reservation.consumer(), Control::new(Policy::new(ceiling))));
537
538 loop {
539 let interval = hang::catalog::stalled::interval_from_fps(Some(framerate.as_f64()));
543 let frame = tokio::select! {
544 biased;
545 res = demand.unused() => {
546 if let Err(err) = res {
547 log_track_ended(err);
548 return Ok(());
549 }
550 break; }
552 estimate = next_estimate(&mut rate) => {
555 apply_estimate(&mut encoder, &mut rate, estimate).await;
556 continue;
557 }
558 frame = tokio::time::timeout(interval, camera.read()) => match frame {
562 Ok(frame) => frame?,
563 Err(_) => {
564 producer.tick()?;
565 continue;
566 }
567 },
568 };
569
570 let Some(mut frame) = frame else { break };
571 frame.timestamp = map_capture_timestamp(capture_epoch, frame.timestamp)?;
572 let started = Instant::now();
573 let Some(encoded) = wait_capture(producer, demand, encoder.encode(frame)).await? else {
574 break;
575 };
576 let lag = started.elapsed();
577 producer.observe_lag(lag)?;
578 producer.publish(&encoded)?;
579 }
580
581 drop(camera);
583 drop(encoder);
584 producer.idle()?;
585 capture_stopped(producer)?;
586 tracing::info!("capture stopped; released source");
587 }
588}
589
590#[cfg(feature = "capture")]
591fn map_capture_timestamp(epoch_micros: u64, timestamp: Timestamp) -> Result<Timestamp, Error> {
592 let capture_micros = u64::try_from(timestamp.as_micros()).unwrap_or(u64::MAX);
593 Ok(Timestamp::from_micros(epoch_micros.saturating_add(capture_micros))?)
594}
595
596#[cfg(test)]
597mod tests {
598 #![cfg_attr(not(feature = "openh264"), allow(dead_code, unused_imports))]
599
600 use moq_mux::catalog::Stream as _;
601
602 use super::*;
603 use crate::Frame;
604 use crate::encode::{Codec, Config, Encoder};
605
606 #[cfg(feature = "capture")]
607 #[test]
608 fn capture_clock_mapping_is_monotonic() {
609 let first = map_capture_timestamp(10_000, Timestamp::from_micros(2_000).unwrap()).unwrap();
610 let second = map_capture_timestamp(10_000, Timestamp::from_micros(2_001).unwrap()).unwrap();
611 assert!(second > first);
612 }
613
614 async fn roundtrip_rendition(codec: Codec, kind: encoder::Kind) -> (String, hang::catalog::VideoConfig) {
624 let mut broadcast = moq_net::broadcast::Info::new().produce();
625 let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
626
627 let mut config = Config::new(320, 240, crate::Rate::new(30, 1).unwrap());
628 config.codec = codec;
629 config.kind = kind;
630
631 let mut producer = Producer::new(broadcast, catalog.clone(), config.probe().await.unwrap()).unwrap();
632 let advertised = rendition(&catalog).expect("the rendition publishes before any frame").1;
633
634 let mut encoder = Encoder::new(&config).unwrap();
635 assert_eq!(encoder.codec(), codec);
636
637 let rgba = vec![0x80u8; 320 * 240 * 4];
638 for i in 0..10u64 {
639 let surface = crate::Surface::rgba(&rgba, crate::Size::new(320, 240)).unwrap();
640 let frame = Frame::new(surface, Timestamp::from_micros(i * 33_333).unwrap());
641 producer.publish(&encoder.encode(&frame).unwrap()).unwrap();
642 }
643 producer.publish(&encoder.finish().unwrap()).unwrap();
644
645 let (name, resolved) = rendition(&catalog).expect("the importer should have registered a video rendition");
646 let (mut before, mut after) = (advertised, resolved.clone());
648 before.jitter = None;
649 after.jitter = None;
650 assert_eq!(
651 before, after,
652 "the first keyframe should confirm the advertised rendition, not correct it"
653 );
654 (name, resolved)
655 }
656
657 fn rendition(catalog: &moq_mux::catalog::Producer) -> Option<(String, hang::catalog::VideoConfig)> {
659 let snapshot = catalog.snapshot();
660 let (name, config) = snapshot.video.renditions.iter().next()?;
661 Some((name.clone(), config.clone()))
662 }
663
664 async fn collect_groups(mut consumer: moq_net::track::Subscriber) -> Vec<usize> {
665 let mut groups = Vec::new();
666 while let Some(mut group) = consumer.recv_group().await.unwrap() {
667 let mut frames = 0;
668 while group.next_frame().await.unwrap().is_some() {
669 frames += 1;
670 }
671 groups.push(frames);
672 }
673 groups
674 }
675
676 #[tokio::test]
680 #[cfg(feature = "openh264")]
681 async fn idle_capture_publishes_a_discontinuity_before_resume() {
682 let mut broadcast = moq_net::broadcast::Info::new().produce();
683 let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
684 let replay = std::time::Duration::from_secs(11);
687 let track = broadcast
688 .create_track(
689 "video",
690 catalog.track_info(hang::catalog::PRIORITY.video).with_max_age(replay),
691 )
692 .unwrap();
693 let consumer = track.subscribe(moq_net::track::Subscription::default().with_max_age(replay));
694
695 let mut config = Config::new(320, 240, crate::Rate::new(30, 1).unwrap());
696 config.kind = encoder::Kind::Software;
697 let mut producer = Producer::with_track(track, catalog, config.probe().await.unwrap()).unwrap();
698 let mut encoder = Encoder::new(&config).unwrap();
699 let rgba = vec![0x80u8; 320 * 240 * 4];
700
701 for timestamp in [0, 10_000_000] {
702 if timestamp > 0 {
703 capture_stopped(&mut producer).unwrap();
704 }
705 encoder.cut().unwrap();
706 let surface = crate::Surface::rgba(&rgba, crate::Size::new(320, 240)).unwrap();
707 let frame = Frame::new(surface, Timestamp::from_micros(timestamp).unwrap());
708 producer.publish(&encoder.encode(&frame).unwrap()).unwrap();
709 }
710 producer.finish().unwrap();
711
712 assert_eq!(collect_groups(consumer).await, vec![1, 1, 1]);
713 }
714
715 #[tokio::test]
716 #[cfg(feature = "openh264")]
717 async fn source_resize_updates_the_published_rendition() {
718 let mut broadcast = moq_net::broadcast::Info::new().produce();
719 let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
720 let mut initial = Config::new(320, 240, crate::Rate::new(30, 1).unwrap());
721 initial.kind = encoder::Kind::Software;
722 let mut producer = Producer::new(broadcast, catalog.clone(), initial.probe().await.unwrap()).unwrap();
723
724 for (timestamp, config) in [
725 (0, initial),
726 (33_333, Config::new(640, 360, crate::Rate::new(30, 1).unwrap())),
727 ] {
728 let mut config = config;
729 config.kind = encoder::Kind::Software;
730 let mut encoder = Encoder::new(&config).unwrap();
731 encoder.cut().unwrap();
732 let rgba = vec![0x80u8; usize::try_from(config.width * config.height * 4).unwrap()];
733 let surface = crate::Surface::rgba(&rgba, crate::Size::new(config.width, config.height)).unwrap();
734 let frame = Frame::new(surface, Timestamp::from_micros(timestamp).unwrap());
735 producer.publish(&encoder.encode(&frame).unwrap()).unwrap();
736 capture_stopped(&mut producer).unwrap();
737 }
738
739 let (_, rendition) = rendition(&catalog).expect("the resized rendition should be published");
740 assert_eq!(rendition.coded_width, Some(640));
741 assert_eq!(rendition.coded_height, Some(360));
742 }
743
744 #[tokio::test]
750 #[cfg(feature = "openh264")]
751 async fn a_selected_container_survives_the_rendition_hint() {
752 let mut broadcast = moq_net::broadcast::Info::new().produce();
753 let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
754
755 let mut config = Config::new(320, 240, crate::Rate::new(30, 1).unwrap());
756 config.kind = encoder::Kind::Software;
758 let mut selected = config.probe().await.unwrap();
759 selected.container = hang::catalog::Container::Loc;
760
761 let _producer = Producer::new(broadcast, catalog.clone(), selected).unwrap();
762
763 let (_, published) = rendition(&catalog).expect("the rendition publishes before any frame");
764 assert_eq!(published.container, hang::catalog::Container::Loc);
765 }
766
767 #[tokio::test]
775 #[cfg(feature = "openh264")]
776 async fn the_rendition_reaches_the_wire_before_the_first_frame() {
777 let mut broadcast = moq_net::broadcast::Info::new().produce();
778 let consumer = broadcast.consume();
779 let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
780
781 let mut config = Config::new(1920, 1080, crate::Rate::new(30, 1).unwrap());
782 config.bitrate = Some(moq_net::bandwidth::Rate::from_mbps(6));
783 config.kind = encoder::Kind::Software;
785 let _producer = Producer::new(broadcast, catalog, config.probe().await.unwrap()).unwrap();
786
787 let mut stream = moq_mux::catalog::Consumer::<()>::new(&consumer, moq_mux::catalog::CatalogFormat::Hang)
789 .await
790 .unwrap();
791 let snapshot = stream.next().await.unwrap().expect("a catalog before any frame");
792
793 let (name, rendition) = snapshot
794 .video
795 .renditions
796 .iter()
797 .next()
798 .expect("the track must be discoverable before it has encoded anything");
799 assert!(name.ends_with(".avc3"));
800
801 let hang::catalog::VideoCodec::H264(h264) = &rendition.codec else {
804 panic!("expected H.264, got {}", rendition.codec)
805 };
806 assert!(h264.inline, "an avc3 track carries its parameter sets in band");
807 assert_eq!(rendition.coded_width, Some(1920));
808 assert_eq!(rendition.coded_height, Some(1080));
809 assert_eq!(rendition.framerate, Some(30.0));
811 assert_eq!(rendition.bitrate, Some(6_000_000));
812 }
813
814 #[tokio::test]
816 #[cfg(feature = "openh264")]
817 async fn abort_after_finish() {
818 let mut broadcast = moq_net::broadcast::Info::new().produce();
819 let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap();
820 let mut config = Config::new(320, 240, crate::Rate::new(30, 1).unwrap());
821 config.kind = encoder::Kind::Software;
822 let track = broadcast
823 .create_track("video", catalog.track_info(hang::catalog::PRIORITY.video))
824 .unwrap();
825 let mut subscriber = track.subscribe(None);
826 let mut producer = Producer::with_track(track, catalog, config.probe().await.unwrap()).unwrap();
827 let mut encoder = Encoder::new(&config).unwrap();
828 let rgba = vec![0x80u8; 320 * 240 * 4];
829 let surface = crate::Surface::rgba(&rgba, crate::Size::new(320, 240)).unwrap();
830 let frame = Frame::new(surface, Timestamp::from_micros(0).unwrap());
831 producer.publish(&encoder.encode(&frame).unwrap()).unwrap();
832
833 producer.finish().unwrap();
834 assert!(subscriber.recv_group().await.unwrap().is_some());
835 producer.abort(moq_net::Error::Cancel);
836 }
837
838 #[tokio::test]
839 #[cfg(feature = "openh264")]
840 async fn h264_roundtrip_publishes_avc3() {
841 let (name, config) = roundtrip_rendition(Codec::H264, encoder::Kind::Software).await;
844 assert!(name.ends_with(".avc3"));
845 assert_eq!(config.coded_width, Some(320));
846 assert_eq!(config.coded_height, Some(240));
847 }
848
849 #[cfg(target_os = "macos")]
852 #[tokio::test]
853 async fn h265_roundtrip_publishes_hev1() {
854 let (name, config) = roundtrip_rendition(Codec::H265, encoder::Kind::Hardware).await;
855 assert!(name.ends_with(".hev1"));
856 assert_eq!(config.coded_width, Some(320));
857 assert_eq!(config.coded_height, Some(240));
858 }
859}