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:
FredApps authored and GitHub committed 2026-11-06 02:26:23 +01:00
commit 3102b6e632
14 files changed
+824 -23

No files matched your search

+11 -2
View File
@@ -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);
+52
View File
@@ -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);
}
+23 -6
View File
@@ -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
),
+4 -1
View File
@@ -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));
}
+67
View File
@@ -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:?}");
}
}
+237
View File
@@ -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));
}
}
+5 -11
View File
@@ -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);
+99
View File
@@ -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();
+199
View File
@@ -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));
}
}
+15
View File
@@ -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));
}
}
+44
View File
@@ -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"),
+55 -2
View File
@@ -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")));
}
}
+8
View File
@@ -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
+5 -1
View File
@@ -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