mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
Merge pull request #1820 from kk376/fix/png-frame-alpha-preservation
fix(engine): preserve alpha channel when saving still PNG frames
This commit is contained in:
@@ -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<Vec
|
||||
/// Save Frame As ▸ File with `queue`: a Render Queue item for the current frame, from the Frame
|
||||
/// Default templates (Edit ▸ Templates).
|
||||
fn queue_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
|
||||
fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<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 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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user