Skip to main content

ff_stream/
rtmp.rs

1//! Frame-push RTMP output.
2//!
3//! [`RtmpOutput`] receives pre-decoded [`ff_format::VideoFrame`] / [`ff_format::AudioFrame`] values
4//! from the caller, encodes them with H.264/AAC, and pushes the stream to an
5//! RTMP ingest endpoint using `FFmpeg`'s built-in RTMP support.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use ff_stream::{RtmpOutput, StreamOutput};
11//!
12//! let mut out = RtmpOutput::new("rtmp://ingest.example.com/live/stream_key")
13//!     .video(1920, 1080, 30.0)
14//!     .audio(44100, 2)
15//!     .video_bitrate(4_000_000)
16//!     .audio_bitrate(128_000)
17//!     .build()?;
18//!
19//! // for each decoded frame:
20//! out.push_video(&video_frame)?;
21//! out.push_audio(&audio_frame)?;
22//!
23//! // when done:
24//! Box::new(out).finish()?;
25//! ```
26
27use ff_format::{AudioCodec, VideoCodec};
28
29use crate::error::StreamError;
30use crate::rtmp_inner::RtmpInner;
31
32/// Live RTMP output: encodes frames and pushes them to an RTMP ingest endpoint.
33///
34/// Build with [`RtmpOutput::new`], chain setter methods, then call
35/// [`build`](Self::build) to open the `FFmpeg` context and establish the
36/// RTMP connection. After `build()`:
37///
38/// - `push_video` and `push_audio` encode and
39///   transmit frames in real time.
40/// - [`crate::StreamOutput::finish`] flushes all encoders, sends the FLV end-of-stream
41///   marker, and closes the RTMP connection.
42///
43/// RTMP/FLV requires H.264 video and AAC audio; [`build`](Self::build) returns
44/// [`StreamError::UnsupportedCodec`] for any other codec selection.
45pub 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    /// Create a new builder that streams to the given RTMP URL.
62    ///
63    /// The URL must begin with `rtmp://`; [`build`](Self::build) returns
64    /// [`StreamError::InvalidConfig`] otherwise.
65    ///
66    /// # Example
67    ///
68    /// ```ignore
69    /// use ff_stream::RtmpOutput;
70    ///
71    /// let out = RtmpOutput::new("rtmp://ingest.example.com/live/key");
72    /// ```
73    #[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    /// Open the `FFmpeg` context and establish the RTMP connection.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`StreamError::InvalidConfig`] when:
96    /// - The URL does not start with `rtmp://`.
97    /// - [`video`](Self::video) was not called before `build`.
98    ///
99    /// Returns [`StreamError::UnsupportedCodec`] when:
100    /// - The video codec is not [`VideoCodec::H264`].
101    /// - The audio codec is not [`AudioCodec::Aac`].
102    ///
103    /// Returns [`StreamError::Ffmpeg`] when any `FFmpeg` operation fails
104    /// (including network connection errors).
105    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}