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:
wstock 2026-10-08 08:31:37 +00:00
parent 61238bf8fc
commit 6068bfc589
7 changed files with 52 additions and 10 deletions

View File

@ -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
),

View File

@ -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) => {}

View File

@ -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) {

View File

@ -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();

View File

@ -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 {

View File

@ -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));

View File

@ -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;