Skip to main content

ffcine_reframe/
profile.rs

1//! Render profiles: the output frame a timeline is reframed into. One timeline renders to several
2//! aspects (16:9 master, 9:16 reels, 4:5 feed) by rendering it once per profile; the reframe
3//! effect can carry per-profile overrides (`profiles: {name: {...}}`, [`crate::Reframe`]).
4
5use std::str::FromStr;
6
7use ffcine_core::Error;
8
9/// An output frame.
10#[derive(Debug, Clone, PartialEq)]
11pub struct Profile {
12    /// Picks the reframe's `profiles.<name>` override.
13    pub name: String,
14    pub width: u32,
15    pub height: u32,
16    /// Cap on punch-in zoom (`edit.yaml` `output.<mode>.max_punch`, 1.4 in the prototype).
17    pub max_punch: f64,
18    /// Draw the safe area over the picture (review renders only).
19    pub safe_area: Option<SafeArea>,
20}
21
22pub const DEFAULT_MAX_PUNCH: f64 = 1.4;
23
24impl Profile {
25    pub fn new(name: impl Into<String>, width: u32, height: u32) -> Self {
26        Self {
27            name: name.into(),
28            width,
29            height,
30            max_punch: DEFAULT_MAX_PUNCH,
31            safe_area: None,
32        }
33    }
34
35    /// `landscape` (`16:9`, 3840x2160), `hd` (1920x1080), `reel` (`9:16`, 1080x1920), `feed`
36    /// (`4:5`, 1080x1350), `square` (`1:1`, 1080x1080).
37    pub fn preset(name: &str) -> Option<Self> {
38        let (n, w, h) = match name {
39            "landscape" | "16:9" => ("landscape", 3840, 2160),
40            "hd" => ("hd", 1920, 1080),
41            "reel" | "9:16" => ("reel", 1080, 1920),
42            "feed" | "4:5" => ("feed", 1080, 1350),
43            "square" | "1:1" => ("square", 1080, 1080),
44            _ => return None,
45        };
46        Some(Self::new(n, w, h))
47    }
48
49    pub fn aspect(&self) -> f64 {
50        f64::from(self.width) / f64::from(self.height)
51    }
52
53    /// The platform safe area for this aspect: reels for 9:16, the feed for 4:5, a title-safe
54    /// margin otherwise.
55    pub fn default_safe_area(&self) -> SafeArea {
56        let ar = self.aspect();
57        if (ar - 9.0 / 16.0).abs() < 0.01 {
58            SafeArea::REELS
59        } else if (ar - 4.0 / 5.0).abs() < 0.01 {
60            SafeArea::FEED
61        } else {
62            SafeArea::TITLE
63        }
64    }
65}
66
67impl FromStr for Profile {
68    type Err = Error;
69    /// A preset name, `WxH`, or `name=WxH`.
70    fn from_str(s: &str) -> Result<Self, Error> {
71        if let Some(p) = Self::preset(s) {
72            return Ok(p);
73        }
74        let invalid = || Error::Invalid {
75            what: "profile (preset, WxH or name=WxH)",
76            value: s.to_owned(),
77        };
78        let (name, size) = s.split_once('=').unwrap_or((s, s));
79        let (w, h) = size.split_once('x').ok_or_else(invalid)?;
80        let (w, h): (u32, u32) = (
81            w.parse().map_err(|_| invalid())?,
82            h.parse().map_err(|_| invalid())?,
83        );
84        if w == 0 || h == 0 {
85            return Err(invalid());
86        }
87        Ok(Self::new(name, w, h))
88    }
89}
90
91/// Margins kept clear of platform UI, as fractions of the output width (left, right) and height
92/// (top, bottom).
93#[derive(Debug, Clone, Copy, PartialEq)]
94pub struct SafeArea {
95    pub top: f64,
96    pub right: f64,
97    pub bottom: f64,
98    pub left: f64,
99}
100
101impl SafeArea {
102    /// Instagram Reels, `TikTok` and Shorts at 1080x1920: status and header at the top (~220 px),
103    /// caption, audio and buttons at the bottom (~420 px), the action column on the right.
104    pub const REELS: Self = Self {
105        top: 0.115,
106        right: 0.11,
107        bottom: 0.22,
108        left: 0.055,
109    };
110    /// 4:5 feed posts: the profile grid shows the middle 3:4, so keep text off the side 3.1%.
111    pub const FEED: Self = Self {
112        top: 0.05,
113        right: 0.0625,
114        bottom: 0.05,
115        left: 0.0625,
116    };
117    /// Broadcast title safe (90%).
118    pub const TITLE: Self = Self {
119        top: 0.05,
120        right: 0.05,
121        bottom: 0.05,
122        left: 0.05,
123    };
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn parse_profiles() {
132        let p: Profile = "9:16".parse().unwrap();
133        assert_eq!((p.name.as_str(), p.width, p.height), ("reel", 1080, 1920));
134        assert_eq!(p.default_safe_area(), SafeArea::REELS);
135        let p: Profile = "story=720x1280".parse().unwrap();
136        assert_eq!((p.name.as_str(), p.width), ("story", 720));
137        assert_eq!(p.default_safe_area(), SafeArea::REELS);
138        assert_eq!(
139            "1080x1350".parse::<Profile>().unwrap().default_safe_area(),
140            SafeArea::FEED
141        );
142        assert!("reels".parse::<Profile>().is_err());
143        assert!("0x10".parse::<Profile>().is_err());
144    }
145}