collective

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

diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -88,3 +88,7 @@ sync_github_name = false  [people.webdevtodayjason] display_name = "Jason Brashear"++[people.thecyberbutler]+display_name = "@thecyberbutler"+sync_github_name = falsediff --git a/crates/export/README.md b/crates/export/README.mdnew file mode 100644--- /dev/null+++ b/crates/export/README.md@@ -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.diff --git a/crates/export/src/format.rs b/crates/export/src/format.rs--- a/crates/export/src/format.rs+++ b/crates/export/src/format.rs@@ -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,diff --git a/crates/export/src/run.rs b/crates/export/src/run.rs--- a/crates/export/src/run.rs+++ b/crates/export/src/run.rs@@ -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()))?;diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs--- a/crates/export/src/settings.rs+++ b/crates/export/src/settings.rs@@ -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,         }diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs--- a/crates/export/src/settings_tests.rs+++ b/crates/export/src/settings_tests.rs@@ -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]diff --git a/crates/ui-egui/src/panels/export_mode/settings.rs b/crates/ui-egui/src/panels/export_mode/settings.rs--- a/crates/ui-egui/src/panels/export_mode/settings.rs+++ b/crates/ui-egui/src/panels/export_mode/settings.rs@@ -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) => {diff --git a/crates/ui-egui/tests/it/export_ui.rs b/crates/ui-egui/tests/it/export_ui.rs--- a/crates/ui-egui/tests/it/export_ui.rs+++ b/crates/ui-egui/tests/it/export_ui.rs@@ -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;diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -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 |diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -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 |