Skip to main content

ffcine_assembly/
profile.rs

1//! Render profiles: output size and aspect, and the one encoder config every segment shares, so
2//! segments join by stream copy.
3
4use std::path::PathBuf;
5
6use ffcine_reframe::Profile as Aspect;
7use ffcine_render::{EncoderSettings, PixelFormat, RateControl, Size};
8use ffcine_timeline::Output;
9use serde_json::{Value, json};
10
11/// A render profile. Part of every segment key.
12#[derive(Debug, Clone, PartialEq)]
13pub struct Profile {
14    /// `preview`, `master` or `review`.
15    pub name: String,
16    pub size: Size,
17    /// The output frame clips are reframed into: its name picks the reframe's and the text's
18    /// `profiles.<name>` overrides; its size is `size`.
19    pub aspect: Aspect,
20    /// Shared by every segment: same codec, profile, GOP and colour tags, so the encoded
21    /// parameter sets match and concat is a plain stream copy.
22    pub encoder: EncoderSettings,
23    /// AAC bitrate of the master's audio.
24    pub audio_bitrate: u32,
25    /// Shots decode their proxies ([`crate::proxy`]) instead of the sources.
26    pub proxies: bool,
27    /// Where made proxies are ([`crate::proxy::cached`]); camera proxies are found without it.
28    pub proxy_dir: Option<PathBuf>,
29}
30
31/// Short side of a preset aspect at each quality.
32const PREVIEW_SHORT: u32 = 1080;
33const MASTER_SHORT: u32 = 2160;
34const REVIEW_SHORT: u32 = 720;
35
36/// `aspect` sized to `short` px on its short side (even dimensions).
37/// The largest render side [`Profile::for_canvas`] plans, px.
38pub const MAX_SIDE: u32 = 16384;
39
40#[allow(
41    clippy::cast_possible_truncation,
42    clippy::cast_sign_loss,
43    clippy::cast_precision_loss
44)]
45fn sized(aspect: &Aspect, short: u32) -> Size {
46    let ar = aspect.aspect();
47    let even = |v: f64| ((v / 2.0).round() as u32 * 2).max(2);
48    if ar >= 1.0 {
49        Size {
50            width: even(f64::from(short) * ar),
51            height: short,
52        }
53    } else {
54        Size {
55            width: short,
56            height: even(f64::from(short) / ar),
57        }
58    }
59}
60
61impl Profile {
62    fn landscape(short: u32) -> Aspect {
63        let mut a = Aspect::preset("landscape").unwrap_or_else(|| Aspect::new("landscape", 16, 9));
64        let s = sized(&a, short);
65        (a.width, a.height) = (s.width, s.height);
66        a
67    }
68
69    /// 1080p HEVC NVENC 10-bit, for review.
70    pub fn preview() -> Self {
71        Self {
72            name: "preview".into(),
73            size: Size {
74                width: 1920,
75                height: 1080,
76            },
77            aspect: Self::landscape(PREVIEW_SHORT),
78            encoder: EncoderSettings {
79                preset: Some("p4".into()),
80                rate: RateControl::ConstQp(22),
81                ..EncoderSettings::nvenc_hevc_master()
82            },
83            audio_bitrate: 192_000,
84            proxies: false,
85            proxy_dir: None,
86        }
87    }
88
89    /// 4K HEVC NVENC 10-bit master.
90    pub fn master() -> Self {
91        Self {
92            name: "master".into(),
93            size: Size {
94                width: 3840,
95                height: 2160,
96            },
97            aspect: Self::landscape(MASTER_SHORT),
98            encoder: EncoderSettings::nvenc_hevc_master(),
99            audio_bitrate: 320_000,
100            proxies: false,
101            proxy_dir: None,
102        }
103    }
104
105    /// 720p 8-bit HEVC NVENC from proxies: a fast, small copy to watch an edit.
106    pub fn review() -> Self {
107        Self {
108            name: "review".into(),
109            size: Size {
110                width: 1280,
111                height: 720,
112            },
113            aspect: Self::landscape(REVIEW_SHORT),
114            encoder: EncoderSettings {
115                preset: Some("p1".into()),
116                tune: None,
117                profile: Some("main".into()),
118                rate: RateControl::ConstQp(26),
119                pixel_format: PixelFormat::Nv12,
120                ..EncoderSettings::nvenc_hevc_master()
121            },
122            audio_bitrate: 128_000,
123            proxies: true,
124            proxy_dir: None,
125        }
126    }
127
128    /// `preview`, `master` or `review`, landscape.
129    pub fn by_name(name: &str) -> Option<Self> {
130        match name {
131            "preview" => Some(Self::preview()),
132            "master" | "final" => Some(Self::master()),
133            "review" => Some(Self::review()),
134            _ => None,
135        }
136    }
137
138    /// The `name` quality (`preview`, `master`, `review`) in the timeline's `project.output`, its
139    /// aspect replaced by `aspect` if given (`landscape`, `reel`, `feed`, `square`, `9:16`, `WxH`,
140    /// `name=WxH`). A preset aspect renders at 1080 (preview), 2160 (master) or 720 (review) px
141    /// on its short side unless the timeline sets the size (and no `aspect` overrides it); `WxH`
142    /// is exact. `review` takes the preview's settings, scaled down to 720 px.
143    pub fn resolve(name: &str, output: &Output, aspect: Option<&str>) -> Result<Self, String> {
144        let mut p = Self::by_name(name)
145            .ok_or_else(|| format!("unknown profile {name:?} (preview, master, review)"))?;
146        let mode = if p.name == "master" {
147            &output.master
148        } else {
149            &output.preview
150        };
151        let spec = aspect.or(output.aspect.as_deref()).unwrap_or("landscape");
152        let mut a: Aspect = spec.parse().map_err(|e| format!("render profile: {e}"))?;
153        let preset = Aspect::preset(spec).is_some();
154        let short = match p.name.as_str() {
155            "master" => MASTER_SHORT,
156            "review" => REVIEW_SHORT,
157            _ => PREVIEW_SHORT,
158        };
159        let size = match mode.size {
160            Some([w, h]) if aspect.is_none() && w > 0 && h > 0 && p.name == "review" => {
161                let scale = (f64::from(REVIEW_SHORT) / f64::from(w.min(h))).min(1.0);
162                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
163                let even = |v: u32| ((f64::from(v) * scale / 2.0).round() as u32 * 2).max(2);
164                Size {
165                    width: even(w),
166                    height: even(h),
167                }
168            }
169            Some([w, h]) if aspect.is_none() && w > 0 && h > 0 => Size {
170                width: w,
171                height: h,
172            },
173            _ if preset => sized(&a, short),
174            _ => Size {
175                width: a.width,
176                height: a.height,
177            },
178        };
179        (a.width, a.height) = (size.width, size.height);
180        if let Some(m) = mode.max_punch {
181            a.max_punch = m;
182        }
183        p.size = size;
184        p.aspect = a;
185        Ok(p)
186    }
187
188    /// The `name` profile for a comp whose canvas is `w`x`h` px: the canvas's aspect at the
189    /// profile's short side (a 1080x1920 comp previews at 1080x1920 and masters at 2160x3840).
190    /// Comps decode their sources (no proxies).
191    pub fn for_canvas(name: &str, w: u32, h: u32) -> Result<Self, String> {
192        let mut p = Self::by_name(name)
193            .ok_or_else(|| format!("unknown profile {name:?} (preview, master, review)"))?;
194        let mut a: Aspect = format!("{w}x{h}")
195            .parse()
196            .map_err(|e| format!("canvas size: {e}"))?;
197        let short = match p.name.as_str() {
198            "master" => MASTER_SHORT,
199            "review" => REVIEW_SHORT,
200            _ => PREVIEW_SHORT,
201        };
202        let size = sized(&a, short);
203        if size.width.max(size.height) > MAX_SIDE {
204            return Err(format!(
205                "canvas {w}x{h} renders at {}x{} for {name}: at most {MAX_SIDE} px a side",
206                size.width, size.height
207            ));
208        }
209        (a.width, a.height) = (size.width, size.height);
210        p.size = size;
211        p.aspect = a;
212        p.proxies = false;
213        Ok(p)
214    }
215
216    /// What of the profile changes pixels (not its name).
217    pub fn recipe(&self) -> Value {
218        let mut v = json!({
219            "size": [self.size.width, self.size.height],
220            "aspect": self.aspect.name,
221            "max_punch": self.aspect.max_punch,
222            "encoder": format!("{:?}", self.encoder),
223        });
224        // Only when on, so the other profiles' keys stay as they were.
225        if self.proxies {
226            v["proxies"] = true.into();
227        }
228        v
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn canvas_render_size_is_capped() {
238        let p = Profile::for_canvas("master", 1080, 1920).unwrap();
239        assert_eq!((p.size.width, p.size.height), (2160, 3840));
240        let e = Profile::for_canvas("preview", 32768, 1).unwrap_err();
241        assert!(e.contains("at most 16384 px a side"), "{e}");
242    }
243
244    #[test]
245    fn aspects_and_sizes() {
246        let out = Output::default();
247        let p = Profile::resolve("preview", &out, None).unwrap();
248        assert_eq!(p, Profile::preview());
249        let size = |q: &str, a: &str| {
250            let p = Profile::resolve(q, &out, Some(a)).unwrap();
251            (p.aspect.name.clone(), p.size.width, p.size.height)
252        };
253        assert_eq!(size("preview", "reel"), ("reel".into(), 1080, 1920));
254        assert_eq!(size("master", "9:16"), ("reel".into(), 2160, 3840));
255        assert_eq!(size("preview", "4:5"), ("feed".into(), 1080, 1350));
256        assert_eq!(size("master", "square"), ("square".into(), 2160, 2160));
257        assert_eq!(
258            size("master", "story=720x1280"),
259            ("story".into(), 720, 1280)
260        );
261        // The timeline's aspect, size and punch cap; a CLI aspect replaces the size.
262        let out: Output = serde_json::from_value(json!({
263            "aspect": "reel", "preview": {"size": [720, 1280], "max_punch": 1.12}
264        }))
265        .unwrap();
266        let p = Profile::resolve("preview", &out, None).unwrap();
267        assert_eq!(
268            (p.size.width, p.size.height, p.aspect.max_punch),
269            (720, 1280, 1.12)
270        );
271        let p = Profile::resolve("preview", &out, Some("square")).unwrap();
272        assert_eq!((p.size.width, p.size.height), (1080, 1080));
273        assert!(Profile::resolve("preview", &out, Some("bogus")).is_err());
274        // review: 720 px, the timeline's preview size scaled down, from proxies.
275        let p = Profile::resolve("review", &out, Some("reel")).unwrap();
276        assert_eq!((p.size.width, p.size.height, p.proxies), (720, 1280, true));
277        let out: Output =
278            serde_json::from_value(json!({"preview": {"size": [3840, 1600]}})).unwrap();
279        let p = Profile::resolve("review", &out, None).unwrap();
280        assert_eq!((p.size.width, p.size.height), (1728, 720));
281        assert_eq!(Profile::review().recipe()["proxies"], true);
282        assert!(Profile::preview().recipe().get("proxies").is_none());
283        assert_ne!(
284            Profile::preview().recipe(),
285            Profile::resolve("preview", &Output::default(), Some("reel"))
286                .unwrap()
287                .recipe()
288        );
289    }
290}