Browse / github.com/storytold/filmcraft / patch
Export: preserve optional binary transparency in animated GIFs (#3396)
Export: animated GIF cannot preserve transparency ## Summary Animated GIF export does not preserve a transparent background. ## Evidence `ExportSettings::supports_alpha()` currently enables alpha only for PNG, TIFF, Targa and OpenEXR image sequences and ProRes 4444/4444 XQ. `Format::Gif` is not included, so GIF export flattens alpha even though the GIF encoder receives RGBA frames. ## Requested behavior When exporting an animated GIF with transparent regions, preserve binary GIF transparency where representable, with a clear UI option and coverage for opaque, transparent and partially transparent pixels. ## Notes GIF supports only binary t
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- thecyberbutler (imported from pull request #3423, open)
- Base
10d626ccc4888e0f020f9ca314c2bc9148ded4f4- Size
- +265 −7 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
274abe77ffd9c1be5c606588
How it went
No other operator has reported using it yet.
Diff
raw · contributors/people.toml crates/export/README.md crates/export/src/format.rs crates/export/src/run.rs crates/export/src/settings.rs crates/export/src/settings_tests.rs crates/ui-egui/src/panels/export_mode/settings.rs crates/ui-egui/tests/it/export_ui.rs docs/file-format-parity.md docs/gaps.md
@@ -88,3 +88,7 @@ sync_github_name = false [people.webdevtodayjason] display_name = "Jason Brashear"++[people.thecyberbutler]+display_name = "@thecyberbutler"+sync_github_name = falsenew file mode 100644@@ -0,0 +1,37 @@+# FilmCraft export++The export pipeline renders sequence frames, encodes them and writes the selected container.+See [architecture](../../docs/architecture.md) for formats, settings, presets and queue behavior.++## Animated GIF transparency++Animated GIF supports the existing `ExportSettings::alpha` option (#3396). In Export mode,+choose Animated GIF and enable Video ▸ Include Alpha Channel. Engine and MCP exports use+`file.exportMedia` with `"format": "gif", "alpha": true`; queue entries and presets use the+same settings. The default remains off, flattening the picture over black.++GIF has binary transparency, not partial alpha. After rendering and scaling, RGBA8 alpha+0–127 becomes transparent and 128–255 becomes opaque. Visible pixels retain their straight+RGB color before palette quantization; transparent pixels share a cleared color. This avoids+darkening partially transparent edges, but cannot preserve smooth translucent edges or shadows.+The UI displays the threshold alongside the checkbox.++The existing `image` GIF encoder writes the animation; no codec implementation is added.+Format reference: [GIF89a, 31 July 1990, §23 Graphic Control Extension](https://www.w3.org/Graphics/GIF/spec-gif89a.txt).++`settings_tests::gif_alpha_threshold_and_disposal_roundtrip` exports synthetic moving stripes+with alpha 0, 127, 128 and 255, followed by an empty frame. Decoding verifies transparency,+color, frame timing and removal of previous-frame pixels, with alpha both enabled and disabled.+When FFmpeg is installed, its independent decode must agree on every pixel's alpha and opaque color.+`settings_tests::gif_palette_quantization_preserves_isolated_transparency_and_dark_pixels`+checks two frames with more than 256 distinct post-conversion RGBA colors, forcing palette+reduction. A single transparent pixel moves between frames, surrounded by opaque black and+near-black pixels. Decoded alpha must match exactly; opaque RGB channels allow at most 16/255+quantization error. This test uses the Rust decoder and needs no FFmpeg installation.++Run the GIF export tests with `cargo test -p filmcraft-export --lib gif_ -- --nocapture`.+Run all export unit tests with `cargo test -p filmcraft-export --lib`.++`filmcraft-ui-egui`'s `export_ui::gif_export_exposes_binary_alpha_option` uses the control+channel to select GIF, toggle alpha and inspect the explanatory label. Set+`FILMCRAFT_UI_SNAPSHOT_DIR` to render its Export-mode screenshot.@@ -293,7 +293,8 @@ pub struct ExportSettings { pub sdr: bool, /// Keep the alpha channel in PNG, TIFF, Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ /// QuickTime movies (straight alpha, Premiere's "Include Alpha Channel"; OpenEXR stores it- /// premultiplied, as the format defines). Off, every frame is flattened over black (#160).+ /// premultiplied, as the format defines). GIF keeps binary transparency: RGBA8 alpha below+ /// 128 becomes transparent, otherwise opaque. Off, every frame is flattened over black (#160). /// Other formats ignore it. #[serde(default)] pub alpha: bool,@@ -184,7 +184,18 @@ fn export_here(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, s let end = (f + batch).min(f1); if let Some(enc) = gif.as_mut() { let frames: Vec<Vec<u8>> = (f..end).into_par_iter().map(|fi| pipe.frame(fi, sources).0).collect();- for (fi, rgba) in (f..end).zip(frames) {+ for (fi, mut rgba) in (f..end).zip(frames) {+ // GIF89a has one transparent palette index, not partial alpha. Decide+ // coverage before quantization and keep the straight colour of visible pixels.+ if settings.keeps_alpha() {+ for px in rgba.as_chunks_mut::<4>().0 {+ if px[3] < 128 {+ *px = [0; 4];+ } else {+ px[3] = 255;+ }+ }+ } let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame".into()))?; let delay = image::Delay::from_numer_denom_ms(u32::from(gif_delay_cs(pipe.rate, fi - f0)) * 10, 1); enc.encode_frame(image::Frame::from_parts(img, 0, 0, delay)).map_err(|e| ExportError::Encode(e.to_string()))?;@@ -505,10 +505,10 @@ impl ExportSettings { } /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF,- /// Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ QuickTime movies.+ /// Targa and OpenEXR sequences, GIF (binary transparency), and ProRes 4444 / 4444 XQ movies. pub fn supports_alpha(&self) -> bool { match self.format {- Format::PngSequence | Format::TiffSequence | Format::TgaSequence | Format::ExrSequence => true,+ Format::PngSequence | Format::TiffSequence | Format::TgaSequence | Format::ExrSequence | Format::Gif => true, Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444, _ => false, }@@ -270,6 +270,180 @@ fn image_sequences_keep_alpha_when_asked() { assert_eq!(png(false) * 4, png(true) * 3, "PNG estimate: {} RGB vs {} RGBA", png(false), png(true)); } +/// Four blue stripes with alpha 0, 127, 128 and 255. Reverse them on frame two, then+/// clear the whole picture: a decoder must not retain opaque pixels from earlier frames.+struct GifAlphaSource(filmcraft_media::SharedSource);++impl MediaSource for GifAlphaSource {+ fn info(&self) -> &filmcraft_media::MediaInfo {+ self.0.info()+ }++ fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<filmcraft_frame::VideoFrame>> {+ let frame = FrameRate::FPS_24.frame_at(req.time);+ let mut rgba = Vec::with_capacity(64 * 36 * 4);+ for _ in 0..36 {+ for x in 0..64 {+ let stripe = if frame == 1 { 3 - x / 16 } else { x / 16 };+ let alpha = if frame >= 2 { 0 } else { [0, 127, 128, 255][stripe] };+ rgba.extend_from_slice(&[0, 0, 255, alpha]);+ }+ }+ Ok(Arc::new(filmcraft_frame::VideoFrame::rgba8(64, 36, rgba)))+ }++ fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<filmcraft_frame::AudioBuffer> {+ self.0.audio(start, frames, sample_rate)+ }+}++#[test]+fn gif_alpha_threshold_and_disposal_roundtrip() {+ use image::AnimationDecoder;+ let (p, seq, mut sources) = matte([0.0, 0.0, 1.0, 1.0], 64, 36, None);+ for source in sources.0.values_mut() {+ *source = Arc::new(GifAlphaSource(source.clone()));+ }+ for alpha in [false, true] {+ let dir = Scratch::new(&format!("gif-alpha-{alpha}"));+ let settings = ExportSettings {+ format: Format::Gif,+ path: dir.path("alpha.gif"),+ alpha,+ range: Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(3))),+ ..Default::default()+ };+ let result = export(&p, seq, &settings, &sources, &Progress::default()).unwrap();+ assert_eq!(result.frames, 3);+ let bytes = std::fs::read(&settings.path).unwrap();+ let decoder = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).unwrap();+ let frames = decoder.into_frames().collect_frames().unwrap();+ assert_eq!(frames.len(), 3);+ for (i, frame) in frames.iter().enumerate() {+ assert_eq!(frame.buffer().dimensions(), (64, 36));+ assert_eq!(frame.delay().numer_denom_ms(), (u32::from(gif_delay_cs(FrameRate::FPS_24, i as i64)) * 10, 1));+ for stripe in 0..4 {+ let source_alpha = if i == 2 { 0 } else { [0, 127, 128, 255][if i == 1 { 3 - stripe } else { stripe }] };+ let px = frame.buffer().get_pixel(stripe as u32 * 16 + 8, 18).0;+ let expected_alpha = if alpha && source_alpha < 128 { 0 } else { 255 };+ assert_eq!(px[3], expected_alpha, "alpha={alpha}, frame={i}, stripe={stripe}: {px:?}");+ if expected_alpha != 0 {+ assert!(px[0] < 15 && px[1] < 15, "blue: {px:?}");+ if source_alpha == 0 {+ assert!(px[2] < 15, "flattened transparent pixel: {px:?}");+ } else if alpha || source_alpha == 255 {+ assert!(px[2] > 240, "straight blue survives palette quantization: {px:?}");+ } else {+ assert!((100..240).contains(&px[2]), "partial alpha flattened over black: {px:?}");+ }+ }+ }+ }+ if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("GIF transparency") {+ let decoded = std::process::Command::new(ffmpeg)+ .args(["-v", "error", "-i", &settings.path, "-frames:v", "3", "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "rgba", "-"])+ .output()+ .unwrap();+ assert!(decoded.status.success(), "{}", String::from_utf8_lossy(&decoded.stderr));+ assert_eq!(decoded.stdout.len(), 3 * 64 * 36 * 4);+ for (actual, expected) in decoded.stdout.as_chunks::<{ 64 * 36 * 4 }>().0.iter().zip(&frames) {+ for (actual, expected) in actual.as_chunks::<4>().0.iter().zip(expected.buffer().pixels()) {+ assert_eq!(actual[3], expected.0[3], "ffmpeg alpha, alpha={alpha}");+ if actual[3] == 255 {+ assert_eq!(*actual, expected.0, "ffmpeg opaque color, alpha={alpha}");+ }+ }+ }+ }+ }+}++/// A full grayscale ramp, repeated four times, with one transparent pixel surrounded by+/// opaque black / near-black pixels. Moving the hole also exercises animated frame disposal.+struct GifPaletteSource(filmcraft_media::SharedSource);++impl MediaSource for GifPaletteSource {+ fn info(&self) -> &filmcraft_media::MediaInfo {+ self.0.info()+ }++ fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<filmcraft_frame::VideoFrame>> {+ let second = FrameRate::FPS_24.frame_at(req.time) != 0;+ let hole = if second { (17, 17) } else { (16, 16) };+ let mut rgba = Vec::with_capacity(32 * 32 * 4);+ for y in 0..32 {+ for x in 0..32 {+ let shade = ((y * 32 + x) % 256) as u8;+ let shade = if second { 255 - shade } else { shade };+ let px = if (x, y) == hole {+ [0, 0, 0, 0]+ } else if (x, y) == (hole.0 - 1, hole.1) || (x, y) == (hole.0 + 1, hole.1) {+ [0, 0, 0, 255]+ } else if (x, y) == (hole.0, hole.1 - 1) || (x, y) == (hole.0, hole.1 + 1) {+ [1, 2, 3, 255]+ } else {+ [shade, shade, shade, 255]+ };+ rgba.extend_from_slice(&px);+ }+ }+ Ok(Arc::new(filmcraft_frame::VideoFrame::rgba8(32, 32, rgba)))+ }++ fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<filmcraft_frame::AudioBuffer> {+ self.0.audio(start, frames, sample_rate)+ }+}++#[test]+fn gif_palette_quantization_preserves_isolated_transparency_and_dark_pixels() {+ use image::AnimationDecoder;+ let (p, seq, mut sources) = matte([0.0, 0.0, 0.0, 1.0], 32, 32, None);+ for source in sources.0.values_mut() {+ *source = Arc::new(GifPaletteSource(source.clone()));+ }+ let dir = Scratch::new("gif-palette-alpha");+ let settings = ExportSettings {+ format: Format::Gif,+ path: dir.path("palette.gif"),+ alpha: true,+ range: Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(2))),+ ..Default::default()+ };+ // Count the actual rendered colors after binary-alpha conversion, not just source colors:+ // exceeding GIF's 256-entry palette must survive the render pipeline on both frames.+ let pipe = pipeline::Pipeline::new(p.clone(), seq, &settings, false).unwrap();+ let expected: Vec<Vec<[u8; 4]>> = (0..2)+ .map(|i| {+ let rgba = pipe.frame(i, &sources).0;+ let pixels: Vec<_> = rgba.as_chunks::<4>().0.iter().map(|&[r, g, b, a]| if a < 128 { [0; 4] } else { [r, g, b, 255] }).collect();+ let colors: std::collections::HashSet<_> = pixels.iter().copied().collect();+ assert!(colors.len() >= 257, "frame {i}: only {} post-conversion colors", colors.len());+ assert_eq!(pixels.iter().filter(|p| p[3] == 0).count(), 1, "frame {i}: an isolated transparent pixel");+ assert!(colors.contains(&[0, 0, 0, 255]), "frame {i}: opaque black");+ assert!(colors.contains(&[1, 2, 3, 255]), "frame {i}: opaque near-black");+ pixels+ })+ .collect();+ assert_eq!(export(&p, seq, &settings, &sources, &Progress::default()).unwrap().frames, 2);+ let bytes = std::fs::read(&settings.path).unwrap();+ let frames = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).unwrap().into_frames().collect_frames().unwrap();+ assert_eq!(frames.len(), 2);+ for (i, (frame, expected)) in frames.iter().zip(&expected).enumerate() {+ assert_eq!(frame.buffer().dimensions(), (32, 32));+ for (at, (actual, expected)) in frame.buffer().pixels().zip(expected).enumerate() {+ assert_eq!(actual[3], expected[3], "frame {i}, pixel {at}: alpha must be exact, including opaque dark pixels");+ if expected[3] == 255 {+ // Palette reduction is lossy. For this grayscale ramp, allow at most 16/255+ // error per RGB channel (about 6% of the range), never any loss of opacity.+ for channel in 0..3 {+ assert!(actual[channel].abs_diff(expected[channel]) <= 16, "frame {i}, pixel {at}: {:?} vs {expected:?}", actual.0);+ }+ }+ }+ }+}+ /// Thumbnails (`encode_png`) and an 8-bit PNG Export Frame keep the alpha they are given: only the /// sequence's `alpha` switch drops it (#1863). #[test]@@ -460,6 +460,13 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T row(ui, t, tl!("Alpha"), |ui| { check(ui, reg, "export.video.alpha", &mut s.alpha, tl!("Include Alpha Channel")); });+ if s.format == Format::Gif {+ row(ui, t, "", |ui| {+ let hint = tl!("When enabled, GIF transparency is on/off: alpha below 128 is transparent; 128 and above is opaque.");+ let r = ui.add(egui::Label::new(hint).wrap());+ reg.add("export.video.alpha.hint", r.rect, hint);+ });+ } } match s.video_format() { f @ (Format::H264 | Format::Hevc | Format::Av1) => {@@ -179,6 +179,24 @@ fn export_mode_settings_and_summary() { d.snapshot("export-mode"); } +#[test]+fn gif_export_exposes_binary_alpha_option() {+ use filmcraft_engine::export::Format;+ let mut d = Driver::new("gif-alpha");+ d.ok("ui.set", json!({"mode": "export", "export": {"settings": {"format": "gif"}}}));+ d.frames(4);+ assert_eq!(d.app().ui.export.settings.format, Format::Gif);+ assert!(d.has("export.video.alpha"));+ assert!(!d.app().ui.export.settings.alpha);+ d.click("export.video.alpha");+ assert!(d.app().ui.export.settings.keeps_alpha());+ let hint = d.ok("ui.elements", json!({"prefix": "export.video.alpha.hint"}));+ assert!(hint[0]["label"].as_str().unwrap().contains("128"), "{hint}");+ d.snapshot("export-gif-alpha");+ d.click("export.video.alpha");+ assert!(!d.app().ui.export.settings.keeps_alpha());+}+ #[test] fn the_status_bar_and_the_queue_show_the_time_left() { use std::sync::atomic::Ordering;@@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** major (new file: every file type Premiere Pro 26.5.2 reads or writes, against ours) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (GIF export binary transparency) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 Containers, stills, audio files, project and interchange files, captions and presets. Codecs inside the containers are in [codec-parity.md](codec-parity.md). Our import list is@@ -52,7 +52,7 @@ only). "Tested" names the evidence. | Format | Premiere | Read | Write (frame / sequence) | % | |---|---|---|---|---|-| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP / JPEG sequences, GIF | 90% |+| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP / JPEG sequences; GIF with optional binary transparency (alpha 0–127 transparent, 128–255 opaque, #3396) | 90% | | WebP | — | yes | no | ahead | | **PSD** (layers as sequence) | read (layered) | no | — | 0% | | **OpenEXR** | read + write | stills and sequences: scan-line, none / RLE / ZIPS / ZIP / PXR24, half / float / uint, RGB(A) or Y, kept as linear float (#828); no PIZ, B44, DWA, tiled, multi-part or deep | sequences (half float, linear RGB / premultiplied RGBA; uncompressed, RLE, ZIPS or ZIP; #806, #874) | 70% |@@ -93,3 +93,4 @@ validation in Avid and Pro Tools, DVD / Blu-ray / DCP / broadcast MXF delivery. | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | Created: format-by-format comparison with Premiere 26.5.2 from the import / export registries, crate READMEs, the installed bundle's exporter listings and open issues |+| 2026-10-26 | minor | GIF exports preserve binary transparency when requested; synthetic decode tests cover alpha threshold and animation disposal (#3396); estimates unchanged |@@ -1,6 +1,6 @@ # Where FilmCraft falls short of Premiere Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: ROADMAP's "Where we are lacking" re-measured against Premiere Pro 26.5.2 and the open issue list) · **Target:** Adobe Premiere Pro 2026 (26.5.2)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (G13: optional binary transparency in animated GIF exports) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Every known shortfall, one entry each, ranked by what most stops an editor using FilmCraft for real work. This is the work list: agents should prefer it over adding checklist items. Numbers are in@@ -229,6 +229,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779); OpenEXR sequences (half float, linear; uncompressed, RLE, ZIPS or ZIP; #806, #874); AAC Audio (audio-only `.m4a`, or ADTS `.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342).+- **GIF transparency:** Include Alpha Channel preserves binary transparency (#3396), with an+ explicit 128/255 alpha threshold; decoded synthetic animation tests cover moving transparent+ regions and clearing prior frames. Partial transparency remains a GIF limitation. See the+ [export README](../crates/export/README.md). No readiness estimate changes. - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). ## G14. Localization: 3 of the 12 key languages missing (Hindi, Arabic, Vietnamese)@@ -323,3 +327,4 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** | 2026-10-12 | minor | G10: MP1 (`A_MPEG/L1`) in Matroska imports (#1402) | | 2026-10-12 | minor | G10: OpenEXR PXR24 import | | 2026-10-24 | minor | G8: dragging the cut on a transition's timeline in Effect Controls moves the cut, and the transition moves with it (#577) |+| 2026-10-26 | minor | G13: optional GIF binary transparency, threshold and frame-disposal regression coverage (#3396); estimates unchanged |