diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index d6de320ec..478dc8259 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -5,7 +5,7 @@ use effectcraft_project::Marker; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; +use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; use crate::prefs::CompPreset; use crate::{EngineError, Result, Session, cmd, query}; @@ -82,7 +82,7 @@ pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result Result { - use effectcraft_project::render_queue::{RenderQueueItem, TimeSpan}; + use effectcraft_project::render_queue::{Channels, RenderQueueItem, TimeSpan}; use effectcraft_project::render_templates::TemplateSlot; let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; @@ -91,6 +91,9 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result { it.settings = s.project.render_templates.default_render_settings(TemplateSlot::Still); it.settings.time_span = TimeSpan::Custom { start: t, end: t + comp.frame_duration() }; it.output = s.project.render_templates.default_output_module(TemplateSlot::Still); + if b_p(p, "transparent") == Some(false) { + it.output.channels = Channels::Rgb; + } if let Some(path) = str_p(p, "path") { it.output.output = path.to_string(); } else if !it.output.format.is_sequence() { @@ -107,7 +110,7 @@ fn queue_frame(s: &mut Session, p: &Value) -> Result { } fn save_frame(s: &mut Session, p: &Value) -> Result { - if p.get("queue").and_then(Value::as_bool) == Some(true) { + if b_p(p, "queue") == Some(true) { return queue_frame(s, p); } let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string(); @@ -117,7 +120,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result { let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0); let bg = comp.background; let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() }); - let rgba = img.to_rgba8_over(bg); + let rgba = if b_p(p, "transparent") == Some(false) { img.to_rgba8_over(bg) } else { img.to_rgba8() }; let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?; s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; s.toast(format!("Saved frame to {path}")); @@ -235,7 +238,7 @@ pub fn specs() -> Vec { "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), - "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now)}", + "{path (.png; with queue: an output path or template), time?, scale?, queue?: bool (add a Render Queue item from the Frame Default templates instead of writing now), transparent?: bool (default true)}", has_comp, save_frame ), diff --git a/crates/engine/src/tests/frame_export.rs b/crates/engine/src/tests/frame_export.rs index a45c5d444..a8dc938fb 100644 --- a/crates/engine/src/tests/frame_export.rs +++ b/crates/engine/src/tests/frame_export.rs @@ -164,3 +164,69 @@ fn copy_frame_hands_the_rendered_frame_to_the_frontend() { assert!(Session::default().execute("comp.copyFrame", json!({})).is_err()); assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition"))); } + +#[test] +fn save_frame_as_png_preserves_alpha_channel() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "AlphaComp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + let red = s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [8, 8, 0]})).unwrap(); + let dir = out_dir("frame-png-alpha"); + let path = dir.join("alpha.png").to_string_lossy().to_string(); + + s.execute("comp.saveFrameAs", json!({"path": path})).unwrap(); + let img = image::load_from_memory(&std::fs::read(&path).unwrap()).unwrap().to_rgba8(); + assert_eq!((img.width(), img.height()), (32, 32)); + let solid_px = img.get_pixel(8, 8); + assert_eq!(solid_px[3], 255, "solid region must be opaque: {solid_px:?}"); + assert_eq!(solid_px[0], 255); + let empty_px = img.get_pixel(24, 24); + assert_eq!(empty_px[3], 0, "outside solid region must have alpha 0: {empty_px:?}"); + + let flat_path = dir.join("flat.png").to_string_lossy().to_string(); + s.execute_checked("comp.saveFrameAs", json!({"path": flat_path, "transparent": false})).unwrap(); + let flat_img = image::load_from_memory(&std::fs::read(&flat_path).unwrap()).unwrap().to_rgba8(); + let flat_empty_px = flat_img.get_pixel(24, 24); + assert_eq!(flat_empty_px[3], 255, "transparent: false must composite over background: {flat_empty_px:?}"); +} + +#[test] +fn save_frame_as_queued_respects_frame_default_template() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Comp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap(); + + // Default Still template is "PNG Sequence" (Channels::Rgb) + let r = s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap(); + assert_eq!(r["queued"], true); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence"); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // Test undo and redo of queuing a frame + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.project.render_queue.is_empty()); + s.execute("edit.redo", json!({})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence"); + + // Explicit transparent: false forces Channels::Rgb + s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // When Frame Default is set to an RGBA template, it is respected + s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "PNG Sequence with Alpha"})).unwrap(); + s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.name, "PNG Sequence with Alpha"); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba); + + // Passing transparent: false forces Channels::Rgb even with an RGBA template + s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap(); + let it = s.project.render_queue.last().unwrap(); + assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb); + + // With JPEG Sequence as Frame Default (non-alpha format), queue succeeds without error + s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap(); + assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok()); +}