mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge main into M13.4 (codec completion): HEVC/AV1 on the export pipeline (Cx), one key-frame interval for all movie codecs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -84,38 +84,329 @@ impl ProxyUse {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Settings ▸ Field Render: interlaced output, each frame woven from two fields rendered
|
||||
/// half a frame apart.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum FieldRender {
|
||||
#[default]
|
||||
Off,
|
||||
/// The upper field (lines 0, 2, 4…) holds the earlier time.
|
||||
UpperFirst,
|
||||
LowerFirst,
|
||||
}
|
||||
|
||||
impl FieldRender {
|
||||
pub const ALL: [FieldRender; 3] = [FieldRender::Off, FieldRender::UpperFirst, FieldRender::LowerFirst];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FieldRender::Off => "Off",
|
||||
FieldRender::UpperFirst => "Upper Field First",
|
||||
FieldRender::LowerFirst => "Lower Field First",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<FieldRender> {
|
||||
match norm(s).as_str() {
|
||||
"off" | "none" | "progressive" => Some(FieldRender::Off),
|
||||
"upper" | "upperfirst" | "upperfieldfirst" => Some(FieldRender::UpperFirst),
|
||||
"lower" | "lowerfirst" | "lowerfieldfirst" => Some(FieldRender::LowerFirst),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.to_ascii_lowercase().replace([' ', '_', '-', '.', ':', '&'], "")
|
||||
}
|
||||
|
||||
/// Render Settings ▸ 3:2 Pulldown: with field rendering, four film frames (at 4/5 of the output
|
||||
/// rate) spread over five interlaced frames in 2:3 cadence. The phase names which of the five
|
||||
/// frames are Whole (both fields from one film frame) or Split.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Pulldown {
|
||||
#[default]
|
||||
Off,
|
||||
Wssww,
|
||||
Sswww,
|
||||
Swwws,
|
||||
Wwwss,
|
||||
Wwssw,
|
||||
}
|
||||
|
||||
/// Film frame (within a group of four) of each of the ten fields of the 2:3 cadence.
|
||||
const CADENCE: [i64; 10] = [0, 0, 1, 1, 1, 2, 2, 3, 3, 3];
|
||||
|
||||
impl Pulldown {
|
||||
pub const ALL: [Pulldown; 6] = [Pulldown::Off, Pulldown::Wssww, Pulldown::Sswww, Pulldown::Swwws, Pulldown::Wwwss, Pulldown::Wwssw];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Pulldown::Off => "Off",
|
||||
Pulldown::Wssww => "WSSWW",
|
||||
Pulldown::Sswww => "SSWWW",
|
||||
Pulldown::Swwws => "SWWWS",
|
||||
Pulldown::Wwwss => "WWWSS",
|
||||
Pulldown::Wwssw => "WWSSW",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Pulldown> {
|
||||
let n = norm(s);
|
||||
if matches!(n.as_str(), "off" | "none") {
|
||||
return Some(Pulldown::Off);
|
||||
}
|
||||
Pulldown::ALL.into_iter().find(|p| p.label().eq_ignore_ascii_case(&n))
|
||||
}
|
||||
/// Offset (in frames) into the WWSSW base cadence.
|
||||
fn rotation(self) -> Option<i64> {
|
||||
Some(match self {
|
||||
Pulldown::Off => return None,
|
||||
Pulldown::Wwssw => 0,
|
||||
Pulldown::Wssww => 1,
|
||||
Pulldown::Sswww => 2,
|
||||
Pulldown::Swwws => 3,
|
||||
Pulldown::Wwwss => 4,
|
||||
})
|
||||
}
|
||||
/// The film frames (counted from the first film frame of the render) of the first and
|
||||
/// second field of output frame `k`.
|
||||
pub fn fields(self, k: u64) -> Option<(i64, i64)> {
|
||||
let r = self.rotation()?;
|
||||
let film = |f: i64| f.div_euclid(10) * 4 + CADENCE[f.rem_euclid(10) as usize];
|
||||
let f0 = 2 * (k as i64 + r);
|
||||
let base = film(2 * r);
|
||||
Some((film(f0) - base, film(f0 + 1) - base))
|
||||
}
|
||||
/// The W/S pattern of five consecutive output frames.
|
||||
pub fn pattern(self) -> String {
|
||||
(0..5).filter_map(|k| self.fields(k).map(|(a, b)| if a == b { 'W' } else { 'S' })).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer switch override (Render Settings ▸ Frame Blending / Motion Blur).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SwitchOverride {
|
||||
/// The layer switches and the composition's enable switch.
|
||||
#[default]
|
||||
Current,
|
||||
/// Every layer whose switch is on, whatever the composition's enable switch.
|
||||
OnForChecked,
|
||||
OffForAll,
|
||||
}
|
||||
|
||||
impl SwitchOverride {
|
||||
pub const ALL: [SwitchOverride; 3] = [SwitchOverride::Current, SwitchOverride::OnForChecked, SwitchOverride::OffForAll];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
SwitchOverride::Current => "Current Settings",
|
||||
SwitchOverride::OnForChecked => "On for Checked Layers",
|
||||
SwitchOverride::OffForAll => "Off for All Layers",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<SwitchOverride> {
|
||||
match norm(s).as_str() {
|
||||
"current" | "currentsettings" => Some(SwitchOverride::Current),
|
||||
"on" | "onforchecked" | "onforcheckedlayers" | "true" => Some(SwitchOverride::OnForChecked),
|
||||
"off" | "offforall" | "offforalllayers" | "false" => Some(SwitchOverride::OffForAll),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Settings ▸ Effects.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EffectsMode {
|
||||
#[default]
|
||||
Current,
|
||||
AllOn,
|
||||
AllOff,
|
||||
}
|
||||
|
||||
impl EffectsMode {
|
||||
pub const ALL: [EffectsMode; 3] = [EffectsMode::Current, EffectsMode::AllOn, EffectsMode::AllOff];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
EffectsMode::Current => "Current Settings",
|
||||
EffectsMode::AllOn => "All On",
|
||||
EffectsMode::AllOff => "All Off",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<EffectsMode> {
|
||||
match norm(s).as_str() {
|
||||
"current" | "currentsettings" => Some(EffectsMode::Current),
|
||||
"allon" | "on" => Some(EffectsMode::AllOn),
|
||||
"alloff" | "off" => Some(EffectsMode::AllOff),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Settings ▸ Solo Switches / Guide Layers: the current switches, or all off.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CurrentOrOff {
|
||||
#[default]
|
||||
Current,
|
||||
AllOff,
|
||||
}
|
||||
|
||||
impl CurrentOrOff {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
CurrentOrOff::Current => "Current Settings",
|
||||
CurrentOrOff::AllOff => "All Off",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<CurrentOrOff> {
|
||||
match norm(s).as_str() {
|
||||
"current" | "currentsettings" | "on" => Some(CurrentOrOff::Current),
|
||||
"alloff" | "off" => Some(CurrentOrOff::AllOff),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render Settings ▸ Color Depth.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ColorDepth {
|
||||
/// The project's bit depth.
|
||||
#[default]
|
||||
Current,
|
||||
Bpc8,
|
||||
Bpc16,
|
||||
Bpc32,
|
||||
}
|
||||
|
||||
impl ColorDepth {
|
||||
pub const ALL: [ColorDepth; 4] = [ColorDepth::Current, ColorDepth::Bpc8, ColorDepth::Bpc16, ColorDepth::Bpc32];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ColorDepth::Current => "Current Settings",
|
||||
ColorDepth::Bpc8 => "8 bits per channel",
|
||||
ColorDepth::Bpc16 => "16 bits per channel",
|
||||
ColorDepth::Bpc32 => "32 bits per channel",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<ColorDepth> {
|
||||
match norm(s).as_str() {
|
||||
"current" | "currentsettings" => Some(ColorDepth::Current),
|
||||
"8" | "8bpc" | "8bitsperchannel" => Some(ColorDepth::Bpc8),
|
||||
"16" | "16bpc" | "16bitsperchannel" => Some(ColorDepth::Bpc16),
|
||||
"32" | "32bpc" | "32bitsperchannel" | "float" => Some(ColorDepth::Bpc32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The depth renders use in a project of depth `project`.
|
||||
pub fn resolve(self, project: crate::BitDepth) -> crate::BitDepth {
|
||||
match self {
|
||||
ColorDepth::Current => project,
|
||||
ColorDepth::Bpc8 => crate::BitDepth::Bpc8,
|
||||
ColorDepth::Bpc16 => crate::BitDepth::Bpc16,
|
||||
ColorDepth::Bpc32 => crate::BitDepth::Bpc32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How one output frame is sampled in time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FrameSample {
|
||||
Progressive(Tick),
|
||||
/// Two fields woven together: `first` goes to the dominant field.
|
||||
Fields {
|
||||
first: Tick,
|
||||
second: Tick,
|
||||
upper_first: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RenderSettings {
|
||||
/// The template these settings came from ("" or "Custom" once edited).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
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.
|
||||
/// `None` = use the comp's frame rate (Time Sampling ▸ Use comp's frame rate).
|
||||
pub frame_rate: Option<FrameRate>,
|
||||
/// Motion blur for layers with the switch on (and the comp's Enable Motion Blur).
|
||||
/// Motion blur allowed at all (false = Off for All Layers; see `motion_blur_mode`).
|
||||
pub motion_blur: bool,
|
||||
/// Motion Blur: Current Settings / On for Checked Layers / Off for All Layers.
|
||||
pub motion_blur_mode: SwitchOverride,
|
||||
/// Frame Blending: Current Settings / On for Checked Layers / Off for All Layers.
|
||||
pub frame_blending: SwitchOverride,
|
||||
/// Field Render.
|
||||
pub field_render: FieldRender,
|
||||
/// 3:2 Pulldown phase (with field rendering).
|
||||
pub pulldown: Pulldown,
|
||||
/// Effects: Current Settings / All On / All Off.
|
||||
pub effects: EffectsMode,
|
||||
/// Solo Switches: Current Settings / All Off.
|
||||
pub solo: CurrentOrOff,
|
||||
/// Guide Layers: Current Settings / All Off.
|
||||
pub guide_layers: CurrentOrOff,
|
||||
/// Color Depth.
|
||||
pub color_depth: ColorDepth,
|
||||
/// Image sequences: skip frames whose file already exists.
|
||||
pub skip_existing: bool,
|
||||
/// Proxy Use (Best Settings: Use No Proxies).
|
||||
pub proxy_use: ProxyUse,
|
||||
/// Use Storage Overflow: when the output volume is full, continue in the project's overflow
|
||||
/// folders.
|
||||
#[serde(default = "default_true")]
|
||||
pub storage_overflow: bool,
|
||||
}
|
||||
|
||||
impl Default for RenderSettings {
|
||||
fn default() -> Self {
|
||||
RenderSettings {
|
||||
name: String::new(),
|
||||
quality: RenderQuality::Best,
|
||||
resolution: 1.0,
|
||||
time_span: TimeSpan::WorkArea,
|
||||
frame_rate: None,
|
||||
motion_blur: true,
|
||||
motion_blur_mode: SwitchOverride::Current,
|
||||
frame_blending: SwitchOverride::Current,
|
||||
field_render: FieldRender::Off,
|
||||
pulldown: Pulldown::Off,
|
||||
effects: EffectsMode::Current,
|
||||
solo: CurrentOrOff::Current,
|
||||
guide_layers: CurrentOrOff::AllOff,
|
||||
color_depth: ColorDepth::Current,
|
||||
skip_existing: false,
|
||||
proxy_use: ProxyUse::UseNone,
|
||||
storage_overflow: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderSettings {
|
||||
/// Effective Motion Blur override.
|
||||
pub fn motion_blur_override(&self) -> SwitchOverride {
|
||||
if self.motion_blur { self.motion_blur_mode } else { SwitchOverride::OffForAll }
|
||||
}
|
||||
/// How output frame `i` is sampled (field rendering and 3:2 pulldown).
|
||||
pub fn sample(&self, comp: &Comp, i: u64) -> FrameSample {
|
||||
let upper_first = match self.field_render {
|
||||
FieldRender::Off => return FrameSample::Progressive(self.frame_time(comp, i)),
|
||||
FieldRender::UpperFirst => true,
|
||||
FieldRender::LowerFirst => false,
|
||||
};
|
||||
let r = self.rate(comp);
|
||||
if let Some((a, b)) = self.pulldown.fields(i) {
|
||||
// Film frames at 4/5 of the output rate, from the first one starting in the span.
|
||||
let film = FrameRate::new(r.num * 4, r.den * 5);
|
||||
let f0 = Self::ceil_frame(film, self.span(comp).0);
|
||||
return FrameSample::Fields { first: film.tick_of(f0 + a), second: film.tick_of(f0 + b), upper_first };
|
||||
}
|
||||
let f = self.first_frame(comp) + i as i64;
|
||||
let t = r.tick_of(f);
|
||||
let half = Tick((r.tick_of(f + 1).0 - t.0) / 2);
|
||||
FrameSample::Fields { first: t, second: Tick(t.0 + half.0), upper_first }
|
||||
}
|
||||
/// The comp-time span `[start, end)` to render.
|
||||
pub fn span(&self, comp: &Comp) -> (Tick, Tick) {
|
||||
let (a, b) = match self.time_span {
|
||||
@@ -162,11 +453,45 @@ impl RenderSettings {
|
||||
_ => "Custom",
|
||||
}
|
||||
}
|
||||
/// One-line summary shown next to "Render Settings:" (AE shows the template name).
|
||||
/// One-line summary shown next to "Render Settings:" (the template name, like AE).
|
||||
pub fn summary(&self) -> String {
|
||||
if !self.name.is_empty() {
|
||||
return self.name.clone();
|
||||
}
|
||||
let q = if self.quality == RenderQuality::Best { "Best Settings" } else { "Draft Settings" };
|
||||
format!("{q} · {}", self.resolution_label())
|
||||
}
|
||||
/// Every setting as `(label, value)` lines (render logs, the settings dialog).
|
||||
pub fn describe(&self, comp: Option<&Comp>) -> Vec<(&'static str, String)> {
|
||||
let span = match self.time_span {
|
||||
TimeSpan::WorkArea => "Work Area Only".to_string(),
|
||||
TimeSpan::LengthOfComp => "Length of Comp".to_string(),
|
||||
TimeSpan::Custom { start, end } => format!("Custom ({:.3}s – {:.3}s)", start.seconds(), end.seconds()),
|
||||
};
|
||||
let rate = match (self.frame_rate, comp) {
|
||||
(Some(r), _) => format!("{:.3} fps", r.as_f64()),
|
||||
(None, Some(c)) => format!("Use comp's frame rate ({:.3})", c.frame_rate.as_f64()),
|
||||
(None, None) => "Use comp's frame rate".into(),
|
||||
};
|
||||
vec![
|
||||
("Template", if self.name.is_empty() { "Custom".into() } else { self.name.clone() }),
|
||||
("Quality", format!("{:?}", self.quality)),
|
||||
("Resolution", format!("{} ({:.3})", self.resolution_label(), self.resolution)),
|
||||
("Proxy Use", self.proxy_use.label().into()),
|
||||
("Effects", self.effects.label().into()),
|
||||
("Solo Switches", self.solo.label().into()),
|
||||
("Guide Layers", self.guide_layers.label().into()),
|
||||
("Color Depth", self.color_depth.label().into()),
|
||||
("Frame Blending", self.frame_blending.label().into()),
|
||||
("Field Render", self.field_render.label().into()),
|
||||
("3:2 Pulldown", self.pulldown.label().into()),
|
||||
("Motion Blur", self.motion_blur_override().label().into()),
|
||||
("Time Span", span),
|
||||
("Frame Rate", rate),
|
||||
("Skip Existing Files", if self.skip_existing { "On" } else { "Off" }.into()),
|
||||
("Use Storage Overflow", if self.storage_overflow { "On" } else { "Off" }.into()),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Format.
|
||||
@@ -303,8 +628,30 @@ pub enum Channels {
|
||||
/// Opaque, composited over the comp background colour.
|
||||
#[default]
|
||||
Rgb,
|
||||
/// With straight (unmatted) alpha.
|
||||
/// With alpha (straight or premultiplied: [`AlphaMode`]).
|
||||
Rgba,
|
||||
/// The alpha channel alone, as an opaque greyscale matte.
|
||||
Alpha,
|
||||
}
|
||||
|
||||
impl Channels {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Channels::Rgb => "RGB",
|
||||
Channels::Rgba => "RGB + Alpha",
|
||||
Channels::Alpha => "Alpha",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Color: how RGB is stored with alpha.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AlphaMode {
|
||||
/// Unmatted colour.
|
||||
#[default]
|
||||
Straight,
|
||||
/// Colour multiplied by alpha (matted with black).
|
||||
Premultiplied,
|
||||
}
|
||||
|
||||
/// Output Module ▸ Audio Output.
|
||||
@@ -317,6 +664,121 @@ pub enum AudioOutput {
|
||||
Off,
|
||||
}
|
||||
|
||||
/// Output Module ▸ Audio Output ▸ sample format (PCM outputs: WAV, AIFF, QuickTime).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AudioFormat {
|
||||
#[default]
|
||||
S16,
|
||||
S24,
|
||||
/// 32-bit float (WAV, QuickTime; AIFF writes 24-bit).
|
||||
F32,
|
||||
}
|
||||
|
||||
impl AudioFormat {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
AudioFormat::S16 => "16 Bit",
|
||||
AudioFormat::S24 => "24 Bit",
|
||||
AudioFormat::F32 => "32 Bit Float",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<AudioFormat> {
|
||||
match norm(s).as_str() {
|
||||
"16" | "16bit" | "s16" => Some(AudioFormat::S16),
|
||||
"24" | "24bit" | "s24" => Some(AudioFormat::S24),
|
||||
"32" | "32bit" | "32bitfloat" | "f32" | "float" => Some(AudioFormat::F32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Crop (pixels of the rendered frame; negative values add transparent pixels).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Crop {
|
||||
pub enabled: bool,
|
||||
/// Use Region of Interest: crop to `roi` (comp pixels, captured from the viewer's region of
|
||||
/// interest when the option is turned on) instead of the edge values.
|
||||
pub use_roi: bool,
|
||||
pub roi: Option<[f64; 4]>,
|
||||
pub top: i32,
|
||||
pub left: i32,
|
||||
pub bottom: i32,
|
||||
pub right: i32,
|
||||
}
|
||||
|
||||
impl Crop {
|
||||
/// Edges `(top, left, bottom, right)` in pixels of a `w`×`h` frame rendered at `scale`.
|
||||
pub fn edges(&self, w: u32, h: u32, scale: f64) -> (i32, i32, i32, i32) {
|
||||
if !self.enabled {
|
||||
return (0, 0, 0, 0);
|
||||
}
|
||||
if self.use_roi
|
||||
&& let Some([x, y, rw, rh]) = self.roi
|
||||
{
|
||||
let l = (x * scale).round() as i32;
|
||||
let t = (y * scale).round() as i32;
|
||||
let r = w as i32 - ((x + rw) * scale).round() as i32;
|
||||
let b = h as i32 - ((y + rh) * scale).round() as i32;
|
||||
return (t, l, b, r);
|
||||
}
|
||||
(self.top, self.left, self.bottom, self.right)
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Resize quality.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ResizeQuality {
|
||||
/// Bilinear.
|
||||
Low,
|
||||
/// Bicubic.
|
||||
#[default]
|
||||
High,
|
||||
}
|
||||
|
||||
/// Output Module ▸ Resize.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Resize {
|
||||
pub enabled: bool,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Lock Aspect Ratio: setting one dimension derives the other from the rendered frame.
|
||||
pub lock_aspect: bool,
|
||||
pub quality: ResizeQuality,
|
||||
}
|
||||
|
||||
impl Default for Resize {
|
||||
fn default() -> Self {
|
||||
Resize { enabled: false, width: 1920, height: 1080, lock_aspect: true, quality: ResizeQuality::High }
|
||||
}
|
||||
}
|
||||
|
||||
/// Output Module ▸ Resize ▸ Resize to: presets.
|
||||
pub const RESIZE_PRESETS: [(&str, u32, u32); 10] = [
|
||||
("HDTV 1080", 1920, 1080),
|
||||
("HDTV 720", 1280, 720),
|
||||
("UHD 4K", 3840, 2160),
|
||||
("DCI 2K", 2048, 1080),
|
||||
("DCI 4K", 4096, 2160),
|
||||
("NTSC DV", 720, 480),
|
||||
("PAL D1/DV", 720, 576),
|
||||
("Square 1080", 1080, 1080),
|
||||
("Vertical 1080×1920", 1080, 1920),
|
||||
("Web 640×360", 640, 360),
|
||||
];
|
||||
|
||||
/// Output Module ▸ Post-Render Action.
|
||||
pub fn post_render_parse(s: &str) -> Option<PostRenderAction> {
|
||||
match norm(s).as_str() {
|
||||
"none" => Some(PostRenderAction::None),
|
||||
"import" => Some(PostRenderAction::Import),
|
||||
"importreplace" | "importandreplace" | "importandreplaceusage" | "importreplaceusage" | "replace" => Some(PostRenderAction::ImportAndReplace),
|
||||
"setproxy" | "proxy" => Some(PostRenderAction::SetProxy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ProRes flavour.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProResProfile {
|
||||
@@ -420,13 +882,11 @@ pub struct VideoCodecOptions {
|
||||
pub rate_control: RateControlMode,
|
||||
/// Constant-quality setting 1–100 (100 ≈ visually lossless), used with [`RateControlMode::Quality`].
|
||||
pub quality: u8,
|
||||
/// Frames between key frames; 0 = automatic (about 2 seconds).
|
||||
pub keyframe_interval: u32,
|
||||
}
|
||||
|
||||
impl Default for VideoCodecOptions {
|
||||
fn default() -> Self {
|
||||
VideoCodecOptions { profile: CodecProfile::Main, level: None, rate_control: RateControlMode::Bitrate, quality: 70, keyframe_interval: 0 }
|
||||
VideoCodecOptions { profile: CodecProfile::Main, level: None, rate_control: RateControlMode::Bitrate, quality: 70 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,10 +895,6 @@ impl VideoCodecOptions {
|
||||
pub const HEVC_LEVELS: [u8; 13] = [10, 20, 21, 30, 31, 40, 41, 50, 51, 52, 60, 61, 62];
|
||||
/// AV1 levels (× 10) offered in the UI.
|
||||
pub const AV1_LEVELS: [u8; 14] = [20, 21, 30, 31, 40, 41, 50, 51, 52, 53, 60, 61, 62, 63];
|
||||
/// The key-frame interval in frames at `fps` (automatic: 2 s).
|
||||
pub fn keyint(&self, fps: f64) -> u32 {
|
||||
if self.keyframe_interval > 0 { self.keyframe_interval } else { (fps * 2.0).round().max(1.0) as u32 }
|
||||
}
|
||||
/// HEVC QP (0–51) for the constant-quality setting.
|
||||
pub fn hevc_qp(&self) -> u8 {
|
||||
(4.0 + (100 - self.quality.clamp(1, 100)) as f64 * 0.45).round() as u8
|
||||
@@ -512,17 +968,31 @@ impl OpusApplication {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct OutputModule {
|
||||
/// The template this module came from ("" or "Custom" once edited).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
pub format: OutputFormat,
|
||||
pub channels: Channels,
|
||||
/// Color: straight or premultiplied RGB with alpha.
|
||||
pub alpha_mode: AlphaMode,
|
||||
/// Output path or template (see the module docs).
|
||||
pub output: String,
|
||||
/// JPEG quality 1–100.
|
||||
/// JPEG / WebM quality 1–100.
|
||||
pub quality: u8,
|
||||
/// H.264 target bitrate (also HEVC / AV1 with [`RateControlMode::Bitrate`]).
|
||||
/// H.264 target bitrate (and WebM's when `webm_bitrate`; HEVC / AV1 with [`RateControlMode::Bitrate`]).
|
||||
pub bitrate_kbps: u32,
|
||||
/// WebM: rate control by `bitrate_kbps` instead of `quality`.
|
||||
pub webm_bitrate: bool,
|
||||
/// Movies: frames between key frames (0 = automatic, two seconds).
|
||||
pub keyframe_interval: u32,
|
||||
pub prores_profile: ProResProfile,
|
||||
pub crop: Crop,
|
||||
pub resize: Resize,
|
||||
pub audio: AudioOutput,
|
||||
pub audio_sample_rate: u32,
|
||||
/// 1 (mono) or 2 (stereo).
|
||||
pub audio_channels: u8,
|
||||
pub audio_format: AudioFormat,
|
||||
/// GIF: loop forever.
|
||||
pub gif_loop: bool,
|
||||
/// HEVC / AV1 options.
|
||||
@@ -532,29 +1002,110 @@ pub struct OutputModule {
|
||||
/// WebM Opus audio bitrate (stereo total), kbit/s.
|
||||
pub opus_bitrate_kbps: u32,
|
||||
pub opus_application: OpusApplication,
|
||||
/// Include Project Link: kept for parity; EffectCraft's writers have no place to store a
|
||||
/// link back to the project, so it changes nothing in the file.
|
||||
pub include_project_link: bool,
|
||||
}
|
||||
|
||||
impl Default for OutputModule {
|
||||
fn default() -> Self {
|
||||
OutputModule {
|
||||
name: String::new(),
|
||||
format: OutputFormat::H264,
|
||||
channels: Channels::Rgb,
|
||||
alpha_mode: AlphaMode::Straight,
|
||||
output: DEFAULT_TEMPLATE.into(),
|
||||
quality: 90,
|
||||
bitrate_kbps: 10_000,
|
||||
webm_bitrate: false,
|
||||
keyframe_interval: 0,
|
||||
prores_profile: ProResProfile::Hq,
|
||||
crop: Crop::default(),
|
||||
resize: Resize::default(),
|
||||
audio: AudioOutput::Auto,
|
||||
audio_sample_rate: 48_000,
|
||||
audio_channels: 2,
|
||||
audio_format: AudioFormat::S16,
|
||||
gif_loop: true,
|
||||
codec: VideoCodecOptions::default(),
|
||||
webm_codec: WebmVideoCodec::Vp9,
|
||||
opus_bitrate_kbps: 192,
|
||||
opus_application: OpusApplication::Audio,
|
||||
include_project_link: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutputModule {
|
||||
/// The size of the frames this module writes for frames rendered at `w`×`h` with render
|
||||
/// scale `scale`: after Crop and Resize (before codec rounding).
|
||||
pub fn frame_size(&self, w: u32, h: u32, scale: f64) -> (u32, u32) {
|
||||
let (t, l, b, r) = self.crop.edges(w, h, scale);
|
||||
let cw = (w as i32 - l - r).max(1) as u32;
|
||||
let ch = (h as i32 - t - b).max(1) as u32;
|
||||
if !self.resize.enabled {
|
||||
return (cw, ch);
|
||||
}
|
||||
let (mut rw, mut rh) = (self.resize.width.max(1), self.resize.height.max(1));
|
||||
if self.resize.lock_aspect {
|
||||
// Width rules; height follows the cropped frame's aspect.
|
||||
rh = ((rw as f64 * ch as f64 / cw as f64).round() as u32).max(1);
|
||||
}
|
||||
rw = rw.min(30_000);
|
||||
rh = rh.min(30_000);
|
||||
(rw, rh)
|
||||
}
|
||||
/// The written frame size of an item (Render Settings resolution, Crop, Resize, codec
|
||||
/// rounding).
|
||||
pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) {
|
||||
let (w, h) = settings.output_size(comp);
|
||||
let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0));
|
||||
self.format.coded_size(w, h)
|
||||
}
|
||||
/// Every setting as `(label, value)` lines (render logs).
|
||||
pub fn describe(&self) -> Vec<(&'static str, String)> {
|
||||
let mut v = vec![
|
||||
("Template", if self.name.is_empty() { "Custom".into() } else { self.name.clone() }),
|
||||
("Format", self.format.label().into()),
|
||||
("Channels", self.channels.label().into()),
|
||||
("Output To", self.output.clone()),
|
||||
];
|
||||
if self.channels == Channels::Rgba {
|
||||
v.insert(3, ("Color", format!("{:?}", self.alpha_mode)));
|
||||
}
|
||||
match self.format {
|
||||
OutputFormat::H264 => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))),
|
||||
OutputFormat::ProRes => v.push(("Codec", self.prores_profile.label().into())),
|
||||
OutputFormat::WebM if self.webm_bitrate => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))),
|
||||
OutputFormat::WebM | OutputFormat::JpegSequence => v.push(("Quality", self.quality.to_string())),
|
||||
_ => {}
|
||||
}
|
||||
if self.crop.enabled {
|
||||
let c = &self.crop;
|
||||
v.push((
|
||||
"Crop",
|
||||
if c.use_roi { format!("Region of Interest {:?}", c.roi) } else { format!("T {} L {} B {} R {}", c.top, c.left, c.bottom, c.right) },
|
||||
));
|
||||
}
|
||||
if self.resize.enabled {
|
||||
let r = &self.resize;
|
||||
v.push(("Resize", format!("{}×{} ({:?} quality{})", r.width, r.height, r.quality, if r.lock_aspect { ", aspect locked" } else { "" })));
|
||||
}
|
||||
if self.format.supports_audio() {
|
||||
v.push((
|
||||
"Audio Output",
|
||||
format!(
|
||||
"{:?} · {} Hz · {} · {}",
|
||||
self.audio,
|
||||
self.audio_sample_rate,
|
||||
if self.audio_channels == 1 { "Mono" } else { "Stereo" },
|
||||
self.audio_format.label()
|
||||
),
|
||||
));
|
||||
}
|
||||
v.push(("Include Project Link", if self.include_project_link { "On" } else { "Off" }.into()));
|
||||
v
|
||||
}
|
||||
pub fn for_format(format: OutputFormat) -> OutputModule {
|
||||
let mut m = OutputModule { format, ..Default::default() };
|
||||
if format.is_sequence() {
|
||||
@@ -566,7 +1117,7 @@ impl OutputModule {
|
||||
pub fn set_format(&mut self, format: OutputFormat) {
|
||||
let old = self.format;
|
||||
self.format = format;
|
||||
if !self.supports_alpha() {
|
||||
if !self.supports_alpha() && self.channels == Channels::Rgba {
|
||||
self.channels = Channels::Rgb;
|
||||
}
|
||||
if self.output.contains("[fileExtension]") {
|
||||
@@ -590,6 +1141,10 @@ impl OutputModule {
|
||||
pub fn supports_alpha(&self) -> bool {
|
||||
self.format.supports_alpha() && !(self.format == OutputFormat::WebM && self.webm_codec == WebmVideoCodec::Av1)
|
||||
}
|
||||
/// The key-frame interval in frames at `fps` ([`OutputModule::keyframe_interval`]; automatic: 2 s).
|
||||
pub fn keyint(&self, fps: f64) -> u32 {
|
||||
if self.keyframe_interval > 0 { self.keyframe_interval } else { (fps * 2.0).round().max(1.0) as u32 }
|
||||
}
|
||||
/// Codec options summary: `Main · 10000 kbps · Level Auto · Key every auto`.
|
||||
fn codec_summary(&self) -> String {
|
||||
let c = &self.codec;
|
||||
@@ -597,15 +1152,15 @@ impl OutputModule {
|
||||
RateControlMode::Bitrate => format!("{} kbps", self.bitrate_kbps),
|
||||
RateControlMode::Quality => format!("Quality {}", c.quality),
|
||||
};
|
||||
let gop = if c.keyframe_interval == 0 { "auto".to_string() } else { c.keyframe_interval.to_string() };
|
||||
let gop = if self.keyframe_interval == 0 { "auto".to_string() } else { self.keyframe_interval.to_string() };
|
||||
format!("{} · {rate} · Level {} · Key every {gop}", c.profile.label(), c.level_label())
|
||||
}
|
||||
/// 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",
|
||||
};
|
||||
if !self.name.is_empty() {
|
||||
return self.name.clone();
|
||||
}
|
||||
let ch = self.channels.label();
|
||||
match self.format {
|
||||
OutputFormat::H264 => format!("H.264 · {} kbps", self.bitrate_kbps),
|
||||
OutputFormat::ProRes => format!(
|
||||
@@ -678,6 +1233,85 @@ pub struct RenderQueueItem {
|
||||
/// Post-Render Action of the first output module.
|
||||
#[serde(default, skip_serializing_if = "PostRenderAction::is_none")]
|
||||
pub post_render: PostRenderAction,
|
||||
/// The Log menu: what the render log next to the output records.
|
||||
#[serde(default, skip_serializing_if = "RenderLog::is_default")]
|
||||
pub log: RenderLog,
|
||||
}
|
||||
|
||||
/// Render Queue item ▸ Log.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RenderLog {
|
||||
/// A log only when the render fails.
|
||||
#[default]
|
||||
ErrorsOnly,
|
||||
/// Always: the result plus every Render Settings and Output Module setting.
|
||||
PlusSettings,
|
||||
/// Also one line per rendered frame.
|
||||
PlusPerFrameInfo,
|
||||
}
|
||||
|
||||
impl RenderLog {
|
||||
pub const ALL: [RenderLog; 3] = [RenderLog::ErrorsOnly, RenderLog::PlusSettings, RenderLog::PlusPerFrameInfo];
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == RenderLog::ErrorsOnly
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
RenderLog::ErrorsOnly => "Errors Only",
|
||||
RenderLog::PlusSettings => "Plus Settings",
|
||||
RenderLog::PlusPerFrameInfo => "Plus Per Frame Info",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<RenderLog> {
|
||||
match norm(s).as_str() {
|
||||
"errors" | "errorsonly" => Some(RenderLog::ErrorsOnly),
|
||||
"settings" | "plussettings" => Some(RenderLog::PlusSettings),
|
||||
"perframe" | "plusperframe" | "plusperframeinfo" | "frames" => Some(RenderLog::PlusPerFrameInfo),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The render log's path for an output: next to it, `<name>_RenderLog.txt` (a sequence's
|
||||
/// frame-number run is dropped).
|
||||
pub fn log_path(output: &str) -> String {
|
||||
let p = std::path::Path::new(output);
|
||||
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let mut stem: String = stem.replace("_[#####]", "").replace("[#####]", "");
|
||||
if let Some(i) = stem.find('#') {
|
||||
let n = stem[i..].chars().take_while(|c| *c == '#').count();
|
||||
stem.replace_range(i..i + n, "");
|
||||
}
|
||||
let stem = stem.trim_end_matches(['_', '.', ' ']);
|
||||
let name = format!("{}_RenderLog.txt", if stem.is_empty() { "Render" } else { stem });
|
||||
match p.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
Some(d) => d.join(name).to_string_lossy().to_string(),
|
||||
None => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage hook (Render Settings ▸ Use Storage Overflow): whether `bytes` more fit on the volume
|
||||
/// holding `path`. The desktop app has no quota by default; tests and hosts with quotas supply
|
||||
/// one.
|
||||
pub trait StorageQuota: Send + Sync {
|
||||
fn has_room(&self, path: &str, bytes: u64) -> bool;
|
||||
}
|
||||
|
||||
/// Project-wide Render Queue preferences.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RenderQueuePrefs {
|
||||
/// Notify when the render finishes (a sound and a notification in the desktop app).
|
||||
pub notify: bool,
|
||||
/// Storage overflow folders, used in order when the output volume is full.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub overflow_folders: Vec<String>,
|
||||
}
|
||||
|
||||
impl RenderQueuePrefs {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == RenderQueuePrefs::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens after an item renders (Output Module ▸ Post-Render Action).
|
||||
@@ -721,6 +1355,7 @@ impl RenderQueueItem {
|
||||
last_output: None,
|
||||
extra_outputs: vec![],
|
||||
post_render: PostRenderAction::None,
|
||||
log: RenderLog::ErrorsOnly,
|
||||
}
|
||||
}
|
||||
/// Every output module, the first one first.
|
||||
@@ -812,6 +1447,90 @@ mod tests {
|
||||
assert_eq!(ProResProfile::from_name("hq"), Some(ProResProfile::Hq));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulldown_cadence() {
|
||||
assert_eq!(Pulldown::Wwssw.pattern(), "WWSSW");
|
||||
for p in &Pulldown::ALL[1..] {
|
||||
assert_eq!(p.pattern(), p.label(), "{p:?}");
|
||||
}
|
||||
// 2:3 cadence: ten fields carry four film frames; every output frame starts on film 0.
|
||||
for p in &Pulldown::ALL[1..] {
|
||||
let mut fields = vec![];
|
||||
for k in 0..10 {
|
||||
let (a, b) = p.fields(k).unwrap();
|
||||
fields.extend([a, b]);
|
||||
}
|
||||
assert_eq!(fields[0], 0, "{p:?}");
|
||||
assert!(fields.windows(2).all(|w| w[1] == w[0] || w[1] == w[0] + 1), "{p:?}: {fields:?}");
|
||||
// 20 fields = 8 film frames' worth of cadence (a phase may start or end mid-frame).
|
||||
assert!((7..=8).contains(&(*fields.last().unwrap() - fields[0])), "{p:?} {fields:?}");
|
||||
// Each film frame appears in 2 or 3 consecutive fields, alternating.
|
||||
let mut runs = vec![];
|
||||
let mut n = 1;
|
||||
for w in fields.windows(2) {
|
||||
if w[1] == w[0] {
|
||||
n += 1;
|
||||
} else {
|
||||
runs.push(n);
|
||||
n = 1;
|
||||
}
|
||||
}
|
||||
assert!(runs[1..].iter().all(|r| *r == 2 || *r == 3), "{p:?}: {runs:?}");
|
||||
assert!(runs[1..].windows(2).all(|w| w[0] != w[1]), "2 and 3 alternate: {p:?}: {runs:?}");
|
||||
}
|
||||
assert_eq!(Pulldown::parse("sswww"), Some(Pulldown::Sswww));
|
||||
// With field rendering at 29.97, film frames are 23.976 fps.
|
||||
let c = Comp::new(64, 48, FrameRate::new(30000, 1001), Tick::from_seconds_f64(2.0));
|
||||
let s = RenderSettings { field_render: FieldRender::UpperFirst, pulldown: Pulldown::Wssww, time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
let film = FrameRate::new(24000, 1001);
|
||||
match s.sample(&c, 1) {
|
||||
FrameSample::Fields { first, second, upper_first } => {
|
||||
assert!(upper_first);
|
||||
assert_eq!((first, second), (film.tick_of(0), film.tick_of(1)), "WSSWW: frame 1 is split between film frames 0 and 1");
|
||||
}
|
||||
f => panic!("{f:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_samples_are_half_a_frame_apart() {
|
||||
let c = Comp::new(64, 48, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
|
||||
let s = RenderSettings { field_render: FieldRender::LowerFirst, time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
assert_eq!(s.sample(&c, 3), FrameSample::Fields { first: Tick::from_seconds_f64(0.12), second: Tick::from_seconds_f64(0.14), upper_first: false });
|
||||
let p = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
|
||||
assert_eq!(p.sample(&c, 3), FrameSample::Progressive(Tick::from_seconds_f64(0.12)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_and_resize_sizes() {
|
||||
let c = Comp::new(1920, 1080, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0));
|
||||
let rs = RenderSettings::default();
|
||||
let mut m = OutputModule::default();
|
||||
assert_eq!(m.output_size(&c, &rs), (1920, 1080));
|
||||
m.crop = Crop { enabled: true, top: 10, left: 20, bottom: 30, right: 41, ..Default::default() };
|
||||
assert_eq!(m.output_size(&c, &rs), (1858, 1040), "H.264 rounds the odd width down");
|
||||
m.format = OutputFormat::PngSequence;
|
||||
assert_eq!(m.output_size(&c, &rs), (1859, 1040));
|
||||
// Region of interest, at half resolution.
|
||||
m.crop = Crop { enabled: true, use_roi: true, roi: Some([100.0, 50.0, 800.0, 400.0]), ..Default::default() };
|
||||
let half = RenderSettings { resolution: 0.5, ..Default::default() };
|
||||
assert_eq!(m.output_size(&c, &half), (400, 200));
|
||||
// Negative crop pads.
|
||||
m.crop = Crop { enabled: true, top: -10, bottom: -10, ..Default::default() };
|
||||
assert_eq!(m.output_size(&c, &rs), (1920, 1100));
|
||||
// Resize: fixed, and with the aspect locked to the (cropped) frame.
|
||||
m.crop = Crop::default();
|
||||
m.resize = Resize { enabled: true, width: 1280, height: 999, lock_aspect: false, quality: ResizeQuality::High };
|
||||
assert_eq!(m.output_size(&c, &rs), (1280, 999));
|
||||
m.resize.lock_aspect = true;
|
||||
assert_eq!(m.output_size(&c, &rs), (1280, 720));
|
||||
m.crop = Crop { enabled: true, left: 480, right: 480, ..Default::default() };
|
||||
assert_eq!(m.output_size(&c, &rs), (1280, 1440), "960×1080 cropped, then 1280 wide at 8:9");
|
||||
assert_eq!(log_path("/out/Main_[#####].png"), "/out/Main_RenderLog.txt");
|
||||
assert_eq!(log_path("/out/a.mov"), "/out/a_RenderLog.txt");
|
||||
assert_eq!(log_path("seq###.tif"), "seq_RenderLog.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans() {
|
||||
let mut c = Comp::new(100, 50, FrameRate::new(25, 1), Tick::from_seconds_f64(4.0));
|
||||
@@ -853,9 +1572,6 @@ mod tests {
|
||||
assert_eq!((o.hevc_qp(), o.av1_qindex()), (4, 4));
|
||||
o.quality = 1;
|
||||
assert!(o.hevc_qp() <= 51 && o.av1_qindex() >= 240);
|
||||
assert_eq!(o.keyint(29.97), 60);
|
||||
o.keyframe_interval = 1;
|
||||
assert_eq!(o.keyint(29.97), 1);
|
||||
// WebM with AV1 has no alpha; switching drops RGB + Alpha.
|
||||
let mut m = OutputModule::for_format(OutputFormat::WebM);
|
||||
m.channels = Channels::Rgba;
|
||||
@@ -863,7 +1579,11 @@ mod tests {
|
||||
m.webm_codec = WebmVideoCodec::Av1;
|
||||
assert!(!m.supports_alpha());
|
||||
assert!(m.summary().starts_with("WebM (AV1 + Opus 192 kbps)"), "{}", m.summary());
|
||||
let h = OutputModule::for_format(OutputFormat::Hevc);
|
||||
let mut h = OutputModule::for_format(OutputFormat::Hevc);
|
||||
assert_eq!(h.keyint(29.97), 60);
|
||||
h.keyframe_interval = 1;
|
||||
assert_eq!(h.keyint(29.97), 1);
|
||||
h.keyframe_interval = 0;
|
||||
assert_eq!(h.summary(), "HEVC (H.265) · Main · 10000 kbps · Level Auto · Key every auto");
|
||||
// Older projects (no codec fields) load with the defaults.
|
||||
let old: OutputModule = serde_json::from_str(r#"{"format":"H264","bitrate_kbps":5000}"#).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user