mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
Merge pull request #2915 from vangel76/perf/exr-output-options
Render Queue OpenEXR: choose compression and half or 32-bit float
This commit is contained in:
14 files changed
+824
-23
No files matched your search
@@ -17,6 +17,7 @@
|
||||
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
|
||||
//! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
|
||||
//! [--exr-compression none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab] [--exr-depth half|float]
|
||||
//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings)
|
||||
//! [--progress] report progress on stderr when it is not a terminal, up to four lines a second:
|
||||
//! `progress <frames done> <frames total> <seconds elapsed> <seconds left, or - when unknown>`
|
||||
@@ -114,9 +115,13 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
[--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
|
||||
[--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality N]
|
||||
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] | --queue
|
||||
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
|
||||
[--exr-compression C] [--exr-depth half|float] | --queue
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus;
|
||||
--exr-compression none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab
|
||||
(default zip; pxr24 on float, b44/b44a on half and dwaa/dwab are lossy),
|
||||
--exr-depth (default float): OpenEXR)
|
||||
a relative --out is relative to the working directory
|
||||
--progress: progress lines on stderr when it is not a terminal
|
||||
(progress <frames done> <total> <seconds elapsed> <seconds left|->)
|
||||
@@ -190,6 +195,8 @@ const VALUED: &[&str] = &[
|
||||
"--webm-codec",
|
||||
"--audio-bitrate",
|
||||
"--opus-app",
|
||||
"--exr-compression",
|
||||
"--exr-depth",
|
||||
// mcp
|
||||
"--automation-read-root",
|
||||
"--automation-write-root",
|
||||
@@ -881,6 +888,8 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
("--rate-control", "rateControl"),
|
||||
("--webm-codec", "webmCodec"),
|
||||
("--opus-app", "opusApplication"),
|
||||
("--exr-compression", "exrCompression"),
|
||||
("--exr-depth", "exrBitDepth"),
|
||||
] {
|
||||
if let Some(v) = args.opt(flag) {
|
||||
p[key] = json!(v);
|
||||
|
||||
@@ -1171,3 +1171,55 @@ fn render_without_progress_writes_no_progress_lines() {
|
||||
let stderr = render_demo_stderr("no-progress.gif", &[]);
|
||||
assert!(!stderr.lines().any(|l| l.starts_with("progress")), "{stderr}");
|
||||
}
|
||||
|
||||
/// `render --exr-compression C --exr-depth half` writes OpenEXR frames with that compression and
|
||||
/// half-float channels (#2911); an unknown value is an error naming the parameter.
|
||||
#[test]
|
||||
fn render_exr_compression_and_depth() {
|
||||
let root = tmp("render-exr-options");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let project = root.join("x.ecproj");
|
||||
let p = project.to_str().unwrap();
|
||||
ok_json(&[
|
||||
"run",
|
||||
"comp.new",
|
||||
r#"{"name":"T","width":64,"height":36,"duration":1}"#,
|
||||
"layer.newSolid",
|
||||
r#"{"width":64,"height":36}"#,
|
||||
"--empty",
|
||||
"--save-as",
|
||||
p,
|
||||
]);
|
||||
let out = root.join("f_[#####].exr");
|
||||
let reply = ok_json(&[
|
||||
"render",
|
||||
"--project",
|
||||
p,
|
||||
"--start",
|
||||
"0",
|
||||
"--end",
|
||||
"0.01",
|
||||
"--exr-compression",
|
||||
"piz",
|
||||
"--exr-depth",
|
||||
"half",
|
||||
"--out",
|
||||
out.to_str().unwrap(),
|
||||
]);
|
||||
assert_eq!(reply["rendered"].as_array().unwrap().len(), 1, "{reply}");
|
||||
let bytes = std::fs::read(root.join("f_00000.exr")).unwrap();
|
||||
// The `compression` attribute: name, type name, a 1-byte size, then PIZ (4).
|
||||
let attr = b"compression\0compression\0\x01\0\0\0";
|
||||
let at = bytes.windows(attr.len()).position(|w| w == attr).expect("compression attribute");
|
||||
assert_eq!(bytes[at + attr.len()], 4, "PIZ");
|
||||
// Each channel in `chlist`: its name, then the pixel type (1 = half).
|
||||
for name in [&b"R\0"[..], b"G\0", b"B\0", b"A\0"] {
|
||||
let half = [name, &[1, 0, 0, 0]].concat();
|
||||
assert!(bytes.windows(half.len()).any(|w| w == half.as_slice()), "{:?} is half float", std::str::from_utf8(name));
|
||||
}
|
||||
let (code, reply) = run_json(&["render", "--project", p, "--exr-depth", "8", "--out", out.to_str().unwrap()]);
|
||||
assert_eq!(code, 1, "{reply}");
|
||||
assert!(reply["error"].as_str().unwrap().contains("exrBitDepth: half|float"), "{reply}");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -6,9 +6,9 @@
|
||||
//! `index` (1-based, the # column).
|
||||
|
||||
use effectcraft_project::render_queue::{
|
||||
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, DEFAULT_SEQUENCE_TEMPLATE, DEFAULT_TEMPLATE, EffectsMode, FieldRender,
|
||||
OutputFormat, OutputModule, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderSettings,
|
||||
RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse, without_project_extension,
|
||||
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, DEFAULT_SEQUENCE_TEMPLATE, DEFAULT_TEMPLATE, EffectsMode, ExrBitDepth,
|
||||
ExrCompression, FieldRender, OutputFormat, OutputModule, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality,
|
||||
RenderQueueItem, RenderSettings, RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse, without_project_extension,
|
||||
};
|
||||
use effectcraft_project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
@@ -202,6 +202,9 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
|
||||
om.name = t.name.clone();
|
||||
}
|
||||
|
||||
/// The `exrCompression` parameter's values.
|
||||
const EXR_COMPRESSIONS: &str = "none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab";
|
||||
|
||||
/// The `format` parameter's values.
|
||||
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff";
|
||||
|
||||
@@ -311,6 +314,17 @@ fn apply_resize(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OpenEXR `exrCompression` and `exrBitDepth`.
|
||||
fn apply_exr(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
|
||||
if let Some(c) = enum_p(p, "exrCompression", cmd, ExrCompression::from_name, EXR_COMPRESSIONS)? {
|
||||
om.exr_compression = c;
|
||||
}
|
||||
if let Some(d) = enum_p(p, "exrBitDepth", cmd, ExrBitDepth::from_name, "half|float")? {
|
||||
om.exr_bit_depth = d;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest
|
||||
/// (captured by `crop.useRoi`).
|
||||
fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<bool> {
|
||||
@@ -344,6 +358,7 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
||||
if let Some(pp) = str_p(p, "proresProfile") {
|
||||
om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?;
|
||||
}
|
||||
apply_exr(om, p, cmd)?;
|
||||
apply_crop(om, roi, p, cmd)?;
|
||||
apply_resize(om, p, cmd)?;
|
||||
if let Some(a) = p.get("audio") {
|
||||
@@ -745,6 +760,8 @@ fn formats(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"formats": v,
|
||||
"proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::<Vec<_>>(),
|
||||
"exrCompressions": ExrCompression::ALL.iter().map(|c| json!({"id": c.name(), "label": c.label(), "lossyHalf": c.is_lossy(ExrBitDepth::Half), "lossyFloat": c.is_lossy(ExrBitDepth::Float)})).collect::<Vec<_>>(),
|
||||
"exrBitDepths": ExrBitDepth::ALL.iter().map(|d| d.name()).collect::<Vec<_>>(),
|
||||
"codecProfiles": CodecProfile::ALL.iter().map(|p| p.label()).collect::<Vec<_>>(),
|
||||
"hevcLevels": VideoCodecOptions::HEVC_LEVELS.iter().map(level).collect::<Vec<_>>(),
|
||||
"av1Levels": VideoCodecOptions::AV1_LEVELS.iter().map(level).collect::<Vec<_>>(),
|
||||
@@ -1036,7 +1053,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add to Render Queue",
|
||||
["Composition"],
|
||||
["Cmd+M", "Cmd+Shift+/"],
|
||||
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
|
||||
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, exrCompression?, exrBitDepth?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
|
||||
can_add,
|
||||
add
|
||||
),
|
||||
@@ -1056,7 +1073,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Output Module Settings...",
|
||||
[],
|
||||
None,
|
||||
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
|
||||
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, exrCompression?: none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab (OpenEXR, scan lines; default zip; lossy: pxr24 on float, b44/b44a on half, dwaa/dwab), exrBitDepth?: half|float (OpenEXR; default float), profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
|
||||
has_items,
|
||||
set_output_module
|
||||
),
|
||||
@@ -1074,7 +1091,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Output Module",
|
||||
["Composition"],
|
||||
None,
|
||||
"{item?|index? (default: the last item), format?, output?, channels?, quality?, bitrate?, proresProfile?, audio?}",
|
||||
"{item?|index? (default: the last item), format?, output?, channels?, quality?, bitrate?, proresProfile?, exrCompression?, exrBitDepth?, audio?}",
|
||||
has_items,
|
||||
add_output_module
|
||||
),
|
||||
|
||||
@@ -181,6 +181,8 @@ pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) -
|
||||
BitDepth::Bpc32 => 32,
|
||||
};
|
||||
match format {
|
||||
// The default; a module's half float ([`OutputModule::exr_bit_depth`]) is read where the
|
||||
// module is known.
|
||||
OutputFormat::ExrSequence => 32,
|
||||
OutputFormat::PngSequence | OutputFormat::TiffSequence => project.min(16),
|
||||
OutputFormat::ProRes => 10,
|
||||
@@ -460,7 +462,8 @@ impl Session {
|
||||
Some(p) if formats.is_none_or(|f| f.contains(&om.format)) => {
|
||||
// Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences /
|
||||
// Movie Files.
|
||||
let bits = output_bits(om.format, self.project.settings.bit_depth);
|
||||
let bits =
|
||||
if om.format == OutputFormat::ExrSequence { om.exr_bit_depth.bits() } else { output_bits(om.format, self.project.settings.bit_depth) };
|
||||
let p = self.prefs.output_name(&p, bits);
|
||||
modules.extend(self.segments(&m, &p));
|
||||
}
|
||||
|
||||
@@ -171,3 +171,70 @@ fn an_unknown_comp_is_named_in_the_error() {
|
||||
assert!(e.contains(&format!("no composition {name}")), "{cmd}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenEXR compression and bit depth over the command API (#2911): set on add, on an Output
|
||||
/// Module and on a second module, listed by `renderQueue.formats`, refused when unknown, and kept
|
||||
/// through a project save and load. A module that doesn't name them stays ZIP, 32-bit float.
|
||||
#[test]
|
||||
fn exr_compression_and_bit_depth_options() {
|
||||
use effectcraft_project::render_queue::{ExrBitDepth, ExrCompression};
|
||||
let mut s = session();
|
||||
let a = s.execute("renderQueue.add", json!({"format": "exr"})).unwrap();
|
||||
let om = &s.project.render_queue[0].output;
|
||||
assert_eq!((om.format, om.exr_compression, om.exr_bit_depth), (OutputFormat::ExrSequence, ExrCompression::Zip, ExrBitDepth::Float));
|
||||
let id = a["item"].clone();
|
||||
s.execute("renderQueue.setOutputModule", json!({"item": id, "exrCompression": "piz", "exrBitDepth": "half"})).unwrap();
|
||||
let om = &s.project.render_queue[0].output;
|
||||
assert_eq!((om.exr_compression, om.exr_bit_depth, om.name.as_str()), (ExrCompression::Piz, ExrBitDepth::Half, "Custom"));
|
||||
s.execute("renderQueue.setOutputModule", json!({"item": id, "exrCompression": "DWAB", "exrBitDepth": 32})).unwrap();
|
||||
let om = &s.project.render_queue[0].output;
|
||||
assert_eq!((om.exr_compression, om.exr_bit_depth), (ExrCompression::Dwab, ExrBitDepth::Float));
|
||||
for bad in [json!({"exrCompression": "htj2k"}), json!({"exrBitDepth": "8"}), json!({"exrCompression": 3})] {
|
||||
let mut p = bad.clone();
|
||||
p["item"] = id.clone();
|
||||
assert!(s.execute("renderQueue.setOutputModule", p).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.project.render_queue[0].output.exr_compression, ExrCompression::Dwab, "a refused value changes nothing");
|
||||
s.execute("render.addOutputModule", json!({"item": id, "format": "exr", "exrCompression": "b44a", "exrBitDepth": "half"})).unwrap();
|
||||
let extra = &s.project.render_queue[0].extra_outputs[0];
|
||||
assert_eq!((extra.exr_compression, extra.exr_bit_depth), (ExrCompression::B44a, ExrBitDepth::Half));
|
||||
let b = s.execute("renderQueue.add", json!({"format": "exr", "exrCompression": "rle", "exrBitDepth": "16"})).unwrap();
|
||||
assert_eq!(b["output"]["exr_compression"], "Rle", "{b}");
|
||||
let log = s.project.render_queue[1].output.describe();
|
||||
assert!(log.contains(&("Compression", "RLE".to_string())) && log.contains(&("Bit Depth", "16-bit Half Float".to_string())), "{log:?}");
|
||||
|
||||
let f = s.execute("renderQueue.formats", json!({})).unwrap();
|
||||
let ids: Vec<&str> = f["exrCompressions"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||||
assert_eq!(ids, ["none", "rle", "zips", "zip", "piz", "pxr24", "b44", "b44a", "dwaa", "dwab"]);
|
||||
assert_eq!(f["exrBitDepths"], json!(["half", "float"]));
|
||||
|
||||
// Saved and opened again, the project keeps them.
|
||||
let dir = std::env::temp_dir().join(format!("ec-exr-options-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("exr.ecproj");
|
||||
std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap();
|
||||
let mut t = Session::default();
|
||||
t.execute("file.open", json!({"path": path})).unwrap();
|
||||
let q = &t.project.render_queue;
|
||||
assert_eq!((q[0].output.exr_compression, q[0].output.exr_bit_depth), (ExrCompression::Dwab, ExrBitDepth::Float));
|
||||
assert_eq!((q[0].extra_outputs[0].exr_compression, q[0].extra_outputs[0].exr_bit_depth), (ExrCompression::B44a, ExrBitDepth::Half));
|
||||
assert_eq!((q[1].output.exr_compression, q[1].output.exr_bit_depth), (ExrCompression::Rle, ExrBitDepth::Half));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Settings ▸ Export ▸ Append Bit Depth to File Name names a half-float OpenEXR module's frames
|
||||
/// `_16bpc` and a 32-bit one's `_32bpc` (#2911).
|
||||
#[test]
|
||||
fn append_bit_depth_follows_the_exr_bit_depth() {
|
||||
use effectcraft_project::render_queue::{ExrBitDepth, RenderQueueItem};
|
||||
let mut s = session();
|
||||
s.execute("prefs.set", json!({"values": {"export.appendBitsToName": true}})).unwrap();
|
||||
let mut item = RenderQueueItem::new(1, s.active_comp_id().unwrap());
|
||||
item.output = effectcraft_project::render_queue::OutputModule::for_format(OutputFormat::ExrSequence);
|
||||
item.output.output = "/out/Main_[#####].exr".into();
|
||||
for (depth, tag) in [(ExrBitDepth::Half, "_16bpc"), (ExrBitDepth::Float, "_32bpc")] {
|
||||
item.output.exr_bit_depth = depth;
|
||||
let outs = s.outputs_of(&item, None).unwrap();
|
||||
assert!(outs.iter().all(|(_, p)| p.contains(tag)), "{depth:?}: {outs:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! OpenEXR sequence frames: one single-part scan-line file per frame, RGBA, with the Output
|
||||
//! Module's compression and sample type (#2911).
|
||||
//!
|
||||
//! Scan lines, not tiles: `image`'s encoder wrote 64×64 RLE tiles, which FilmCraft and other
|
||||
//! scan-line readers refuse (#2805). Each compression keeps its own block height (1 line for
|
||||
//! None, RLE and ZIPS; 16 for ZIP and PXR24; 32 for PIZ, B44, B44A and DWAA; 256 for DWAB) and the
|
||||
//! `exr` crate's default levels for ZIP and DWA. Half-float samples are rounded from the 32-bit
|
||||
//! render to the nearest half, ties to even. The lossy methods (PXR24 on float, B44 / B44A on half,
|
||||
//! DWAA / DWAB at either depth) keep every linear sample within 0.03 + 6 % of its value in the
|
||||
//! tests below. Half float holds at most ±65 504: larger samples are written as that limit, not
|
||||
//! as infinity.
|
||||
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
use effectcraft_project::render_queue::{ExrBitDepth, ExrCompression};
|
||||
use exr::prelude::{Blocks, Compression, Encoding, Image, Layer, LayerAttributes, LineOrder, SpecificChannels, Vec2, WritableImage, f16};
|
||||
|
||||
/// The largest finite half float.
|
||||
const HALF_MAX: f32 = 65_504.0;
|
||||
|
||||
/// A sample as half float: rounded to the nearest half (ties to even), held to ±65 504 so an
|
||||
/// over-range render doesn't turn into infinity. NaN stays NaN.
|
||||
fn to_half(v: f32) -> f16 {
|
||||
f16::from_f32(v.clamp(-HALF_MAX, HALF_MAX))
|
||||
}
|
||||
|
||||
/// The `exr` crate's compression for an Output Module setting.
|
||||
pub fn compression(c: ExrCompression) -> Compression {
|
||||
match c {
|
||||
ExrCompression::None => Compression::Uncompressed,
|
||||
ExrCompression::Rle => Compression::RLE,
|
||||
ExrCompression::Zips => Compression::ZIP1,
|
||||
ExrCompression::Zip => Compression::ZIP16,
|
||||
ExrCompression::Piz => Compression::PIZ,
|
||||
ExrCompression::Pxr24 => Compression::PXR24,
|
||||
ExrCompression::B44 => Compression::B44,
|
||||
ExrCompression::B44a => Compression::B44A,
|
||||
ExrCompression::Dwaa => Compression::DWAA(None),
|
||||
ExrCompression::Dwab => Compression::DWAB(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan lines, top to bottom, with compression `c`.
|
||||
fn encoding(c: ExrCompression) -> Encoding {
|
||||
Encoding { compression: compression(c), blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }
|
||||
}
|
||||
|
||||
/// Write `w`×`h` RGBA pixels (`data`: four samples per pixel as they are to be written,
|
||||
/// premultiplied, rows top to bottom) as one OpenEXR frame, its blocks compressed in parallel as before. A pixel `data`
|
||||
/// doesn't cover is written as transparent black.
|
||||
pub fn write_rgba<W: Write + Seek>(file: &mut W, data: &[f32], (w, h): (usize, usize), (c, depth): (ExrCompression, ExrBitDepth)) -> exr::error::UnitResult {
|
||||
let row = w.saturating_mul(4);
|
||||
let px = move |p: Vec2<usize>| -> [f32; 4] {
|
||||
let o = p.y().saturating_mul(row).saturating_add(p.x().saturating_mul(4));
|
||||
match data.get(o..o.saturating_add(4)) {
|
||||
Some(&[r, g, b, a]) => [r, g, b, a],
|
||||
_ => [0.0; 4],
|
||||
}
|
||||
};
|
||||
match depth {
|
||||
ExrBitDepth::Float => {
|
||||
let image = Image::from_layer(Layer::new(
|
||||
(w, h),
|
||||
LayerAttributes::default(),
|
||||
encoding(c),
|
||||
SpecificChannels::rgba(move |p: Vec2<usize>| {
|
||||
let [r, g, b, a] = px(p);
|
||||
(r, g, b, a)
|
||||
}),
|
||||
));
|
||||
image.write().to_buffered(file)
|
||||
}
|
||||
ExrBitDepth::Half => {
|
||||
let image = Image::from_layer(Layer::new(
|
||||
(w, h),
|
||||
LayerAttributes::default(),
|
||||
encoding(c),
|
||||
SpecificChannels::rgba(move |p: Vec2<usize>| {
|
||||
let [r, g, b, a] = px(p).map(to_half);
|
||||
(r, g, b, a)
|
||||
}),
|
||||
));
|
||||
image.write().to_buffered(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use exr::prelude::{ReadChannels, ReadLayers};
|
||||
use exr::prelude::{SampleType, read};
|
||||
use std::io::Cursor;
|
||||
|
||||
/// A frame with a smooth gradient, grain, over-range highlights, a hard-edged high-contrast
|
||||
/// block and a soft alpha ramp (premultiplied), like a render with text over a plate.
|
||||
fn frame(w: usize, h: usize) -> Vec<f32> {
|
||||
let mut s = 0x9e37_79b9u32;
|
||||
let mut grain = move || {
|
||||
s ^= s << 13;
|
||||
s ^= s >> 17;
|
||||
s ^= s << 5;
|
||||
(s as f32 / u32::MAX as f32 - 0.5) * 0.02
|
||||
};
|
||||
let mut d = Vec::with_capacity(w * h * 4);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let a = ((1.0 - fx) * 3.0).clamp(0.0, 1.0);
|
||||
let text = (w / 3..2 * w / 3).contains(&x) && (h / 3..h / 2).contains(&y) && (x % 6 < 2 || y % 7 == 0);
|
||||
let c = if text { [6.0; 3] } else { [0.18 + 0.8 * fx, 0.05 + 0.6 * fy, 0.4 * (1.0 - fx) + 0.2 * fy] };
|
||||
let g = grain();
|
||||
d.extend_from_slice(&[(c[0] + g).max(0.0) * a, (c[1] + g).max(0.0) * a, (c[2] + g).max(0.0) * a, a]);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn write(data: &[f32], size: (usize, usize), c: ExrCompression, depth: ExrBitDepth) -> Vec<u8> {
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
write_rgba(&mut buf, data, size, (c, depth)).unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
/// The samples of an RGBA file as f32, and the sample type it stores.
|
||||
fn read_back(bytes: &[u8]) -> (Vec<f32>, SampleType) {
|
||||
let image = read()
|
||||
.no_deep_data()
|
||||
.largest_resolution_level()
|
||||
.rgba_channels(
|
||||
|res, _| (res.width(), vec![0f32; res.width() * res.height() * 4]),
|
||||
|(w, v): &mut (usize, Vec<f32>), p: Vec2<usize>, (r, g, b, a): (f32, f32, f32, f32)| {
|
||||
let o = (p.y() * *w + p.x()) * 4;
|
||||
v[o..o + 4].copy_from_slice(&[r, g, b, a]);
|
||||
},
|
||||
)
|
||||
.first_valid_layer()
|
||||
.all_attributes()
|
||||
.from_buffered(Cursor::new(bytes))
|
||||
.unwrap();
|
||||
let ty = image.layer_data.channel_data.channels.0.sample_type;
|
||||
(image.layer_data.channel_data.pixels.1, ty)
|
||||
}
|
||||
|
||||
/// Every compression at both depths reads back: the lossless ones exactly (half: exactly the
|
||||
/// f16 rounding of the render), the lossy ones within the tolerance the docs give. The project
|
||||
/// crate's lossy table matches the `exr` crate's own.
|
||||
#[test]
|
||||
fn every_compression_and_depth_round_trips() {
|
||||
let (w, h) = (97, 61);
|
||||
let src = frame(w, h);
|
||||
for depth in ExrBitDepth::ALL {
|
||||
let want: Vec<f32> = match depth {
|
||||
ExrBitDepth::Float => src.clone(),
|
||||
ExrBitDepth::Half => src.iter().map(|v| f16::from_f32(*v).to_f32()).collect(),
|
||||
};
|
||||
let ty = if depth == ExrBitDepth::Half { SampleType::F16 } else { SampleType::F32 };
|
||||
for c in ExrCompression::ALL {
|
||||
assert_eq!(c.is_lossy(depth), !compression(c).is_lossless_for(ty), "{c:?} {depth:?}");
|
||||
let bytes = write(&src, (w, h), c, depth);
|
||||
if c.is_lossy(depth) {
|
||||
assert!(bytes.len() < write(&src, (w, h), ExrCompression::None, depth).len(), "{c:?} {depth:?}: smaller than None");
|
||||
}
|
||||
let (got, stored) = read_back(&bytes);
|
||||
assert_eq!(stored, ty, "{c:?} {depth:?}");
|
||||
assert_eq!(got.len(), want.len());
|
||||
if c.is_lossy(depth) {
|
||||
for (i, (g, s)) in got.iter().zip(&want).enumerate() {
|
||||
assert!((g - s).abs() <= 0.03 + 0.06 * s.abs(), "{c:?} {depth:?} sample {i}: {g} vs {s}");
|
||||
}
|
||||
} else {
|
||||
assert!(got.iter().zip(&want).all(|(g, s)| g.to_bits() == s.to_bits()), "{c:?} {depth:?} is not exact");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files are single-part scan-line images (no tile bit in the version field) with the chosen
|
||||
/// compression, also when the frame spans several blocks and ends in a partial one.
|
||||
#[test]
|
||||
fn frames_are_scan_lines_with_the_chosen_compression() {
|
||||
let (w, h) = (80, 300);
|
||||
let src = frame(w, h);
|
||||
for depth in ExrBitDepth::ALL {
|
||||
for c in ExrCompression::ALL {
|
||||
let bytes = write(&src, (w, h), c, depth);
|
||||
assert_eq!(&bytes[..4], &[0x76, 0x2f, 0x31, 0x01], "magic");
|
||||
assert_eq!(&bytes[4..6], &[2, 0], "{c:?} {depth:?}: version 2, no tile / multi-part flags");
|
||||
let meta = exr::meta::MetaData::read_from_buffered(Cursor::new(&bytes), false).unwrap();
|
||||
assert_eq!(meta.headers.len(), 1);
|
||||
assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines, "{c:?}");
|
||||
assert_eq!(meta.headers[0].compression, compression(c), "{c:?}");
|
||||
assert_eq!(meta.headers[0].line_order, LineOrder::Increasing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The default (ZIP, 32-bit float) writes the `exr` crate's `SMALL_LOSSLESS` preset (scan
|
||||
/// lines, ZIP), the layout the Render Queue has written since #2812. The reference is that
|
||||
/// preset, not files from before #2812, which were tiled RLE.
|
||||
#[test]
|
||||
fn the_default_matches_the_small_lossless_preset_byte_for_byte() {
|
||||
let (w, h) = (64, 48);
|
||||
let src = frame(w, h);
|
||||
let pixels = SpecificChannels::rgba(|p: Vec2<usize>| {
|
||||
let o = p.y() * w * 4 + p.x() * 4;
|
||||
(src[o], src[o + 1], src[o + 2], src[o + 3])
|
||||
});
|
||||
let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, pixels);
|
||||
let mut before = Cursor::new(Vec::new());
|
||||
Image::from_layer(layer).write().to_buffered(&mut before).unwrap();
|
||||
let default = (ExrCompression::default(), ExrBitDepth::default());
|
||||
assert_eq!(default, (ExrCompression::Zip, ExrBitDepth::Float));
|
||||
assert_eq!(write(&src, (w, h), default.0, default.1), before.into_inner());
|
||||
}
|
||||
|
||||
/// Half-float files hold samples above the half range at its largest finite value, not as
|
||||
/// infinity; the 32-bit file keeps them.
|
||||
#[test]
|
||||
fn half_clamps_over_range_samples_to_the_largest_half() {
|
||||
let data = [1.0e6, 70_000.0, 65_504.0, 1.0, -1.0e6, 0.5, f32::NAN, 1.0];
|
||||
let (half, _) = read_back(&write(&data, (2, 1), ExrCompression::Zip, ExrBitDepth::Half));
|
||||
assert_eq!(&half[..6], &[65_504.0, 65_504.0, 65_504.0, 1.0, -65_504.0, 0.5]);
|
||||
assert!(half[6].is_nan() && half.iter().all(|v| !v.is_infinite()));
|
||||
let (float, _) = read_back(&write(&data, (2, 1), ExrCompression::Zip, ExrBitDepth::Float));
|
||||
assert_eq!(&float[..2], &[1.0e6, 70_000.0]);
|
||||
}
|
||||
|
||||
/// Data shorter than the frame (never produced by the Render Queue) writes transparent black
|
||||
/// for the missing pixels instead of panicking.
|
||||
#[test]
|
||||
fn short_data_is_padded_not_a_panic() {
|
||||
let (got, _) = read_back(&write(&[1.0, 0.5, 0.25, 1.0], (3, 2), ExrCompression::Zip, ExrBitDepth::Half));
|
||||
assert_eq!(&got[..4], &[1.0, 0.5, 0.25, 1.0]);
|
||||
assert!(got[4..].iter().all(|v| *v == 0.0));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | PCM 16/24-bit or 32-bit float, mono/stereo |
|
||||
//! | Uncompressed | QuickTime MOV (`raw `) | 8-bit RGB (depth 24, rows padded to even bytes) or ARGB (depth 32) | RGB + Alpha (straight or premultiplied) | PCM 16/24-bit or 32-bit float, mono/stereo |
|
||||
//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — |
|
||||
//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — |
|
||||
//! | OpenEXR sequence | one file per frame, scan lines | 32-bit float (default) or half float, linear light, premultiplied; compression None, RLE, ZIPS, ZIP (default), PIZ, PXR24, B44, B44A, DWAA, DWAB | yes | — |
|
||||
//! | Targa sequence | one file per frame | uncompressed 8-bit: 24-bit RGB, 32-bit RGB + Alpha (8 alpha bits), 8-bit grey for Alpha; top-left origin | yes (straight or premultiplied) | — |
|
||||
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
|
||||
//! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`effectcraft-av1enc`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz |
|
||||
@@ -34,6 +34,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod encode;
|
||||
pub mod exr_out;
|
||||
mod hevc_av1;
|
||||
mod out;
|
||||
mod pipeline;
|
||||
@@ -340,16 +341,9 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
|
||||
data.extend_from_slice(&[c[0], c[1], c[2], a]);
|
||||
}
|
||||
}
|
||||
// Scan lines with ZIP like `comp.saveFrameAsExr`: `image`'s encoder writes 64×64 RLE tiles,
|
||||
// which FilmCraft and other scan-line readers refuse (#2805).
|
||||
use exr::prelude::{Encoding, Layer, LayerAttributes, SpecificChannels, Vec2, WritableImage};
|
||||
let row = w as usize * 4;
|
||||
let pixels = SpecificChannels::rgba(|p: Vec2<usize>| {
|
||||
let o = p.y() * row + p.x() * 4;
|
||||
(data[o], data[o + 1], data[o + 2], data[o + 3])
|
||||
});
|
||||
let layer = Layer::new((w as usize, h as usize), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, pixels);
|
||||
exr::image::Image::from_layer(layer).write().to_buffered(file).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
// Scan lines with the module's compression and sample type (`exr_out`).
|
||||
let om = cx.output;
|
||||
exr_out::write_rgba(file, &data, (w as usize, h as usize), (om.exr_compression, om.exr_bit_depth)).map_err(|e| ExportError::Encode(e.to_string()))
|
||||
}
|
||||
OutputFormat::JpegSequence => {
|
||||
let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h);
|
||||
|
||||
@@ -379,6 +379,105 @@ fn exr_sequence_writes_scan_lines() {
|
||||
assert_eq!(meta.headers[0].compression, exr::compression::Compression::ZIP16);
|
||||
}
|
||||
|
||||
/// The RGBA samples of an EXR file, as stored (f16 widened to f32), and their sample types.
|
||||
fn exr_samples(path: &Path) -> (Vec<f32>, Vec<exr::prelude::SampleType>) {
|
||||
use exr::prelude::{ReadChannels, ReadLayers};
|
||||
let image = exr::prelude::read()
|
||||
.no_deep_data()
|
||||
.largest_resolution_level()
|
||||
.rgba_channels(
|
||||
|res, _| (res.width(), vec![0f32; res.width() * res.height() * 4]),
|
||||
|(w, v): &mut (usize, Vec<f32>), p: exr::prelude::Vec2<usize>, (r, g, b, a): (f32, f32, f32, f32)| {
|
||||
let o = (p.y() * *w + p.x()) * 4;
|
||||
v[o..o + 4].copy_from_slice(&[r, g, b, a]);
|
||||
},
|
||||
)
|
||||
.first_valid_layer()
|
||||
.all_attributes()
|
||||
.from_file(path)
|
||||
.expect("read exr");
|
||||
let c = &image.layer_data.channel_data.channels;
|
||||
(image.layer_data.channel_data.pixels.1, vec![c.0.sample_type, c.1.sample_type, c.2.sample_type, c.3.as_ref().expect("alpha").sample_type])
|
||||
}
|
||||
|
||||
/// Every OpenEXR compression at both bit depths renders through the Render Queue as scan lines
|
||||
/// with that compression and sample type, and reads back through the media pool (#2911):
|
||||
/// lossless 32-bit float matches the uncompressed render exactly, lossless half float stores
|
||||
/// exactly the half rounding of it, and lossy methods stay within the tolerance the docs give.
|
||||
#[test]
|
||||
fn exr_compressions_and_bit_depths_round_trip() {
|
||||
use effectcraft_export::render_queue::{ExrBitDepth, ExrCompression};
|
||||
use exr::prelude::{SampleType, f16};
|
||||
let (p, cid) = project(None);
|
||||
let render = |c: ExrCompression, d: ExrBitDepth| {
|
||||
let dir = out_dir(&format!("seq-exr-{}-{}", c.name(), d.name()));
|
||||
let om = OutputModule { channels: Channels::Rgba, exr_compression: c, exr_bit_depth: d, ..OutputModule::for_format(OutputFormat::ExrSequence) };
|
||||
run(&p, cid, &NoFootage, &om, &dir.join("f_[#####].exr"));
|
||||
dir.join("f_00007.exr")
|
||||
};
|
||||
let pooled = |path: &Path| {
|
||||
let f = effectcraft_media::probe(path).expect("probe");
|
||||
MediaPool::new().frame_at(&f, Tick(0)).expect("decode")
|
||||
};
|
||||
let reference = render(ExrCompression::None, ExrBitDepth::Float);
|
||||
let (want, _) = exr_samples(&reference);
|
||||
let want_pooled = pooled(&reference);
|
||||
assert!(want.iter().any(|v| *v > 0.5) && want.contains(&0.0), "the frame has red, background and transparent corners");
|
||||
for d in ExrBitDepth::ALL {
|
||||
let ty = if d == ExrBitDepth::Half { SampleType::F16 } else { SampleType::F32 };
|
||||
for c in ExrCompression::ALL {
|
||||
let path = render(c, d);
|
||||
let bytes = std::fs::read(&path).expect("exr");
|
||||
assert_eq!(&bytes[4..6], &[2, 0], "{c:?} {d:?}: single-part scan lines");
|
||||
let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes), false).expect("exr header");
|
||||
assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines, "{c:?} {d:?}");
|
||||
assert_eq!(meta.headers[0].compression.to_string(), exr_compression_name(c), "{c:?} {d:?}");
|
||||
let (got, types) = exr_samples(&path);
|
||||
assert_eq!(types, [ty; 4], "{c:?} {d:?}");
|
||||
let lossy = c.is_lossy(d);
|
||||
for (i, (g, w)) in got.iter().zip(&want).enumerate() {
|
||||
let w = if d == ExrBitDepth::Half { f16::from_f32(*w).to_f32() } else { *w };
|
||||
if lossy {
|
||||
assert!((g - w).abs() <= 0.03 + 0.06 * w.abs(), "{c:?} {d:?} sample {i}: {g} vs {w}");
|
||||
} else {
|
||||
assert_eq!(g.to_bits(), w.to_bits(), "{c:?} {d:?} sample {i}: {g} vs {w}");
|
||||
}
|
||||
}
|
||||
let img = pooled(&path);
|
||||
assert_eq!((img.width, img.height), (W, H));
|
||||
let tol = if lossy {
|
||||
0.06
|
||||
} else if d == ExrBitDepth::Half {
|
||||
2e-3
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
for (k, (a, b)) in img.data.iter().zip(&want_pooled.data).enumerate() {
|
||||
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol), "{c:?} {d:?} pixel {k}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `exr` crate's name for the compression an Output Module setting writes.
|
||||
fn exr_compression_name(c: effectcraft_export::render_queue::ExrCompression) -> String {
|
||||
use effectcraft_export::render_queue::ExrCompression::*;
|
||||
use exr::compression::Compression as X;
|
||||
match c {
|
||||
None => X::Uncompressed,
|
||||
Rle => X::RLE,
|
||||
Zips => X::ZIP1,
|
||||
Zip => X::ZIP16,
|
||||
Piz => X::PIZ,
|
||||
Pxr24 => X::PXR24,
|
||||
B44 => X::B44,
|
||||
B44a => X::B44A,
|
||||
Dwaa => X::DWAA(Option::None),
|
||||
Dwab => X::DWAB(Option::None),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A 32 bpc project whose only layer is a full-frame opaque solid of the given colour.
|
||||
fn bright_project(color: [f32; 3]) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
|
||||
@@ -895,6 +895,141 @@ impl ProResProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenEXR sequence compression. Every method writes scan lines, in blocks of the method's own
|
||||
/// height (1 line for None, RLE and ZIPS; 16 for ZIP and PXR24; 32 for PIZ, B44, B44A and DWAA;
|
||||
/// 256 for DWAB).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExrCompression {
|
||||
None,
|
||||
Rle,
|
||||
/// ZIP, one scan line per block.
|
||||
Zips,
|
||||
/// ZIP, 16 scan lines per block.
|
||||
#[default]
|
||||
Zip,
|
||||
Piz,
|
||||
/// Lossy for 32-bit float (rounded to 24 bits), lossless for half float.
|
||||
Pxr24,
|
||||
/// Lossy for half float; 32-bit float channels are stored uncompressed.
|
||||
B44,
|
||||
/// B44 that also packs flat areas smaller; lossy for half float, 32-bit float is stored uncompressed.
|
||||
B44a,
|
||||
/// Lossy DCT, 32 scan lines per block.
|
||||
Dwaa,
|
||||
/// Lossy DCT, 256 scan lines per block.
|
||||
Dwab,
|
||||
}
|
||||
|
||||
impl ExrCompression {
|
||||
pub const ALL: [ExrCompression; 10] = [
|
||||
ExrCompression::None,
|
||||
ExrCompression::Rle,
|
||||
ExrCompression::Zips,
|
||||
ExrCompression::Zip,
|
||||
ExrCompression::Piz,
|
||||
ExrCompression::Pxr24,
|
||||
ExrCompression::B44,
|
||||
ExrCompression::B44a,
|
||||
ExrCompression::Dwaa,
|
||||
ExrCompression::Dwab,
|
||||
];
|
||||
/// The parameter value (`exrCompression`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ExrCompression::None => "none",
|
||||
ExrCompression::Rle => "rle",
|
||||
ExrCompression::Zips => "zips",
|
||||
ExrCompression::Zip => "zip",
|
||||
ExrCompression::Piz => "piz",
|
||||
ExrCompression::Pxr24 => "pxr24",
|
||||
ExrCompression::B44 => "b44",
|
||||
ExrCompression::B44a => "b44a",
|
||||
ExrCompression::Dwaa => "dwaa",
|
||||
ExrCompression::Dwab => "dwab",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ExrCompression::None => "None",
|
||||
ExrCompression::Rle => "RLE",
|
||||
ExrCompression::Zips => "ZIPS (1 scan line)",
|
||||
ExrCompression::Zip => "ZIP (16 scan lines)",
|
||||
ExrCompression::Piz => "PIZ",
|
||||
ExrCompression::Pxr24 => "PXR24",
|
||||
ExrCompression::B44 => "B44",
|
||||
ExrCompression::B44a => "B44A",
|
||||
ExrCompression::Dwaa => "DWAA",
|
||||
ExrCompression::Dwab => "DWAB",
|
||||
}
|
||||
}
|
||||
/// Whether the written samples can differ from the rendered ones at `depth` (half float is
|
||||
/// already rounded from the 32-bit render; that rounding is not counted here).
|
||||
pub fn is_lossy(self, depth: ExrBitDepth) -> bool {
|
||||
match self {
|
||||
ExrCompression::None | ExrCompression::Rle | ExrCompression::Zips | ExrCompression::Zip | ExrCompression::Piz => false,
|
||||
ExrCompression::Pxr24 => depth == ExrBitDepth::Float,
|
||||
ExrCompression::B44 | ExrCompression::B44a => depth == ExrBitDepth::Half,
|
||||
ExrCompression::Dwaa | ExrCompression::Dwab => true,
|
||||
}
|
||||
}
|
||||
/// Whether the method leaves the samples uncompressed at `depth` (B44 and B44A pack only half
|
||||
/// floats).
|
||||
pub fn stores_uncompressed(self, depth: ExrBitDepth) -> bool {
|
||||
self == ExrCompression::None || (matches!(self, ExrCompression::B44 | ExrCompression::B44a) && depth == ExrBitDepth::Float)
|
||||
}
|
||||
/// Parse a compression name: `none`/`uncompressed`, `rle`, `zips`/`zip1` (Nuke's "Zip (1
|
||||
/// scanline)"), `zip`/`zip16`,
|
||||
/// `piz`, `pxr24`, `b44`, `b44a`, `dwaa`, `dwab` (any case).
|
||||
pub fn from_name(s: &str) -> Option<ExrCompression> {
|
||||
let n = s.to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], "");
|
||||
match n.as_str() {
|
||||
"uncompressed" | "off" => Some(ExrCompression::None),
|
||||
"zip1" | "zips1scanline" | "zip1scanline" | "zip1scanlines" => Some(ExrCompression::Zips),
|
||||
"zip16" | "zip16scanlines" => Some(ExrCompression::Zip),
|
||||
_ => ExrCompression::ALL.into_iter().find(|c| c.name() == n),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenEXR sequence sample type: 16-bit half float (the usual VFX interchange) or 32-bit float.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExrBitDepth {
|
||||
Half,
|
||||
#[default]
|
||||
Float,
|
||||
}
|
||||
|
||||
impl ExrBitDepth {
|
||||
pub const ALL: [ExrBitDepth; 2] = [ExrBitDepth::Half, ExrBitDepth::Float];
|
||||
/// The parameter value (`exrBitDepth`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ExrBitDepth::Half => "half",
|
||||
ExrBitDepth::Float => "float",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ExrBitDepth::Half => "16-bit Half Float",
|
||||
ExrBitDepth::Float => "32-bit Float",
|
||||
}
|
||||
}
|
||||
pub fn bits(self) -> u32 {
|
||||
match self {
|
||||
ExrBitDepth::Half => 16,
|
||||
ExrBitDepth::Float => 32,
|
||||
}
|
||||
}
|
||||
/// Parse `half`/`16`/`f16` or `float`/`32`/`f32` (any case).
|
||||
pub fn from_name(s: &str) -> Option<ExrBitDepth> {
|
||||
match s.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
|
||||
"half" | "16" | "f16" | "16bit" | "halffloat" | "16bithalffloat" => Some(ExrBitDepth::Half),
|
||||
"float" | "32" | "f32" | "32bit" | "full" | "fullfloat" | "32bitfloat" => Some(ExrBitDepth::Float),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HEVC / AV1 profile: 8-bit (HEVC Main, AV1 Main 8-bit) or 10-bit (HEVC Main 10, AV1 Main 10-bit).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CodecProfile {
|
||||
@@ -1065,6 +1200,10 @@ pub struct OutputModule {
|
||||
/// Movies: frames between key frames (0 = automatic, two seconds).
|
||||
pub keyframe_interval: u32,
|
||||
pub prores_profile: ProResProfile,
|
||||
/// OpenEXR sequence: compression (scan lines).
|
||||
pub exr_compression: ExrCompression,
|
||||
/// OpenEXR sequence: half or 32-bit float samples.
|
||||
pub exr_bit_depth: ExrBitDepth,
|
||||
pub crop: Crop,
|
||||
pub resize: Resize,
|
||||
pub audio: AudioOutput,
|
||||
@@ -1099,6 +1238,8 @@ impl Default for OutputModule {
|
||||
webm_bitrate: false,
|
||||
keyframe_interval: 0,
|
||||
prores_profile: ProResProfile::Hq,
|
||||
exr_compression: ExrCompression::Zip,
|
||||
exr_bit_depth: ExrBitDepth::Float,
|
||||
crop: Crop::default(),
|
||||
resize: Resize::default(),
|
||||
audio: AudioOutput::Auto,
|
||||
@@ -1155,6 +1296,10 @@ impl OutputModule {
|
||||
match self.format {
|
||||
OutputFormat::H264 => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))),
|
||||
OutputFormat::ProRes => v.push(("Codec", self.prores_profile.label().into())),
|
||||
OutputFormat::ExrSequence => {
|
||||
v.push(("Compression", self.exr_compression_label()));
|
||||
v.push(("Bit Depth", self.exr_bit_depth.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())),
|
||||
_ => {}
|
||||
@@ -1215,6 +1360,18 @@ impl OutputModule {
|
||||
}
|
||||
self.output = format!("{stem}.{}", format.extension());
|
||||
}
|
||||
/// The OpenEXR compression's label, marked when it is lossy at the module's bit depth or, for
|
||||
/// B44 / B44A at 32-bit float, stores the samples uncompressed.
|
||||
pub fn exr_compression_label(&self) -> String {
|
||||
let (c, d) = (self.exr_compression, self.exr_bit_depth);
|
||||
if c.is_lossy(d) {
|
||||
format!("{} (lossy)", c.label())
|
||||
} else if c != ExrCompression::None && c.stores_uncompressed(d) {
|
||||
format!("{} (uncompressed at float)", c.label())
|
||||
} else {
|
||||
c.label().into()
|
||||
}
|
||||
}
|
||||
/// Whether this module can write alpha (the format can, and WebM uses VP9).
|
||||
pub fn supports_alpha(&self) -> bool {
|
||||
self.format.supports_alpha() && !(self.format == OutputFormat::WebM && self.webm_codec == WebmVideoCodec::Av1)
|
||||
@@ -1246,6 +1403,7 @@ impl OutputModule {
|
||||
if self.channels == Channels::Rgba && !self.prores_profile.is_4444() { "Apple ProRes 4444" } else { self.prores_profile.label() }
|
||||
),
|
||||
OutputFormat::Hevc | OutputFormat::Av1 => format!("{} · {}", self.format.label(), self.codec_summary()),
|
||||
OutputFormat::ExrSequence => format!("{} · {} · {} · {ch}", self.format.label(), self.exr_compression_label(), self.exr_bit_depth.label()),
|
||||
OutputFormat::WebM if self.webm_codec == WebmVideoCodec::Av1 => {
|
||||
format!("WebM (AV1 + Opus {} kbps) · {}", self.opus_bitrate_kbps, self.codec_summary())
|
||||
}
|
||||
@@ -1895,4 +2053,45 @@ mod tests {
|
||||
let old: OutputModule = serde_json::from_str(r#"{"format":"H264","bitrate_kbps":5000}"#).unwrap();
|
||||
assert_eq!((old.codec, old.webm_codec, old.opus_bitrate_kbps), (VideoCodecOptions::default(), WebmVideoCodec::Vp9, 192));
|
||||
}
|
||||
|
||||
/// OpenEXR compression and bit depth (#2911): names parse, lossy methods are marked for the
|
||||
/// depth they lose data at, and modules saved before the settings existed load as ZIP, 32-bit
|
||||
/// float (what those projects rendered).
|
||||
#[test]
|
||||
fn exr_compression_and_bit_depth() {
|
||||
for c in ExrCompression::ALL {
|
||||
assert_eq!(ExrCompression::from_name(c.name()), Some(c));
|
||||
assert_eq!(ExrCompression::from_name(&c.name().to_ascii_uppercase()), Some(c));
|
||||
}
|
||||
assert_eq!(ExrCompression::from_name("uncompressed"), Some(ExrCompression::None));
|
||||
assert_eq!(ExrCompression::from_name("zip16"), Some(ExrCompression::Zip));
|
||||
assert_eq!(ExrCompression::from_name("ZIP1"), Some(ExrCompression::Zips));
|
||||
assert_eq!(ExrCompression::from_name("Zip (1 scanline)"), Some(ExrCompression::Zips));
|
||||
assert_eq!(ExrCompression::from_name("Zip (16 scanlines)"), Some(ExrCompression::Zip));
|
||||
assert_eq!(ExrCompression::from_name("htj2k"), None);
|
||||
assert_eq!(ExrBitDepth::from_name("16"), Some(ExrBitDepth::Half));
|
||||
assert_eq!(ExrBitDepth::from_name("Half"), Some(ExrBitDepth::Half));
|
||||
assert_eq!(ExrBitDepth::from_name("f32"), Some(ExrBitDepth::Float));
|
||||
assert_eq!(ExrBitDepth::from_name("8"), None);
|
||||
let lossy = |d| ExrCompression::ALL.into_iter().filter(|c| c.is_lossy(d)).map(ExrCompression::name).collect::<Vec<_>>();
|
||||
assert_eq!(lossy(ExrBitDepth::Float), ["pxr24", "dwaa", "dwab"]);
|
||||
assert_eq!(lossy(ExrBitDepth::Half), ["b44", "b44a", "dwaa", "dwab"]);
|
||||
|
||||
let mut m = OutputModule::for_format(OutputFormat::ExrSequence);
|
||||
assert_eq!((m.exr_compression, m.exr_bit_depth), (ExrCompression::Zip, ExrBitDepth::Float));
|
||||
assert_eq!(m.summary(), "OpenEXR Sequence · ZIP (16 scan lines) · 32-bit Float · RGB");
|
||||
m.exr_compression = ExrCompression::Dwaa;
|
||||
m.exr_bit_depth = ExrBitDepth::Half;
|
||||
assert!(m.describe().contains(&("Compression", "DWAA (lossy)".to_string())), "{:?}", m.describe());
|
||||
assert!(m.describe().contains(&("Bit Depth", "16-bit Half Float".to_string())));
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert_eq!(serde_json::from_str::<OutputModule>(&json).unwrap(), m);
|
||||
m.exr_compression = ExrCompression::B44a;
|
||||
m.exr_bit_depth = ExrBitDepth::Float;
|
||||
assert_eq!(m.exr_compression_label(), "B44A (uncompressed at float)");
|
||||
m.exr_bit_depth = ExrBitDepth::Half;
|
||||
assert_eq!(m.exr_compression_label(), "B44A (lossy)");
|
||||
let old: OutputModule = serde_json::from_str(r#"{"format":"ExrSequence","channels":"Rgba"}"#).unwrap();
|
||||
assert_eq!((old.exr_compression, old.exr_bit_depth), (ExrCompression::Zip, ExrBitDepth::Float));
|
||||
}
|
||||
}
|
||||
@@ -323,4 +323,19 @@ mod tests {
|
||||
t.delete(TemplateKind::OutputModule, "High Quality").unwrap();
|
||||
assert_eq!(t.output_module("High Quality").unwrap().format, OutputFormat::ProRes);
|
||||
}
|
||||
|
||||
/// An Output Module template keeps its OpenEXR compression and bit depth through a save and
|
||||
/// load (#2911); the built-in EXR template stays ZIP, 32-bit float.
|
||||
#[test]
|
||||
fn exr_options_survive_a_template_round_trip() {
|
||||
use crate::render_queue::{ExrBitDepth, ExrCompression};
|
||||
let mut t = RenderTemplates::default();
|
||||
let builtin = t.output_module("OpenEXR Sequence with Alpha").unwrap();
|
||||
assert_eq!((builtin.exr_compression, builtin.exr_bit_depth), (ExrCompression::Zip, ExrBitDepth::Float));
|
||||
let m = OutputModule { exr_compression: ExrCompression::Piz, exr_bit_depth: ExrBitDepth::Half, ..OutputModule::for_format(OutputFormat::ExrSequence) };
|
||||
t.save_output_module("EXR Half PIZ", m);
|
||||
let back: RenderTemplates = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap();
|
||||
let m = back.output_module("exr half piz").unwrap();
|
||||
assert_eq!((m.format, m.exr_compression, m.exr_bit_depth), (OutputFormat::ExrSequence, ExrCompression::Piz, ExrBitDepth::Half));
|
||||
}
|
||||
}
|
||||
@@ -334,6 +334,10 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"),
|
||||
("Copy Source Path", "ソースパスをコピー"),
|
||||
("Effects: {}", "エフェクト:{}"),
|
||||
("EXR Bit Depth: {}", "EXR ビット深度:{}"),
|
||||
("EXR Compression: {}", "EXR 圧縮:{}"),
|
||||
("EXR Compression: {} (lossy)", "EXR 圧縮:{}(非可逆)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "EXR 圧縮:{}(float では非圧縮)"),
|
||||
("Empty folder", "空のフォルダー"),
|
||||
("Enable Depth of Field", "被写界深度を有効化"),
|
||||
("Enable Expression", "エクスプレッションを有効化"),
|
||||
@@ -1459,6 +1463,10 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Copy Presets Folder Path", "复制预设文件夹路径"),
|
||||
("Copy Source Path", "复制源路径"),
|
||||
("Effects: {}", "效果:{}"),
|
||||
("EXR Bit Depth: {}", "EXR 位深度:{}"),
|
||||
("EXR Compression: {}", "EXR 压缩:{}"),
|
||||
("EXR Compression: {} (lossy)", "EXR 压缩:{}(有损)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "EXR 压缩:{}(浮点下不压缩)"),
|
||||
("Empty folder", "空文件夹"),
|
||||
("Enable Depth of Field", "启用景深"),
|
||||
("Enable Expression", "启用表达式"),
|
||||
@@ -2572,6 +2580,10 @@ const SWEDISH: &[(&str, &str)] = &[
|
||||
("Effects & Presets options", "Alternativ för Effekter och förinställningar"),
|
||||
("Copy Presets Folder Path", "Kopiera sökväg till förinställningsmappen"),
|
||||
("Effects: {}", "Effekter: {}"),
|
||||
("EXR Bit Depth: {}", "EXR-bitdjup: {}"),
|
||||
("EXR Compression: {}", "EXR-komprimering: {}"),
|
||||
("EXR Compression: {} (lossy)", "EXR-komprimering: {} (förstörande)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "EXR-komprimering: {} (okomprimerad vid float)"),
|
||||
("Empty folder", "Tom mapp"),
|
||||
("Enable Depth of Field", "Aktivera skärpedjup"),
|
||||
("Enable Expression", "Aktivera uttryck"),
|
||||
@@ -3833,6 +3845,10 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Copy Presets Folder Path", "複製預設集資料夾路徑"),
|
||||
("Copy Source Path", "複製來源路徑"),
|
||||
("Effects: {}", "效果:{}"),
|
||||
("EXR Bit Depth: {}", "EXR 位元深度:{}"),
|
||||
("EXR Compression: {}", "EXR 壓縮:{}"),
|
||||
("EXR Compression: {} (lossy)", "EXR 壓縮:{}(失真)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "EXR 壓縮:{}(浮點時不壓縮)"),
|
||||
("Empty folder", "空資料夾"),
|
||||
("Enable Depth of Field", "啟用景深"),
|
||||
("Enable Expression", "啟用表達式"),
|
||||
@@ -4900,6 +4916,10 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Copy Presets Folder Path", "Копіювати шлях до папки наборів"),
|
||||
("Copy Source Path", "Копіювати шлях до джерела"),
|
||||
("Effects: {}", "Ефекти: {}"),
|
||||
("EXR Bit Depth: {}", "Бітова глибина EXR: {}"),
|
||||
("EXR Compression: {}", "Стиснення EXR: {}"),
|
||||
("EXR Compression: {} (lossy)", "Стиснення EXR: {} (із втратами)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Стиснення EXR: {} (без стиснення для float)"),
|
||||
("Empty folder", "Порожня тека"),
|
||||
("Enable Depth of Field", "Увімкнути глибину різкості"),
|
||||
("Enable Expression", "Увімкнути вираз"),
|
||||
@@ -5469,6 +5489,10 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("Copy Presets Folder Path", "Copiar caminho da pasta de predefinições"),
|
||||
("Copy Source Path", "Copiar caminho da origem"),
|
||||
("Effects: {}", "Efeitos: {}"),
|
||||
("EXR Bit Depth: {}", "Profundidade de bits EXR: {}"),
|
||||
("EXR Compression: {}", "Compactação EXR: {}"),
|
||||
("EXR Compression: {} (lossy)", "Compactação EXR: {} (com perdas)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Compactação EXR: {} (sem compactação em float)"),
|
||||
("Empty folder", "Pasta vazia"),
|
||||
("Enable Depth of Field", "Ativar profundidade de campo"),
|
||||
("Enable Expression", "Ativar expressão"),
|
||||
@@ -6235,6 +6259,10 @@ const POLISH: &[(&str, &str)] = &[
|
||||
("Effects & Presets options", "Opcje efektów i ustawień"),
|
||||
("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"),
|
||||
("Effects: {}", "Efekty: {}"),
|
||||
("EXR Bit Depth: {}", "Głębia bitowa EXR: {}"),
|
||||
("EXR Compression: {}", "Kompresja EXR: {}"),
|
||||
("EXR Compression: {} (lossy)", "Kompresja EXR: {} (stratna)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Kompresja EXR: {} (bez kompresji dla float)"),
|
||||
("Empty folder", "Pusty folder"),
|
||||
("Enable Depth of Field", "Włącz głębię ostrości"),
|
||||
("Enable Expression", "Włącz wyrażenie"),
|
||||
@@ -6987,6 +7015,10 @@ const CZECH: &[(&str, &str)] = &[
|
||||
("Copy Presets Folder Path", "Kopírovat cestu ke složce přednastavení"),
|
||||
("Copy Source Path", "Kopírovat cestu ke zdroji"),
|
||||
("Effects: {}", "Efekty: {}"),
|
||||
("EXR Bit Depth: {}", "Bitová hloubka EXR: {}"),
|
||||
("EXR Compression: {}", "Komprese EXR: {}"),
|
||||
("EXR Compression: {} (lossy)", "Komprese EXR: {} (ztrátová)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Komprese EXR: {} (u float bez komprese)"),
|
||||
("Empty folder", "Prázdná složka"),
|
||||
("Enable Depth of Field", "Povolit hloubku ostrosti"),
|
||||
("Enable Expression", "Povolit výraz"),
|
||||
@@ -7558,6 +7590,10 @@ const SPANISH: &[(&str, &str)] = &[
|
||||
("Effects", "Efectos"),
|
||||
("Effects & Presets options", "Opciones de Efectos y ajustes preestablecidos"),
|
||||
("Effects: {}", "Efectos: {}"),
|
||||
("EXR Bit Depth: {}", "Profundidad de bits EXR: {}"),
|
||||
("EXR Compression: {}", "Compresión EXR: {}"),
|
||||
("EXR Compression: {} (lossy)", "Compresión EXR: {} (con pérdida)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Compresión EXR: {} (sin compresión en float)"),
|
||||
("Empty folder", "Carpeta vacía"),
|
||||
("Enable Depth of Field", "Habilitar profundidad de campo"),
|
||||
("Enable Expression", "Habilitar expresión"),
|
||||
@@ -8117,6 +8153,10 @@ const GEORGIAN: &[(&str, &str)] = &[
|
||||
("Copy Presets Folder Path", "პრესეტების საქაღალდის მისამართის კოპირება"),
|
||||
("Copy Source Path", "წყაროს მისამართის კოპირება"),
|
||||
("Effects: {}", "ეფექტები: {}"),
|
||||
("EXR Bit Depth: {}", "EXR ბიტის სიღრმე: {}"),
|
||||
("EXR Compression: {}", "EXR შეკუმშვა: {}"),
|
||||
("EXR Compression: {} (lossy)", "EXR შეკუმშვა: {} (დანაკარგით)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "EXR შეკუმშვა: {} (float-ზე შეკუმშვის გარეშე)"),
|
||||
("Empty folder", "ცარიელი საქაღალდე"),
|
||||
("Enable Depth of Field", "ველის სიღრმის ჩართვა"),
|
||||
("Enable Expression", "ექსპრესიის ჩართვა"),
|
||||
@@ -9088,6 +9128,10 @@ const FRENCH: &[(&str, &str)] = &[
|
||||
("Copy Presets Folder Path", "Copier le chemin du dossier des préréglages"),
|
||||
("Copy Source Path", "Copier le chemin source"),
|
||||
("Effects: {}", "Effets : {}"),
|
||||
("EXR Bit Depth: {}", "Profondeur de bits EXR : {}"),
|
||||
("EXR Compression: {}", "Compression EXR : {}"),
|
||||
("EXR Compression: {} (lossy)", "Compression EXR : {} (avec perte)"),
|
||||
("EXR Compression: {} (uncompressed at float)", "Compression EXR : {} (non compressé en float)"),
|
||||
("Empty folder", "Dossier vide"),
|
||||
("Enable Depth of Field", "Activer la profondeur de champ"),
|
||||
("Enable Expression", "Activer l’expression"),
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
|
||||
use crate::i18n::{tr, tr_args};
|
||||
use effectcraft_engine::project::render_queue::{
|
||||
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, EffectsMode, FieldRender, OpusApplication, OutputFormat, PostRenderAction,
|
||||
ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, WebmVideoCodec,
|
||||
AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, EffectsMode, ExrBitDepth, ExrCompression, FieldRender, OpusApplication,
|
||||
OutputFormat, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderStatus, ResizeQuality,
|
||||
SwitchOverride, TimeSpan, WebmVideoCodec,
|
||||
};
|
||||
use effectcraft_engine::project::render_templates::RenderTemplates;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
@@ -177,6 +178,7 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &Ren
|
||||
v.push((mark(o.prores_profile == p, p.label()), json!({"proresProfile": format!("{p:?}")})));
|
||||
}
|
||||
}
|
||||
OutputFormat::ExrSequence => exr_menu(o, &mut v),
|
||||
OutputFormat::H264 => {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for kbps in [2_000, 5_000, 10_000, 15_000, 20_000, 40_000] {
|
||||
@@ -272,6 +274,26 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &Ren
|
||||
v
|
||||
}
|
||||
|
||||
/// OpenEXR options: compression (marked where lossy at the module's bit depth) and bit depth.
|
||||
fn exr_menu(o: &effectcraft_engine::project::render_queue::OutputModule, v: &mut Vec<(String, Value)>) {
|
||||
v.push(("-".into(), Value::Null));
|
||||
for c in ExrCompression::ALL {
|
||||
let d = o.exr_bit_depth;
|
||||
let label = if c.is_lossy(d) {
|
||||
"EXR Compression: {} (lossy)"
|
||||
} else if c != ExrCompression::None && c.stores_uncompressed(d) {
|
||||
"EXR Compression: {} (uncompressed at float)"
|
||||
} else {
|
||||
"EXR Compression: {}"
|
||||
};
|
||||
v.push((mark(o.exr_compression == c, &tr_args(label, &[&(c.label())])), json!({"exrCompression": c.name()})));
|
||||
}
|
||||
v.push(("-".into(), Value::Null));
|
||||
for d in ExrBitDepth::ALL {
|
||||
v.push((mark(o.exr_bit_depth == d, &tr_args("EXR Bit Depth: {}", &[&(d.label())])), json!({"exrBitDepth": d.name()})));
|
||||
}
|
||||
}
|
||||
|
||||
/// HEVC / AV1 options: profile, level, rate control (bitrate or constant quality), key frames.
|
||||
fn codec_menu(o: &effectcraft_engine::project::render_queue::OutputModule, v: &mut Vec<(String, Value)>) {
|
||||
use effectcraft_engine::project::render_queue::{CodecProfile, RateControlMode, VideoCodecOptions};
|
||||
@@ -1076,4 +1098,35 @@ mod tests {
|
||||
pr.finished = true;
|
||||
assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still");
|
||||
}
|
||||
|
||||
/// #2911: an OpenEXR module's menu offers every compression and both bit depths, ticks the
|
||||
/// current ones and marks the methods that are lossy at the current depth; other formats
|
||||
/// don't show them.
|
||||
#[test]
|
||||
fn exr_menu_offers_compression_and_bit_depth() {
|
||||
use effectcraft_engine::project::render_queue::{ExrBitDepth, OutputFormat, OutputModule, RenderQueueItem};
|
||||
use effectcraft_engine::project::{ItemId, render_templates::RenderTemplates};
|
||||
crate::i18n::ui::set_current("en");
|
||||
let mut it = RenderQueueItem::new(1, ItemId(1));
|
||||
it.output = OutputModule::for_format(OutputFormat::ExrSequence);
|
||||
let menu = |it: &RenderQueueItem| super::output_menu(it, &OutputFormat::ALL, &RenderTemplates::default());
|
||||
let find = |m: &[(String, serde_json::Value)], label: &str| m.iter().find(|(l, _)| l.trim_start_matches('✓').trim() == label).cloned();
|
||||
let m = menu(&it);
|
||||
assert_eq!(find(&m, "EXR Compression: ZIP (16 scan lines)").unwrap().0, "✓ EXR Compression: ZIP (16 scan lines)");
|
||||
assert_eq!(find(&m, "EXR Compression: PIZ").unwrap().1, serde_json::json!({"exrCompression": "piz"}));
|
||||
assert!(find(&m, "EXR Compression: PXR24 (lossy)").is_some() && find(&m, "EXR Compression: B44 (uncompressed at float)").is_some());
|
||||
assert_eq!(find(&m, "EXR Compression: ZIPS (1 scan line)").unwrap().1, serde_json::json!({"exrCompression": "zips"}));
|
||||
assert_eq!(find(&m, "EXR Bit Depth: 32-bit Float").unwrap().0, "✓ EXR Bit Depth: 32-bit Float");
|
||||
assert_eq!(find(&m, "EXR Bit Depth: 16-bit Half Float").unwrap().1, serde_json::json!({"exrBitDepth": "half"}));
|
||||
assert_eq!(m.iter().filter(|(l, _)| l.contains("EXR Compression: ")).count(), 10);
|
||||
it.output.exr_bit_depth = ExrBitDepth::Half;
|
||||
let m = menu(&it);
|
||||
assert!(
|
||||
find(&m, "EXR Compression: PXR24").is_some()
|
||||
&& find(&m, "EXR Compression: B44 (lossy)").is_some()
|
||||
&& find(&m, "EXR Compression: DWAA (lossy)").is_some()
|
||||
);
|
||||
it.output = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
assert!(!menu(&it).iter().any(|(l, _)| l.contains("EXR Compression") || l.contains("EXR Bit Depth")));
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,13 @@ build real projects through these interfaces; they are worked examples of everyt
|
||||
proresProfile: 4444, output}` then `renderQueue.render {"wait": true}`. Check a render by
|
||||
importing it (`file.import`, `file.newCompFromSelection`) and `render_frame {transparent: true}`
|
||||
for its alpha (ProRes 4444 and WebM VP9 alpha both read back as straight alpha).
|
||||
OpenEXR sequences (`format: exr`) are scan-line files, linear and premultiplied, and take
|
||||
`exrCompression: none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab` (default `zip`, 16-line
|
||||
blocks; `zips` is ZIP one scan line per block, the "Zip (1 scanline)" many compositing
|
||||
pipelines default to) and `exrBitDepth: half|float` (default `float`); `renderQueue.formats`
|
||||
lists them.
|
||||
PXR24 on float, B44 / B44A on half and DWAA / DWAB are lossy (in the tests every linear sample
|
||||
stays within 0.03 + 6 % of its value); B44 / B44A store 32-bit float uncompressed. Half rounds each sample to the nearest half float and holds it to ±65 504.
|
||||
**A render never writes one file twice either:** when two outputs of the job (two items, a
|
||||
`renderQueue.duplicate` copy, output modules, segments) name the same file, the whole render is
|
||||
refused before anything is written, naming both items. Sequences clash only on frames both
|
||||
@@ -752,6 +759,7 @@ effectcraft-cli exec layer.newNull --bridge 9877 # same commands
|
||||
effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json","includeExpressions":true}' main.ecproj --json
|
||||
effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save
|
||||
effectcraft-cli render main.ecproj --comp Main --out main.mov --format prores --progress # progress lines on stderr for a program that reads them
|
||||
effectcraft-cli render main.ecproj --comp Main --out 'main_[#####].exr' --exr-compression piz --exr-depth half # half-float PIZ OpenEXR frames
|
||||
```
|
||||
|
||||
Runs that save over the project they opened (`--save`, or `--save-as` the same file) take turns, so
|
||||
|
||||
@@ -41,7 +41,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
|
||||
| PNG | R/W | yes | yes (sequence) | good |
|
||||
| JPEG | R/W | yes | yes (sequence) | good |
|
||||
| TIFF | R/W | yes | yes (sequence) | good |
|
||||
| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); a multi-layer frame decodes once for its picture and EXtractoR / Cryptomatte from the first frame on, except Media Replacement items, which are learned at their first frame, and files whose picture the `image` crate still decodes (#474); footage alone keeps only its picture's channels, though a single-part file still decompresses every channel's pixels; decoded channels are held as f32 |
|
||||
| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (scan-line sequence, half or 32-bit float, every compression but HTJ2K) | good; HTJ2K compression fails (#482); a multi-layer frame decodes once for its picture and EXtractoR / Cryptomatte from the first frame on, except Media Replacement items, which are learned at their first frame, and files whose picture the `image` crate still decodes (#474); footage alone keeps only its picture's channels, though a single-part file still decompresses every channel's pixels; decoded channels are held as f32 |
|
||||
| Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec |
|
||||
| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes; Composition and Composition - Retain Layer Sizes, #1536) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS |
|
||||
| SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't |
|
||||
@@ -100,6 +100,10 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
|
||||
|
||||
H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, uncompressed 8-bit RGB / RGB + Alpha MOV (`raw `, + PCM;
|
||||
#372), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR / Targa sequences, animated GIF, WAV, AIFF.
|
||||
OpenEXR sequences are scan-line RGBA files in half or 32-bit float (default) with None, RLE, ZIPS,
|
||||
ZIP (default), PIZ, PXR24, B44, B44A, DWAA or DWAB compression; PXR24 on float, B44 / B44A on half
|
||||
and DWAA / DWAB are lossy (#2911). No tiled or multi-part output, no HTJ2K (the `exr` crate can't
|
||||
write it).
|
||||
Missing against After Effects + Media Encoder: 16-bpc uncompressed, AVI and lossless-compressed
|
||||
single-file video (Animation / PNG codecs), DPX / Photoshop sequences, MXF, DNxHR, hardware
|
||||
encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file
|
||||
|
||||
Reference in new issue
Block a user