//! 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)>>>; 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, Files) { let files: Files = Default::default(); let f = files.clone(); let sink: Box = Box::new(move |path: &str, data: Vec| 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)> = 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 { (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!(path, "/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!(path, "/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, } impl StorageQuota for Quota { fn has_room(&self, path: &str, bytes: u64) -> bool { if !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, _)| p.starts_with("/overflow/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, _)| 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"); // AIFF has no float: 24-bit. 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(); assert_eq!(&d[8..12], b"AIFF"); assert_eq!(u16::from_be_bytes([d[26], d[27]]), 24, "sample size"); }