//! The Render Queue: compositions queued for export, each with Render Settings (what to render: //! quality, resolution, time span, frame rate) and an Output Module (how to write it: format, //! channels, codec options, output path). Saved with the project, like After Effects. //! //! Output paths are templates: `[compName].[fileExtension]` (the default), with the tokens //! `[compName]`, `[projectName]`, `[fileExtension]`, `[width]`, `[height]`, `[frameRate]`, //! `[startFrame]`, `[endFrame]`, `[outputModuleName]` and, for image sequences, a run of `#` //! (`[#####]`) that becomes the zero-padded frame number. A relative result is resolved against the //! project's folder (or the working directory for unsaved projects). use effectcraft_time::{FrameRate, Tick}; use serde::{Deserialize, Serialize}; use crate::{Comp, ItemId}; /// The default output path template. pub const DEFAULT_TEMPLATE: &str = "[compName].[fileExtension]"; /// The default template for image sequences. pub const DEFAULT_SEQUENCE_TEMPLATE: &str = "[compName]_[#####].[fileExtension]"; /// Render Settings ▸ Quality. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum RenderQuality { #[default] Best, Draft, } /// Render Settings ▸ Time Span. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum TimeSpan { /// The composition's work area. #[default] WorkArea, /// The whole composition. LengthOfComp, /// An explicit span `[start, end)` in comp time. Custom { start: Tick, end: Tick }, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RenderSettings { pub quality: RenderQuality, /// Output scale (1 = Full, 0.5 = Half, 1/3 = Third, 0.25 = Quarter). pub resolution: f64, pub time_span: TimeSpan, /// `None` = use the comp's frame rate. pub frame_rate: Option, /// Motion blur for layers with the switch on (and the comp's Enable Motion Blur). pub motion_blur: bool, /// Image sequences: skip frames whose file already exists. pub skip_existing: bool, } impl Default for RenderSettings { fn default() -> Self { RenderSettings { quality: RenderQuality::Best, resolution: 1.0, time_span: TimeSpan::WorkArea, frame_rate: None, motion_blur: true, skip_existing: false, } } } impl RenderSettings { /// The comp-time span `[start, end)` to render. pub fn span(&self, comp: &Comp) -> (Tick, Tick) { let (a, b) = match self.time_span { TimeSpan::WorkArea => comp.work_area, TimeSpan::LengthOfComp => (Tick::ZERO, comp.duration), TimeSpan::Custom { start, end } => (start, end), }; let a = a.clamp(Tick::ZERO, comp.duration); let b = b.clamp(a, comp.duration); (a, b) } pub fn rate(&self, comp: &Comp) -> FrameRate { self.frame_rate.unwrap_or(comp.frame_rate) } /// First output-rate frame starting at or after `t`. fn ceil_frame(r: FrameRate, t: Tick) -> i64 { let f = r.frame_at(t); if r.tick_of(f) < t { f + 1 } else { f } } /// Output-rate frame number of output frame 0 (the first frame starting inside the span). pub fn first_frame(&self, comp: &Comp) -> i64 { Self::ceil_frame(self.rate(comp), self.span(comp).0) } /// Number of output frames: those starting in `[start, end)` (at least 1). pub fn frame_count(&self, comp: &Comp) -> u64 { let (_, b) = self.span(comp); let n = Self::ceil_frame(self.rate(comp), b) - self.first_frame(comp); n.max(1) as u64 } /// Comp time of output frame `i`. pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick { self.rate(comp).tick_of(self.first_frame(comp) + i as i64) } pub fn output_size(&self, comp: &Comp) -> (u32, u32) { let s = self.resolution.clamp(0.01, 4.0); (((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1)) } pub fn resolution_label(&self) -> &'static str { match self.resolution { r if (r - 1.0).abs() < 1e-6 => "Full", r if (r - 0.5).abs() < 1e-6 => "Half", r if (r - 1.0 / 3.0).abs() < 1e-3 => "Third", r if (r - 0.25).abs() < 1e-6 => "Quarter", _ => "Custom", } } /// One-line summary shown next to "Render Settings:" (AE shows the template name). pub fn summary(&self) -> String { let q = if self.quality == RenderQuality::Best { "Best Settings" } else { "Draft Settings" }; format!("{q} · {}", self.resolution_label()) } } /// Output Module ▸ Format. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum OutputFormat { /// H.264 video (+ AAC audio) in MP4. #[default] H264, /// Apple ProRes in QuickTime (+ PCM audio). 4444 when channels include alpha. ProRes, PngSequence, JpegSequence, TiffSequence, /// OpenEXR sequence (32-bit float, linear light). ExrSequence, /// Animated GIF. Gif, } impl OutputFormat { pub const ALL: [OutputFormat; 7] = [ OutputFormat::H264, OutputFormat::ProRes, OutputFormat::PngSequence, OutputFormat::JpegSequence, OutputFormat::TiffSequence, OutputFormat::ExrSequence, OutputFormat::Gif, ]; pub fn label(self) -> &'static str { match self { OutputFormat::H264 => "H.264", OutputFormat::ProRes => "QuickTime (ProRes)", OutputFormat::PngSequence => "PNG Sequence", OutputFormat::JpegSequence => "JPEG Sequence", OutputFormat::TiffSequence => "TIFF Sequence", OutputFormat::ExrSequence => "OpenEXR Sequence", OutputFormat::Gif => "Animated GIF", } } pub fn extension(self) -> &'static str { match self { OutputFormat::H264 => "mp4", OutputFormat::ProRes => "mov", OutputFormat::PngSequence => "png", OutputFormat::JpegSequence => "jpg", OutputFormat::TiffSequence => "tif", OutputFormat::ExrSequence => "exr", OutputFormat::Gif => "gif", } } pub fn is_sequence(self) -> bool { matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence) } pub fn is_movie(self) -> bool { matches!(self, OutputFormat::H264 | OutputFormat::ProRes) } pub fn supports_alpha(self) -> bool { !matches!(self, OutputFormat::H264 | OutputFormat::JpegSequence) } pub fn supports_audio(self) -> bool { self.is_movie() } /// The written frame size for a rendered size: H.264 needs even dimensions (rounded down, /// at least 2). pub fn coded_size(self, w: u32, h: u32) -> (u32, u32) { let even = |v: u32| if v < 2 { 2 } else { v & !1 }; if self == OutputFormat::H264 { (even(w), even(h)) } else { (w, h) } } /// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `png`, `jpeg`/`jpg`, `tiff`/`tif`, /// `exr`, `gif`, or a label. pub fn from_name(s: &str) -> Option { let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], ""); Some(match n.as_str() { "h264" | "mp4" | "avc" => OutputFormat::H264, "prores" | "mov" | "quicktime" | "quicktimeprores" => OutputFormat::ProRes, "png" | "pngsequence" => OutputFormat::PngSequence, "jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence, "tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence, "exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence, "gif" | "animatedgif" => OutputFormat::Gif, _ => return None, }) } /// Guess from a file name's extension. pub fn from_path(path: &str) -> Option { let ext = std::path::Path::new(path).extension()?.to_string_lossy().to_ascii_lowercase(); OutputFormat::from_name(&ext) } } /// Output Module ▸ Channels. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Channels { /// Opaque, composited over the comp background colour. #[default] Rgb, /// With straight (unmatted) alpha. Rgba, } /// Output Module ▸ Audio Output. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum AudioOutput { /// On when the comp has audible layers. #[default] Auto, On, Off, } /// ProRes flavour. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ProResProfile { Proxy, Lt, Standard, #[default] Hq, P4444, P4444Xq, } impl ProResProfile { pub const ALL: [ProResProfile; 6] = [ProResProfile::Proxy, ProResProfile::Lt, ProResProfile::Standard, ProResProfile::Hq, ProResProfile::P4444, ProResProfile::P4444Xq]; pub fn label(self) -> &'static str { match self { ProResProfile::Proxy => "Apple ProRes 422 Proxy", ProResProfile::Lt => "Apple ProRes 422 LT", ProResProfile::Standard => "Apple ProRes 422", ProResProfile::Hq => "Apple ProRes 422 HQ", ProResProfile::P4444 => "Apple ProRes 4444", ProResProfile::P4444Xq => "Apple ProRes 4444 XQ", } } pub fn is_4444(self) -> bool { matches!(self, ProResProfile::P4444 | ProResProfile::P4444Xq) } pub fn from_name(s: &str) -> Option { let n = s.to_ascii_lowercase().replace([' ', '-', '_'], ""); ProResProfile::ALL.into_iter().find(|p| { let l = p.label().to_ascii_lowercase().replace(' ', ""); l == n || l.trim_start_matches("appleprores") == n.trim_start_matches("appleprores") || format!("{p:?}").to_ascii_lowercase() == n }) } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct OutputModule { pub format: OutputFormat, pub channels: Channels, /// Output path or template (see the module docs). pub output: String, /// JPEG quality 1–100. pub quality: u8, /// H.264 target bitrate. pub bitrate_kbps: u32, pub prores_profile: ProResProfile, pub audio: AudioOutput, pub audio_sample_rate: u32, /// GIF: loop forever. pub gif_loop: bool, } impl Default for OutputModule { fn default() -> Self { OutputModule { format: OutputFormat::H264, channels: Channels::Rgb, output: DEFAULT_TEMPLATE.into(), quality: 90, bitrate_kbps: 10_000, prores_profile: ProResProfile::Hq, audio: AudioOutput::Auto, audio_sample_rate: 48_000, gif_loop: true, } } } impl OutputModule { pub fn for_format(format: OutputFormat) -> OutputModule { let mut m = OutputModule { format, ..Default::default() }; if format.is_sequence() { m.output = DEFAULT_SEQUENCE_TEMPLATE.into(); } m } /// Change the format, keeping the output name but fixing its extension / frame-number token. pub fn set_format(&mut self, format: OutputFormat) { let old = self.format; self.format = format; if !format.supports_alpha() { self.channels = Channels::Rgb; } if self.output.contains("[fileExtension]") { if format.is_sequence() && !self.output.contains('#') { self.output = self.output.replace(".[fileExtension]", "_[#####].[fileExtension]"); } else if !format.is_sequence() && old.is_sequence() { self.output = self.output.replace("_[#####]", "").replace("[#####]", ""); } return; } let p = std::path::Path::new(&self.output); let mut stem = p.with_extension("").to_string_lossy().to_string(); if format.is_sequence() && !stem.contains('#') { stem.push_str("_[#####]"); } else if !format.is_sequence() { stem = stem.replace("_[#####]", "").replace("[#####]", ""); } self.output = format!("{stem}.{}", format.extension()); } /// One-line summary shown next to "Output Module:". pub fn summary(&self) -> String { let ch = match self.channels { Channels::Rgb => "RGB", Channels::Rgba => "RGB + Alpha", }; match self.format { OutputFormat::H264 => format!("H.264 · {} kbps", self.bitrate_kbps), OutputFormat::ProRes => format!( "{} · {ch}", if self.channels == Channels::Rgba && !self.prores_profile.is_4444() { "Apple ProRes 4444" } else { self.prores_profile.label() } ), f => format!("{} · {ch}", f.label()), } } } /// Render Queue item status (the Status column). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub enum RenderStatus { /// Render checkbox off. Unqueued, #[default] Queued, NeedsOutput, Rendering, Done, UserStopped, Failed(String), } impl RenderStatus { pub fn label(&self) -> &str { match self { RenderStatus::Unqueued => "Unqueued", RenderStatus::Queued => "Queued", RenderStatus::NeedsOutput => "Needs Output", RenderStatus::Rendering => "Rendering", RenderStatus::Done => "Done", RenderStatus::UserStopped => "User Stopped", RenderStatus::Failed(_) => "Failed", } } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RenderQueueItem { /// Stable id (unique within the queue). pub id: u64, pub comp: ItemId, /// The Render checkbox. pub render: bool, #[serde(default)] pub status: RenderStatus, #[serde(default)] pub settings: RenderSettings, #[serde(default)] pub output: OutputModule, /// Unix seconds when the last render of this item started. #[serde(default)] pub started: Option, /// Seconds the last render took. #[serde(default)] pub render_time: Option, /// Resolved path of the last render (first file for sequences). #[serde(default)] pub last_output: Option, } impl RenderQueueItem { pub fn new(id: u64, comp: ItemId) -> RenderQueueItem { RenderQueueItem { id, comp, render: true, status: RenderStatus::Queued, settings: RenderSettings::default(), output: OutputModule::default(), started: None, render_time: None, last_output: None, } } /// Will be rendered by the next Render. pub fn is_queued(&self) -> bool { self.render && matches!(self.status, RenderStatus::Queued) } } /// Values for template tokens. pub struct TemplateVars<'a> { pub comp_name: &'a str, pub project_name: &'a str, pub width: u32, pub height: u32, pub frame_rate: f64, pub start_frame: i64, pub end_frame: i64, } /// Expand template tokens (all but the `#` frame-number run). pub fn expand_template(template: &str, format: OutputFormat, v: &TemplateVars) -> String { let fps = if (v.frame_rate - v.frame_rate.round()).abs() < 1e-6 { format!("{}", v.frame_rate.round() as i64) } else { format!("{:.2}", v.frame_rate) }; template .replace("[compName]", &sanitize(v.comp_name)) .replace("[projectName]", &sanitize(v.project_name)) .replace("[fileExtension]", format.extension()) .replace("[width]", &v.width.to_string()) .replace("[height]", &v.height.to_string()) .replace("[frameRate]", &fps) .replace("[startFrame]", &v.start_frame.to_string()) .replace("[endFrame]", &v.end_frame.to_string()) .replace("[outputModuleName]", format.label()) } /// Replace the first run of `#` (optionally in brackets, `[####]`) with the zero-padded frame /// number. Paths without a run get `_NNNNN` before the extension. pub fn sequence_path(path: &str, frame: i64) -> String { if let Some(start) = path.find('#') { let len = path[start..].chars().take_while(|c| *c == '#').count(); let (mut a, mut b) = (start, start + len); if a > 0 && path.as_bytes()[a - 1] == b'[' && path.as_bytes().get(b) == Some(&b']') { a -= 1; b += 1; } return format!("{}{:0width$}{}", &path[..a], frame, &path[b..], width = len); } let p = std::path::Path::new(path); match p.extension() { Some(ext) => format!("{}_{frame:05}.{}", p.with_extension("").to_string_lossy(), ext.to_string_lossy()), None => format!("{path}_{frame:05}"), } } /// Make a name safe as a file name component. pub fn sanitize(name: &str) -> String { name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn templates_and_sequences() { let v = TemplateVars { comp_name: "Main/Title", project_name: "P", width: 1920, height: 1080, frame_rate: 29.97, start_frame: 0, end_frame: 9 }; assert_eq!(expand_template(DEFAULT_TEMPLATE, OutputFormat::H264, &v), "Main_Title.mp4"); assert_eq!(expand_template("[compName]_[width]x[height]@[frameRate].[fileExtension]", OutputFormat::Gif, &v), "Main_Title_1920x1080@29.97.gif"); assert_eq!(sequence_path("out/a_[#####].png", 42), "out/a_00042.png"); assert_eq!(sequence_path("a###.tif", 7), "a007.tif"); assert_eq!(sequence_path("a.png", 3), "a_00003.png"); } #[test] fn set_format_fixes_names() { let mut m = OutputModule::default(); m.set_format(OutputFormat::PngSequence); assert_eq!(m.output, "[compName]_[#####].[fileExtension]"); m.set_format(OutputFormat::ProRes); assert_eq!(m.output, DEFAULT_TEMPLATE); m.output = "/tmp/x.mov".into(); m.set_format(OutputFormat::JpegSequence); assert_eq!(m.output, "/tmp/x_[#####].jpg"); assert_eq!(OutputFormat::from_name("TIFF"), Some(OutputFormat::TiffSequence)); assert_eq!(ProResProfile::from_name("4444"), Some(ProResProfile::P4444)); assert_eq!(ProResProfile::from_name("hq"), Some(ProResProfile::Hq)); } #[test] fn spans() { let mut c = Comp::new(100, 50, FrameRate::new(25, 1), Tick::from_seconds_f64(4.0)); c.work_area = (Tick::from_seconds_f64(1.0), Tick::from_seconds_f64(2.0)); let mut s = RenderSettings::default(); assert_eq!(s.frame_count(&c), 25); assert_eq!(s.frame_time(&c, 0), Tick::from_seconds_f64(1.0)); s.time_span = TimeSpan::LengthOfComp; assert_eq!(s.frame_count(&c), 100); s.frame_rate = Some(FrameRate::new(10, 1)); assert_eq!(s.frame_count(&c), 40); s.resolution = 0.5; assert_eq!(s.output_size(&c), (50, 25)); // NTSC: 1 s – 2 s holds the 30 frames that start inside it. let c = Comp::new(100, 50, FrameRate::new(30000, 1001), Tick::from_seconds_f64(4.0)); let s = RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(1.0), end: Tick::from_seconds_f64(2.0) }, ..Default::default() }; assert_eq!(s.frame_count(&c), 30); assert_eq!(s.first_frame(&c), 30); assert!(s.frame_time(&c, 0) >= Tick::from_seconds_f64(1.0)); } }