1use 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#[derive(Debug, Clone, PartialEq)]
13pub struct Profile {
14 pub name: String,
16 pub size: Size,
17 pub aspect: Aspect,
20 pub encoder: EncoderSettings,
23 pub audio_bitrate: u32,
25 pub proxies: bool,
27 pub proxy_dir: Option<PathBuf>,
29}
30
31const PREVIEW_SHORT: u32 = 1080;
33const MASTER_SHORT: u32 = 2160;
34const REVIEW_SHORT: u32 = 720;
35
36pub 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 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 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 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 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 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 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 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 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 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 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}