export: keep alpha in PNG/TIFF sequences with Include Alpha Channel (#160)
The export pipeline flattened every frame over black (`over_black_rgba8`), so a PNG sequence of a sequence with transparency came out on a black background, while Export Frame already wrote straight RGBA. `ExportSettings.alpha` (serde default false, Premiere's default) makes the pipeline emit straight sRGB RGBA for PNG and TIFF sequences and `encode_still` keep the channel in TIFF; BMP and video formats ignore it. Exposed as the `alpha` flat parameter of file.exportMedia and an "Include Alpha Channel" checkbox in Export mode's Video section, shown for PNG and TIFF only. Fixes #160 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
61238bf8fc
commit
6068bfc589
|
|
@ -1071,7 +1071,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Media…",
|
||||
["File", "Export"],
|
||||
None,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
|
||||
has_seq,
|
||||
crate::export_tools::export_media
|
||||
),
|
||||
|
|
|
|||
|
|
@ -230,6 +230,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
|
|||
if let Some(v) = bool_p(p, "sdr") {
|
||||
settings.sdr = v;
|
||||
}
|
||||
if let Some(v) = bool_p(p, "alpha") {
|
||||
settings.alpha = v;
|
||||
}
|
||||
match (crate::commands::checked_u32_p(p, "width", cmd)?, crate::commands::checked_u32_p(p, "height", cmd)?) {
|
||||
(Some(w), Some(h)) => settings.frame_size = Some((w, h)),
|
||||
(None, None) => {}
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ fn export_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
let img = s.render_program(1.0).ok_or(EngineError::NoSequence)?;
|
||||
let (w, h) = (img.w as u32, img.h as u32);
|
||||
let bytes = filmcraft_export::encode_still(format, img.to_rgba8(), w, h).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let bytes = filmcraft_export::encode_still(format, img.to_rgba8(), w, h, false).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mut out = json!({"path": path, "width": w, "height": h});
|
||||
if p.get("import").and_then(Value::as_bool).unwrap_or(false) {
|
||||
|
|
|
|||
|
|
@ -253,6 +253,10 @@ pub struct ExportSettings {
|
|||
/// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI).
|
||||
#[serde(default)]
|
||||
pub sdr: bool,
|
||||
/// Keep the alpha channel in PNG and TIFF sequences (straight alpha, Premiere's "Include
|
||||
/// Alpha Channel"). Off, every frame is flattened over black (#160). Other formats ignore it.
|
||||
#[serde(default)]
|
||||
pub alpha: bool,
|
||||
/// Output frame size (None = Match Source: the sequence size times `scale`).
|
||||
pub frame_size: Option<(u32, u32)>,
|
||||
/// Output frame rate (None = Match Source).
|
||||
|
|
@ -445,6 +449,7 @@ impl Default for ExportSettings {
|
|||
apv_profile: String::new(),
|
||||
mxf_video_codec: MxfVideoCodec::default(),
|
||||
sdr: false,
|
||||
alpha: false,
|
||||
frame_size: None,
|
||||
frame_rate: None,
|
||||
scaling: Scaling::default(),
|
||||
|
|
@ -1266,11 +1271,12 @@ impl Write for SharedBuf {
|
|||
|
||||
/// Encode straight sRGB RGBA8 pixels as a PNG file (thumbnails, previews).
|
||||
pub fn encode_png(rgba: Vec<u8>, w: u32, h: u32) -> Result<Vec<u8>> {
|
||||
encode_still(Format::PngSequence, rgba, w, h)
|
||||
encode_still(Format::PngSequence, rgba, w, h, false)
|
||||
}
|
||||
|
||||
/// Encode one still of an image sequence (also Export Frame).
|
||||
pub fn encode_still(format: Format, rgba: Vec<u8>, w: u32, h: u32) -> Result<Vec<u8>> {
|
||||
/// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha
|
||||
/// channel it is given; `alpha` keeps it in TIFF too, which is otherwise written as RGB.
|
||||
pub fn encode_still(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
|
||||
let enc = |e: image::ImageError| ExportError::Encode(e.to_string());
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
match format {
|
||||
|
|
@ -1279,9 +1285,12 @@ pub fn encode_still(format: Format, rgba: Vec<u8>, w: u32, h: u32) -> Result<Vec
|
|||
}
|
||||
Format::TiffSequence | Format::BmpSequence => {
|
||||
let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame size".into()))?;
|
||||
let rgb = image::DynamicImage::ImageRgba8(img).to_rgb8();
|
||||
let f = if format == Format::TiffSequence { image::ImageFormat::Tiff } else { image::ImageFormat::Bmp };
|
||||
rgb.write_to(&mut out, f).map_err(enc)?
|
||||
if alpha && format == Format::TiffSequence {
|
||||
img.write_to(&mut out, f).map_err(enc)?
|
||||
} else {
|
||||
image::DynamicImage::ImageRgba8(img).to_rgb8().write_to(&mut out, f).map_err(enc)?
|
||||
}
|
||||
}
|
||||
_ => return Err(ExportError::Unsupported(format!("{} is not an image sequence", format.label()))),
|
||||
}
|
||||
|
|
@ -1358,7 +1367,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
|
|||
let written: Vec<Result<u64>> = (f..end)
|
||||
.into_par_iter()
|
||||
.map(|fi| {
|
||||
let data = encode_still(settings.format, pipe.frame(fi, sources).0, w, h)?;
|
||||
let data = encode_still(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha)?;
|
||||
write_output(settings, &pcm::image_sequence_path(&settings.path, (fi - f0) as u64, count), data)
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use filmcraft_time::FrameRate;
|
|||
use rayon::prelude::*;
|
||||
|
||||
use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay};
|
||||
use crate::{ExportError, ExportSettings, Result};
|
||||
use crate::{ExportError, ExportSettings, Format, Result};
|
||||
|
||||
/// Where the rendered picture lands in the output frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
|
@ -61,6 +61,8 @@ pub(crate) struct Pipeline {
|
|||
geom: Geometry,
|
||||
opts: RenderOptions,
|
||||
pub hdr_out: bool,
|
||||
/// Keep straight alpha in the RGBA8 output instead of flattening over black.
|
||||
alpha: bool,
|
||||
out_tf: Option<filmcraft_color::OutputTransform>,
|
||||
effects: ExportEffects,
|
||||
overlay: Option<Image>,
|
||||
|
|
@ -106,6 +108,7 @@ impl Pipeline {
|
|||
geom,
|
||||
opts,
|
||||
hdr_out,
|
||||
alpha: settings.alpha && matches!(settings.format, Format::PngSequence | Format::TiffSequence),
|
||||
out_tf,
|
||||
effects,
|
||||
overlay,
|
||||
|
|
@ -134,7 +137,7 @@ impl Pipeline {
|
|||
filmcraft_frame::pool::recycle_f32(img.px);
|
||||
return (Vec::new(), out);
|
||||
}
|
||||
let mut rgba = img.over_black_rgba8();
|
||||
let mut rgba = if self.alpha { img.to_rgba8() } else { img.over_black_rgba8() };
|
||||
// the float image is not needed any more: its buffer serves the next frame's layers
|
||||
filmcraft_frame::pool::recycle_f32(img.px);
|
||||
if lim.enabled {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,28 @@ fn image_sequences_are_numbered_like_premiere() {
|
|||
}
|
||||
}
|
||||
|
||||
/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160).
|
||||
#[test]
|
||||
fn image_sequences_keep_alpha_when_asked() {
|
||||
let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None);
|
||||
for (fmt, ext) in [(Format::PngSequence, "png"), (Format::TiffSequence, "tif")] {
|
||||
for alpha in [false, true] {
|
||||
let dir = Scratch::new(&format!("alpha-{ext}-{alpha}"));
|
||||
let mut s = ExportSettings { format: fmt, path: dir.path(&format!("a.{ext}")), alpha, ..Default::default() };
|
||||
s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(2)));
|
||||
export(&p, seq, &s, &m, &Progress::default()).unwrap();
|
||||
let img = image::open(dir.0.join(format!("a000.{ext}"))).unwrap();
|
||||
let px = img.to_rgba8().get_pixel(32, 18).0;
|
||||
if alpha {
|
||||
assert!(img.color().has_alpha(), "{fmt:?} must be written with an alpha channel");
|
||||
assert!((100..=160).contains(&px[3]) && px[2] > 200 && px[0] < 15, "{fmt:?} straight alpha: {px:?}");
|
||||
} else {
|
||||
assert!(px[3] == 255 && px[2] > 100 && px[2] < 240 && px[0] < 15, "{fmt:?} flattened over black: {px:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_audio_only() {
|
||||
let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0));
|
||||
|
|
|
|||
|
|
@ -598,6 +598,11 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
|
|||
}
|
||||
});
|
||||
}
|
||||
if matches!(s.format, Format::PngSequence | Format::TiffSequence) {
|
||||
row(ui, t, "Alpha", |ui| {
|
||||
check(ui, reg, "export.video.alpha", &mut s.alpha, "Include Alpha Channel");
|
||||
});
|
||||
}
|
||||
match s.video_format() {
|
||||
f @ (Format::H264 | Format::Hevc) => {
|
||||
let hevc = f == Format::Hevc;
|
||||
|
|
|
|||
Loading…
Reference in New Issue