fix(engine): preserve alpha channel when saving still PNG frames

Exporting still frames via comp.saveFrameAs previously flattened the
rendered frame against the composition background color, stripping
the alpha channel. Single still frames queued in the Render Queue also
defaulted to an RGB-only PNG sequence output module.

Preserve straight alpha by default in comp.saveFrameAs (while honoring
transparent: false if requested), and set TemplateSlot::Still to default
to 'PNG Sequence with Alpha'.

Fixes #1815
This commit is contained in:
Kushagra Kumar committed 2026-10-19 01:59:48 +05:30
1 parent ef74d82902
commit 791e6bb5eb
3 files changed
+38 -2

No files matched your search

+1 -1
View File
@@ -117,7 +117,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
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 p.get("transparent").and_then(Value::as_bool) == 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}"));
+36
View File
@@ -164,3 +164,39 @@ 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("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_defaults_to_png_sequence_with_alpha() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Comp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
let r = s.execute("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 with Alpha");
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba);
}
+1 -1
View File
@@ -88,7 +88,7 @@ impl Default for TemplateDefaults {
]),
output_modules: s(&[
(Movie, "H.264 - Match Render Settings - 15 Mbps"),
(Still, "PNG Sequence"),
(Still, "PNG Sequence with Alpha"),
(PreRender, "Lossless with Alpha"),
(MovieProxy, "Lossless with Alpha"),
(StillProxy, "PNG Sequence with Alpha"),