mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
405 lines
20 KiB
Rust
405 lines
20 KiB
Rust
//! Render Settings and Output Module features end to end (through a [`Sink`], so nothing touches
|
|
//! the disk): field rendering and 3:2 pulldown, crop / region of interest / resize, alpha-only
|
|
//! and premultiplied output, the solo and guide-layer overrides, the render log, storage
|
|
//! overflow and PCM audio formats.
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use effectcraft_color::Label;
|
|
use effectcraft_export::render_queue::*;
|
|
use effectcraft_export::{Job, JobOptions, Report, Sink, StorageQuota, export};
|
|
use effectcraft_keyframe::{Keyframe, Value};
|
|
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
|
use effectcraft_render::NoFootage;
|
|
use effectcraft_time::{FrameRate, Tick};
|
|
|
|
const W: u32 = 64;
|
|
const H: u32 = 48;
|
|
|
|
/// A comp at `rate`, black, with a white bar 8 px wide sweeping left to right (64 px/s) and a
|
|
/// static red block at the bottom left; `extra` adds layers.
|
|
fn project(rate: FrameRate, extra: impl FnOnce(&mut Project, &mut Comp)) -> (Project, ItemId) {
|
|
let mut p = Project::default();
|
|
let mut comp = Comp::new(W, H, rate, Tick::from_seconds_f64(2.0));
|
|
comp.background = [0.0, 0.0, 0.0];
|
|
let bar = p.add_item("Bar", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 8, height: 32, pixel_aspect: 1.0 }));
|
|
let mut l = build::layer(&mut p, &comp, "Bar", LayerSource::Solid { item: bar }, (8, 32), None);
|
|
l.props.prop_mut("transform/position").unwrap().keys =
|
|
vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0, 16.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([64.0, 16.0, 0.0]))];
|
|
comp.layers.push(l);
|
|
let red = p.add_item("Block", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 16, height: 8, pixel_aspect: 1.0 }));
|
|
let mut b = build::layer(&mut p, &comp, "Block", LayerSource::Solid { item: red }, (16, 8), None);
|
|
b.props.prop_mut("transform/position").unwrap().value = Value::Vec3([8.0, 44.0, 0.0]);
|
|
comp.layers.push(b);
|
|
extra(&mut p, &mut comp);
|
|
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
|
(p, cid)
|
|
}
|
|
|
|
type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
|
|
|
fn custom(start: f64, end: f64) -> RenderSettings {
|
|
RenderSettings { time_span: TimeSpan::Custom { start: Tick::from_seconds_f64(start), end: Tick::from_seconds_f64(end) }, ..Default::default() }
|
|
}
|
|
|
|
fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result<Report, String>, Files) {
|
|
let files: Files = Default::default();
|
|
let f = files.clone();
|
|
let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| f.lock().unwrap().push((path.to_string(), data)));
|
|
let job = Job {
|
|
project: p,
|
|
footage: &NoFootage,
|
|
expr: None,
|
|
accel: None,
|
|
comp: cid,
|
|
settings: s,
|
|
output: om,
|
|
path,
|
|
sink: Some(&*sink),
|
|
options,
|
|
nested_switches: true,
|
|
};
|
|
let r = export(&job, &mut |pr| !(cancel && pr.done > 0)).map_err(|e| e.to_string());
|
|
(r, files)
|
|
}
|
|
|
|
fn run(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str) -> Files {
|
|
let (r, files) = run_with(p, cid, s, om, path, JobOptions::default(), false);
|
|
r.expect("export");
|
|
files
|
|
}
|
|
|
|
fn png(files: &Files, i: usize) -> image::RgbaImage {
|
|
let f = files.lock().unwrap();
|
|
let pngs: Vec<&(String, Vec<u8>)> = f.iter().filter(|(p, _)| p.ends_with(".png")).collect();
|
|
let mut pngs = pngs;
|
|
pngs.sort_by(|a, b| a.0.cmp(&b.0));
|
|
image::load_from_memory(&pngs[i].1).expect("png").to_rgba8()
|
|
}
|
|
|
|
fn row(img: &image::RgbaImage, y: u32) -> Vec<u8> {
|
|
(0..img.width()).flat_map(|x| img.get_pixel(x, y).0).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn field_render_interleaves_two_times() {
|
|
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
|
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
|
// Frame 10 at 25 fps with fields: 0.40 s and 0.42 s.
|
|
for (fr, upper) in [(FieldRender::UpperFirst, true), (FieldRender::LowerFirst, false)] {
|
|
let s = RenderSettings { field_render: fr, ..custom(0.4, 0.44) };
|
|
let fields = png(&run(&p, cid, &s, &om, "/f/field_[#####].png"), 0);
|
|
// Progressive references at 50 fps: frames 20 (0.40 s) and 21 (0.42 s).
|
|
let r = RenderSettings { frame_rate: Some(FrameRate::new(50, 1)), ..custom(0.4, 0.44) };
|
|
let refs = run(&p, cid, &r, &om, "/f/ref_[#####].png");
|
|
let (a, b) = (png(&refs, 0), png(&refs, 1));
|
|
assert_ne!(row(&a, 10), row(&b, 10), "the bar moved between the fields");
|
|
for y in 0..H {
|
|
let first = (y % 2 == 0) == upper;
|
|
assert_eq!(row(&fields, y), row(if first { &a } else { &b }, y), "{fr:?}: line {y}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pulldown_follows_the_cadence() {
|
|
let ntsc = FrameRate::new(30000, 1001);
|
|
let (p, cid) = project(ntsc, |_, _| {});
|
|
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
|
let five = 5.0 * 1001.0 / 30000.0;
|
|
let s = RenderSettings { field_render: FieldRender::UpperFirst, pulldown: Pulldown::Wssww, ..custom(0.0, five) };
|
|
let out = run(&p, cid, &s, &om, "/p/pd_[#####].png");
|
|
assert_eq!(out.lock().unwrap().len(), 5);
|
|
// Film frames at 23.976 fps.
|
|
let r = RenderSettings { frame_rate: Some(FrameRate::new(24000, 1001)), ..custom(0.0, 0.2) };
|
|
let film = run(&p, cid, &r, &om, "/p/film_[#####].png");
|
|
let mut pattern = String::new();
|
|
for k in 0..5 {
|
|
let (a, b) = Pulldown::Wssww.fields(k as u64).unwrap();
|
|
pattern.push(if a == b { 'W' } else { 'S' });
|
|
let img = png(&out, k);
|
|
let (fa, fb) = (png(&film, a as usize), png(&film, b as usize));
|
|
for y in 0..H {
|
|
assert_eq!(row(&img, y), row(if y % 2 == 0 { &fa } else { &fb }, y), "frame {k} line {y}");
|
|
}
|
|
}
|
|
assert_eq!(pattern, "WSSWW");
|
|
}
|
|
|
|
#[test]
|
|
fn crop_roi_and_resize() {
|
|
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
|
let s = custom(0.4, 0.44);
|
|
let full = png(&run(&p, cid, &s, &OutputModule::for_format(OutputFormat::PngSequence), "/c/full_[#####].png"), 0);
|
|
let mut om = OutputModule::for_format(OutputFormat::PngSequence);
|
|
om.crop = Crop { enabled: true, top: 4, left: 10, bottom: 6, right: 2, ..Default::default() };
|
|
let c = png(&run(&p, cid, &s, &om, "/c/crop_[#####].png"), 0);
|
|
assert_eq!((c.width(), c.height()), (52, 38));
|
|
for (x, y) in [(0, 0), (20, 11), (51, 37), (3, 30)] {
|
|
assert_eq!(c.get_pixel(x, y), full.get_pixel(x + 10, y + 4), "({x}, {y})");
|
|
}
|
|
// Region of interest at half resolution.
|
|
om.crop = Crop { enabled: true, use_roi: true, roi: Some([16.0, 8.0, 32.0, 32.0]), ..Default::default() };
|
|
let half = RenderSettings { resolution: 0.5, ..s.clone() };
|
|
let r = png(&run(&p, cid, &half, &om, "/c/roi_[#####].png"), 0);
|
|
assert_eq!((r.width(), r.height()), (16, 16));
|
|
// Resize (fixed size, low and high quality) after the crop.
|
|
om.crop = Crop::default();
|
|
for q in [ResizeQuality::Low, ResizeQuality::High] {
|
|
om.resize = Resize { enabled: true, width: 32, height: 20, lock_aspect: false, quality: q };
|
|
let z = png(&run(&p, cid, &s, &om, "/c/resize_[#####].png"), 0);
|
|
assert_eq!((z.width(), z.height()), (32, 20));
|
|
// The red block (bottom left) survives the resize.
|
|
let px = z.get_pixel(4, 18).0;
|
|
assert!(px[0] > 150 && px[1] < 80, "{q:?}: {px:?}");
|
|
}
|
|
om.resize.lock_aspect = true;
|
|
let z = png(&run(&p, cid, &s, &om, "/c/aspect_[#####].png"), 0);
|
|
assert_eq!((z.width(), z.height()), (32, 24));
|
|
}
|
|
|
|
#[test]
|
|
fn alpha_only_and_premultiplied() {
|
|
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
|
|
// A half-transparent green layer over the right half.
|
|
let g = p.add_item("Green", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 32, height: 48, pixel_aspect: 1.0 }));
|
|
let mut l = build::layer(p, comp, "Green", LayerSource::Solid { item: g }, (32, 48), None);
|
|
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([48.0, 24.0, 0.0]);
|
|
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0);
|
|
comp.layers.insert(0, l);
|
|
});
|
|
let s = custom(0.0, 0.04);
|
|
let mut om = OutputModule::for_format(OutputFormat::PngSequence);
|
|
om.channels = Channels::Alpha;
|
|
let a = png(&run(&p, cid, &s, &om, "/a/alpha_[#####].png"), 0);
|
|
assert_eq!(a.get_pixel(60, 2).0[0], 128, "half-opaque green");
|
|
assert_eq!(a.get_pixel(20, 2).0[0], 0, "transparent");
|
|
assert_eq!(a.get_pixel(8, 44).0[0], 255, "opaque block");
|
|
om.channels = Channels::Rgba;
|
|
let straight = png(&run(&p, cid, &s, &om, "/a/s_[#####].png"), 0);
|
|
om.alpha_mode = AlphaMode::Premultiplied;
|
|
let premul = png(&run(&p, cid, &s, &om, "/a/p_[#####].png"), 0);
|
|
assert_eq!(straight.get_pixel(60, 2).0, [0, 255, 0, 128]);
|
|
assert_eq!(premul.get_pixel(60, 2).0, [0, 128, 0, 128]);
|
|
}
|
|
|
|
#[test]
|
|
fn solo_and_guide_layer_overrides() {
|
|
let (p, cid) = project(FrameRate::new(25, 1), |p, comp| {
|
|
let g = p.add_item("Guide", Label::Green, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 8, height: 8, pixel_aspect: 1.0 }));
|
|
let mut l = build::layer(p, comp, "Guide", LayerSource::Solid { item: g }, (8, 8), None);
|
|
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([56.0, 44.0, 0.0]);
|
|
l.switches.guide = true;
|
|
comp.layers.insert(0, l);
|
|
// Solo the moving bar.
|
|
comp.layers[1].switches.solo = true;
|
|
});
|
|
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
|
let s = custom(0.4, 0.44);
|
|
let cur = png(&run(&p, cid, &s, &om, "/s/cur_[#####].png"), 0);
|
|
assert_eq!(cur.get_pixel(8, 44).0[0], 0, "solo: the red block is hidden");
|
|
assert_eq!(cur.get_pixel(56, 44).0[2], 0, "guide layers off by default");
|
|
let off = RenderSettings { solo: CurrentOrOff::AllOff, guide_layers: CurrentOrOff::Current, ..s.clone() };
|
|
let all = png(&run(&p, cid, &off, &om, "/s/off_[#####].png"), 0);
|
|
assert_eq!(all.get_pixel(8, 44).0[0], 255, "Solo Switches: All Off");
|
|
// (with solo off the guide renders too: Guide Layers: Current Settings)
|
|
assert_eq!(all.get_pixel(56, 44).0[2], 255);
|
|
}
|
|
|
|
#[test]
|
|
fn render_logs() {
|
|
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
|
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
|
let s = RenderSettings { field_render: FieldRender::UpperFirst, name: "DV-ish".into(), ..custom(0.0, 0.2) };
|
|
let log_of = |files: &Files| {
|
|
files.lock().unwrap().iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(p, d)| (p.clone(), String::from_utf8(d.clone()).unwrap()))
|
|
};
|
|
// Errors Only + success: no log.
|
|
let (r, files) = run_with(&p, cid, &s, &om, "/l/a_[#####].png", JobOptions::default(), false);
|
|
assert!(r.unwrap().log.is_none());
|
|
assert!(log_of(&files).is_none());
|
|
// Plus Per Frame Info: settings and one line per frame.
|
|
let opts = JobOptions { log: RenderLog::PlusPerFrameInfo, label: "#1 Comp".into(), ..Default::default() };
|
|
let (r, files) = run_with(&p, cid, &s, &om, "/l/b_[#####].png", opts, false);
|
|
let r = r.unwrap();
|
|
let (path, text) = log_of(&files).expect("log");
|
|
assert_eq!(r.log.as_deref(), Some(path.as_str()));
|
|
assert_eq!(std::path::Path::new(&path), std::path::Path::new("/l/b_RenderLog.txt"));
|
|
assert!(text.contains("Item: #1 Comp") && text.contains("Result: Done (5 frames"), "{text}");
|
|
assert!(text.contains("Field Render: Upper Field First") && text.contains("Template: DV-ish") && text.contains("Format: PNG Sequence"), "{text}");
|
|
assert_eq!(text.lines().filter(|l| l.contains("rendered in")).count(), 5, "{text}");
|
|
assert!(text.contains("Frame 0 (0.000 s): rendered in") && text.contains("Frame 4 (0.160 s)"), "{text}");
|
|
// Plus Settings: no per-frame lines.
|
|
let opts = JobOptions { log: RenderLog::PlusSettings, ..Default::default() };
|
|
let (_, files) = run_with(&p, cid, &s, &om, "/l/c.gif", opts, false);
|
|
let (_, text) = log_of(&files).expect("log");
|
|
assert!(text.contains("Render Settings:") && !text.contains("rendered in"), "{text}");
|
|
// Errors Only + failure (the composition is gone): the error is logged.
|
|
let gif = OutputModule::for_format(OutputFormat::Gif);
|
|
let (r, files) = run_with(&p, ItemId(424_242), &s, &gif, "/l/d.gif", JobOptions::default(), false);
|
|
assert!(r.is_err());
|
|
let (path, text) = log_of(&files).expect("error log");
|
|
assert_eq!(std::path::Path::new(&path), std::path::Path::new("/l/d_RenderLog.txt"));
|
|
assert!(text.contains("Result: Failed") && text.contains("Error: composition not found") && !text.contains("Render Settings:"), "{text}");
|
|
// A user stop is not an error: no log.
|
|
let (r, files) = run_with(&p, cid, &s, &gif, "/l/e.gif", JobOptions::default(), true);
|
|
assert!(r.is_err());
|
|
assert!(log_of(&files).is_none());
|
|
}
|
|
|
|
/// The primary folder holds `limit` bytes; overflow folders are unlimited.
|
|
struct Quota {
|
|
limit: u64,
|
|
used: Mutex<u64>,
|
|
}
|
|
|
|
impl StorageQuota for Quota {
|
|
fn has_room(&self, path: &str, bytes: u64) -> bool {
|
|
if !std::path::Path::new(path).starts_with("/primary") {
|
|
return true;
|
|
}
|
|
let mut u = self.used.lock().unwrap();
|
|
if *u + bytes > self.limit {
|
|
return false;
|
|
}
|
|
*u += bytes;
|
|
true
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn storage_overflow_moves_files_to_the_overflow_folder() {
|
|
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
|
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
|
let s = RenderSettings { skip_existing: false, ..custom(0.0, 0.4) };
|
|
let q = Quota { limit: 1, used: Mutex::new(0) };
|
|
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], log: RenderLog::PlusSettings, ..Default::default() };
|
|
let (r, files) = run_with(&p, cid, &s, &om, "/primary/seq_[#####].png", opts, false);
|
|
let r = r.unwrap();
|
|
assert_eq!(r.overflow.len(), 10, "{:?}", r.overflow);
|
|
let f = files.lock().unwrap();
|
|
assert_eq!(
|
|
f.iter()
|
|
.filter(|(p, _)| std::path::Path::new(p).parent() == Some(std::path::Path::new("/overflow"))
|
|
&& std::path::Path::new(p).file_name().is_some_and(|n| n.to_string_lossy().starts_with("seq_"))
|
|
&& p.ends_with(".png"))
|
|
.count(),
|
|
10
|
|
);
|
|
let log = f.iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8_lossy(d).to_string()).unwrap();
|
|
assert!(log.contains("Storage overflow: 10 file(s)"), "{log}");
|
|
drop(f);
|
|
// Use Storage Overflow off: everything stays in place.
|
|
let off = RenderSettings { storage_overflow: false, ..s };
|
|
let q = Quota { limit: 1, used: Mutex::new(0) };
|
|
let opts = JobOptions { storage: Some(&q), overflow: vec!["/overflow".into()], ..Default::default() };
|
|
let (r, files) = run_with(&p, cid, &off, &om, "/primary/seq_[#####].png", opts, false);
|
|
assert!(r.unwrap().overflow.is_empty());
|
|
assert!(files.lock().unwrap().iter().all(|(p, _)| std::path::Path::new(p).starts_with("/primary")));
|
|
}
|
|
|
|
#[test]
|
|
fn wav_and_aiff_channels_and_formats() {
|
|
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
|
let s = custom(0.0, 0.5);
|
|
let mut om = OutputModule::for_format(OutputFormat::Wav);
|
|
om.audio_channels = 1;
|
|
om.audio_format = AudioFormat::S24;
|
|
om.audio_sample_rate = 44_100;
|
|
let files = run(&p, cid, &s, &om, "/w/a.wav");
|
|
let d = files.lock().unwrap()[0].1.clone();
|
|
assert_eq!(&d[..4], b"RIFF");
|
|
let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
|
|
let u32_at = |o: usize| u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]);
|
|
assert_eq!((u16_at(20), u16_at(22), u32_at(24), u16_at(32), u16_at(34)), (1, 1, 44_100, 3, 24));
|
|
assert_eq!(u32_at(40), 22_050 * 3, "half a second of mono 24-bit");
|
|
om.audio_channels = 2;
|
|
om.audio_format = AudioFormat::F32;
|
|
let files = run(&p, cid, &s, &om, "/w/b.wav");
|
|
let d = files.lock().unwrap()[0].1.clone();
|
|
let u16_at = |o: usize| u16::from_le_bytes([d[o], d[o + 1]]);
|
|
assert_eq!((u16_at(20), u16_at(22), u16_at(32), u16_at(34)), (3, 2, 8, 32), "IEEE float stereo");
|
|
// Plain AIFF has no float: 32-bit float is AIFF-C with `fl32` samples (#317).
|
|
let mut aiff = OutputModule::for_format(OutputFormat::Aiff);
|
|
aiff.audio_format = AudioFormat::F32;
|
|
let files = run(&p, cid, &s, &aiff, "/w/c.aif");
|
|
let d = files.lock().unwrap()[0].1.clone();
|
|
let u32_at = |o: usize| u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]);
|
|
assert_eq!((&d[..4], u32_at(4) as usize + 8, &d[8..12]), (&b"FORM"[..], d.len(), &b"AIFC"[..]));
|
|
assert_eq!((&d[12..16], u32_at(16), u32_at(20)), (&b"FVER"[..], 4, 0xA280_5140));
|
|
assert_eq!(&d[24..28], b"COMM");
|
|
let comm = 32;
|
|
assert_eq!(u32_at(28) as usize, 18 + 4 + 22, "COMM: AIFF fields, compression type, name");
|
|
assert_eq!(u16::from_be_bytes([d[comm], d[comm + 1]]), 2, "stereo");
|
|
assert_eq!(u32_at(comm + 2), 24_000, "half a second at 48 kHz");
|
|
assert_eq!(u16::from_be_bytes([d[comm + 6], d[comm + 7]]), 32, "sample size");
|
|
assert_eq!(&d[comm + 18..comm + 22], b"fl32");
|
|
assert_eq!(&d[comm + 22..comm + 44], b"\x1532-bit floating point");
|
|
let ssnd = comm + 44;
|
|
assert_eq!((&d[ssnd..ssnd + 4], u32_at(ssnd + 4) as usize), (&b"SSND"[..], 8 + 24_000 * 2 * 4));
|
|
// 16- and 24-bit stay plain AIFF.
|
|
aiff.audio_format = AudioFormat::S24;
|
|
let files = run(&p, cid, &s, &aiff, "/w/d.aif");
|
|
let d = files.lock().unwrap()[0].1.clone();
|
|
assert_eq!(&d[8..12], b"AIFF");
|
|
assert_eq!(u16::from_be_bytes([d[26], d[27]]), 24, "sample size");
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_export_removes_only_files_it_opened() {
|
|
use effectcraft_export::ExportError;
|
|
let dir = std::env::temp_dir().join(format!("ec-cancel-owned-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
|
struct Overflow;
|
|
impl StorageQuota for Overflow {
|
|
fn has_room(&self, path: &str, _: u64) -> bool {
|
|
path.contains("overflow")
|
|
}
|
|
}
|
|
for overflow in [false, true] {
|
|
let output_dir = if overflow { dir.join("overflow") } else { dir.clone() };
|
|
std::fs::create_dir_all(&output_dir).unwrap();
|
|
let settings = RenderSettings { skip_existing: true, storage_overflow: overflow, ..Default::default() };
|
|
for format in [OutputFormat::PngSequence, OutputFormat::H264] {
|
|
let om = OutputModule::for_format(format);
|
|
let path = dir.join(if format.is_sequence() { "clip_[#####].png" } else { "clip.mp4" });
|
|
let path = path.to_str().unwrap();
|
|
let decoys = ["clip.aep", "clip2.psd", "clip000.mp4", "clip_99999.png", "clip_00000.png", "clip.txt", "clip_RenderLog.txt"];
|
|
for name in decoys {
|
|
std::fs::write(output_dir.join(name), b"keep").unwrap();
|
|
}
|
|
// Skip-existing checks the primary destination even when the new frames overflow.
|
|
std::fs::write(dir.join("clip_00000.png"), b"keep").unwrap();
|
|
let result = export(
|
|
&Job {
|
|
project: &p,
|
|
footage: &NoFootage,
|
|
expr: None,
|
|
accel: None,
|
|
comp: cid,
|
|
settings: &settings,
|
|
output: &om,
|
|
path,
|
|
sink: None,
|
|
nested_switches: true,
|
|
options: JobOptions {
|
|
log: RenderLog::PlusPerFrameInfo,
|
|
storage: Some(&Overflow),
|
|
overflow: vec![output_dir.to_string_lossy().into_owned()],
|
|
..Default::default()
|
|
},
|
|
},
|
|
&mut |progress| progress.done == 0,
|
|
);
|
|
assert!(matches!(result, Err(ExportError::Cancelled)), "{result:?}");
|
|
for name in decoys {
|
|
assert_eq!(std::fs::read(output_dir.join(name)).unwrap(), b"keep", "{name}");
|
|
}
|
|
let remaining: Vec<_> = std::fs::read_dir(&output_dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
|
assert_eq!(remaining.len(), decoys.len(), "partial files remain: {remaining:?}");
|
|
}
|
|
}
|
|
std::fs::remove_dir_all(dir).unwrap();
|
|
}
|