Skip to main content

ffcine_render/
profile.rs

1//! Delivery render profiles: the encoder and output size for a kind of copy ([`DeliveryProfile`]).
2//!
3//! `master` and `preview` are the prototype's encodes; `review` fits a size budget (a typical chat
4//! app cap is 50 MB) at the best resolution the bitrate carries; `phone` is 1080p HEVC that iOS
5//! plays and streams. See `docs/delivery.md`.
6
7use std::time::Duration;
8
9use crate::{EncoderSettings, Error, PixelFormat, RateControl, Size};
10use ffcine_core::color::named_enum;
11use std::fmt;
12use std::str::FromStr;
13
14named_enum!(
15    /// A delivery profile.
16    #[derive(Default)]
17    DeliveryProfile {
18        /// 10-bit HEVC constqp 12 (the prototype's master).
19        #[default]
20        Master = "master",
21        /// 8-bit x264 crf 10 (the prototype's preview).
22        Preview = "preview",
23        /// HEVC under a size budget (default 50 MB), resolution picked from the bitrate.
24        Review = "review",
25        /// 1080p 8-bit HEVC (`hvc1`), constant quality capped at 16 Mb/s, for iPhones.
26        Phone = "phone",
27    }
28);
29
30/// Default `review` budget: a 50 MB chat-app cap, in bytes (decimal MB, as chat apps count).
31pub const REVIEW_BUDGET: u64 = 50_000_000;
32
33/// Bits kept back for the audio stream that is muxed in afterwards (AAC 160 kb/s).
34pub const AUDIO_BITRATE: u64 = 160_000;
35
36/// What a profile resolved to for one render.
37#[derive(Debug, Clone, PartialEq)]
38pub struct Plan {
39    pub encoder: EncoderSettings,
40    /// Output size, `None` = the grade's (source) size.
41    pub size: Option<Size>,
42}
43
44/// Smallest bits per pixel per frame the `review` profile accepts before dropping a resolution
45/// step (HEVC holds up to about here on handheld travel footage).
46const MIN_BPP: f64 = 0.035;
47
48/// Short-side steps the `review` profile tries, largest first.
49const REVIEW_STEPS: [u32; 4] = [1080, 720, 540, 360];
50
51/// Fits `src` into `short` px on its short side, keeping the aspect, even dimensions, never
52/// upscaling.
53#[allow(
54    clippy::cast_possible_truncation,
55    clippy::cast_sign_loss,
56    clippy::cast_precision_loss
57)]
58pub fn fit_short_side(src: Size, short: u32) -> Size {
59    let s = src.width.min(src.height);
60    if s <= short {
61        return src;
62    }
63    let k = f64::from(short) / f64::from(s);
64    let even = |v: u32| ((f64::from(v) * k / 2.0).round() as u32 * 2).max(2);
65    Size {
66        width: even(src.width),
67        height: even(src.height),
68    }
69}
70
71impl DeliveryProfile {
72    /// Encoder and size for a `duration` render of `src`-sized frames at `fps`. `budget` is the
73    /// whole file's size cap in bytes (only `review` uses it; `None` = [`REVIEW_BUDGET`]).
74    #[allow(
75        clippy::cast_possible_truncation,
76        clippy::cast_sign_loss,
77        clippy::cast_precision_loss
78    )]
79    pub fn plan(self, src: Size, fps: f64, duration: Duration, budget: Option<u64>) -> Plan {
80        let gop = Some((fps * 2.0).round().max(1.0) as u32);
81        match self {
82            Self::Master => Plan {
83                encoder: EncoderSettings::nvenc_hevc_master(),
84                size: None,
85            },
86            Self::Preview => Plan {
87                encoder: EncoderSettings::x264_preview(),
88                size: None,
89            },
90            Self::Phone => {
91                let mut e = hevc_8bit(gop);
92                e.rate = RateControl::Crf(23.0);
93                e.extra
94                    .extend(opts(&[("maxrate", "16000000"), ("bufsize", "32000000")]));
95                Plan {
96                    encoder: e,
97                    size: Some(fit_short_side(src, 1080)),
98                }
99            }
100            Self::Review => {
101                let bps = review_bitrate(budget.unwrap_or(REVIEW_BUDGET), duration);
102                let size = REVIEW_STEPS
103                    .iter()
104                    .map(|&s| fit_short_side(src, s))
105                    .find(|s| {
106                        bps as f64 / (f64::from(s.width) * f64::from(s.height) * fps.max(1.0))
107                            >= MIN_BPP
108                    })
109                    .unwrap_or_else(|| fit_short_side(src, 360));
110                let mut e = hevc_8bit(gop);
111                e.rate = RateControl::Bitrate(bps);
112                let peak = (bps * 2).to_string();
113                e.extra.extend(opts(&[
114                    ("multipass", "fullres"),
115                    ("maxrate", &peak),
116                    ("bufsize", &peak),
117                ]));
118                Plan {
119                    encoder: e,
120                    size: Some(size),
121                }
122            }
123        }
124    }
125}
126
127/// Video bitrate that fits `budget` bytes over `duration` with the audio and ~4 % container and
128/// rate-control margin; at least 200 kb/s.
129#[allow(
130    clippy::cast_possible_truncation,
131    clippy::cast_sign_loss,
132    clippy::cast_precision_loss
133)]
134pub fn review_bitrate(budget: u64, duration: Duration) -> u64 {
135    let secs = duration.as_secs_f64().max(1.0);
136    let total = budget as f64 * 8.0 * 0.96 / secs;
137    ((total - AUDIO_BITRATE as f64) as u64).max(200_000)
138}
139
140fn opts(kv: &[(&str, &str)]) -> Vec<(String, String)> {
141    kv.iter()
142        .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
143        .collect()
144}
145
146/// 8-bit HEVC NVENC (`main`), VBR with lookahead and adaptive quantisation.
147fn hevc_8bit(gop: Option<u32>) -> EncoderSettings {
148    EncoderSettings {
149        preset: Some("p7".into()),
150        tune: Some("hq".into()),
151        profile: Some("main".into()),
152        pixel_format: PixelFormat::Nv12,
153        gop,
154        extra: opts(&[
155            ("rc", "vbr"),
156            ("rc-lookahead", "32"),
157            ("spatial-aq", "1"),
158            ("temporal-aq", "1"),
159        ]),
160        ..EncoderSettings::nvenc_hevc_master()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    const UHD: Size = Size {
169        width: 3840,
170        height: 2160,
171    };
172
173    #[test]
174    fn review_fits_the_budget() {
175        // A 10 min video: ~480 kb/s left for video, 540p.
176        let d = Duration::from_secs(600);
177        let p = DeliveryProfile::Review.plan(UHD, 23.976, d, None);
178        let RateControl::Bitrate(b) = p.encoder.rate else {
179            panic!("review is bitrate-driven");
180        };
181        assert!((b + AUDIO_BITRATE) * 600 / 8 < REVIEW_BUDGET);
182        assert_eq!(p.size.map(|s| s.height), Some(540));
183        // A 90 s reel gets 1080p.
184        let p = DeliveryProfile::Review.plan(UHD, 23.976, Duration::from_secs(90), None);
185        assert_eq!(
186            p.size,
187            Some(Size {
188                width: 1920,
189                height: 1080
190            })
191        );
192    }
193
194    #[test]
195    fn phone_is_1080p_hevc_and_never_upscales() {
196        let p = DeliveryProfile::Phone.plan(UHD, 60.0, Duration::from_secs(60), None);
197        assert_eq!(p.size.map(|s| (s.width, s.height)), Some((1920, 1080)));
198        assert_eq!(p.encoder.profile.as_deref(), Some("main"));
199        let vertical = Size {
200            width: 1080,
201            height: 1920,
202        };
203        assert_eq!(fit_short_side(vertical, 1080), vertical);
204    }
205}