1use ff_format::{AudioCodec, VideoCodec};
28
29use crate::error::StreamError;
30use crate::rtmp_inner::RtmpInner;
31
32pub struct RtmpOutput {
46 url: String,
47 video_width: Option<u32>,
48 video_height: Option<u32>,
49 fps: Option<f64>,
50 sample_rate: u32,
51 channels: u32,
52 video_codec: VideoCodec,
53 audio_codec: AudioCodec,
54 video_bitrate: u64,
55 audio_bitrate: u64,
56 inner: Option<RtmpInner>,
57 finished: bool,
58}
59
60impl RtmpOutput {
61 #[must_use]
74 pub fn new(url: &str) -> Self {
75 Self {
76 url: url.to_owned(),
77 video_width: None,
78 video_height: None,
79 fps: None,
80 sample_rate: 44100,
81 channels: 2,
82 video_codec: VideoCodec::H264,
83 audio_codec: AudioCodec::Aac,
84 video_bitrate: 4_000_000,
85 audio_bitrate: 128_000,
86 inner: None,
87 finished: false,
88 }
89 }
90
91 pub fn build(mut self) -> Result<Self, StreamError> {
106 if !self.url.starts_with("rtmp://") {
107 return Err(StreamError::InvalidConfig {
108 reason: "RtmpOutput URL must start with rtmp://".into(),
109 });
110 }
111
112 let (Some(width), Some(height), Some(fps)) =
113 (self.video_width, self.video_height, self.fps)
114 else {
115 return Err(StreamError::InvalidConfig {
116 reason: "video parameters not set; call .video(width, height, fps) before .build()"
117 .into(),
118 });
119 };
120
121 if self.video_codec != VideoCodec::H264 {
122 return Err(StreamError::UnsupportedCodec {
123 codec: format!("{:?}", self.video_codec),
124 reason: "RTMP/FLV requires H.264 video".into(),
125 });
126 }
127
128 if self.audio_codec != AudioCodec::Aac {
129 return Err(StreamError::UnsupportedCodec {
130 codec: format!("{:?}", self.audio_codec),
131 reason: "RTMP/FLV requires AAC audio".into(),
132 });
133 }
134
135 #[allow(clippy::cast_possible_truncation)]
136 let fps_int = fps.round().max(1.0) as i32;
137
138 let inner = RtmpInner::open(
139 &self.url,
140 width.cast_signed(),
141 height.cast_signed(),
142 fps_int,
143 self.video_bitrate,
144 self.sample_rate.cast_signed(),
145 self.channels.cast_signed(),
146 self.audio_bitrate.cast_signed(),
147 )?;
148
149 self.inner = Some(inner);
150 Ok(self)
151 }
152}
153
154impl_live_stream_setters!(RtmpOutput, required_audio);
155impl_frame_push_stream_output!(RtmpOutput);
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn build_without_rtmp_scheme_should_return_invalid_config() {
163 let result = RtmpOutput::new("http://example.com/live")
164 .video(1280, 720, 30.0)
165 .build();
166 assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
167 }
168
169 #[test]
170 fn build_without_video_should_return_invalid_config() {
171 let result = RtmpOutput::new("rtmp://localhost/live/key").build();
172 assert!(matches!(result, Err(StreamError::InvalidConfig { .. })));
173 }
174
175 #[test]
176 fn build_with_non_h264_video_codec_should_return_unsupported_codec() {
177 let result = RtmpOutput::new("rtmp://localhost/live/key")
178 .video(1280, 720, 30.0)
179 .video_codec(VideoCodec::Vp9)
180 .build();
181 assert!(matches!(result, Err(StreamError::UnsupportedCodec { .. })));
182 }
183
184 #[test]
185 fn build_with_non_aac_audio_codec_should_return_unsupported_codec() {
186 let result = RtmpOutput::new("rtmp://localhost/live/key")
187 .video(1280, 720, 30.0)
188 .audio_codec(AudioCodec::Mp3)
189 .build();
190 assert!(matches!(result, Err(StreamError::UnsupportedCodec { .. })));
191 }
192
193 #[test]
194 fn video_bitrate_default_should_be_four_megabits() {
195 let out = RtmpOutput::new("rtmp://localhost/live/key");
196 assert_eq!(out.video_bitrate, 4_000_000);
197 }
198
199 #[test]
200 fn audio_defaults_should_be_44100hz_stereo() {
201 let out = RtmpOutput::new("rtmp://localhost/live/key");
202 assert_eq!(out.sample_rate, 44100);
203 assert_eq!(out.channels, 2);
204 }
205}