using System.IO; using System.Text; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; namespace CamBooth.App.Features.Capture; /// /// Creates an animated GIF from several frames using WPF's . /// WPF writes the frames but not the per-frame delay or the loop flag, so the encoded bytes are /// post-processed to inject a NETSCAPE2.0 looping block and a Graphic Control Extension per frame. /// public static class GifEncoder { private const int TargetWidth = 480; /// /// Builds an animated, looping GIF from and writes it to /// . is the per-frame display time. /// public static string CreateGif(IReadOnlyList framePaths, string outputPath, int frameDelayMs = 600) { if (framePaths.Count == 0) throw new ArgumentException("No frames for GIF.", nameof(framePaths)); var (w, h) = TargetSize(framePaths[0]); var encoder = new GifBitmapEncoder(); foreach (var path in framePaths) encoder.Frames.Add(BitmapFrame.Create(ToUniformFrame(path, w, h))); byte[] raw; using (var ms = new MemoryStream()) { encoder.Save(ms); raw = ms.ToArray(); } var delayCs = (byte)Math.Clamp(frameDelayMs / 10, 2, 255); var bytes = TryAddAnimation(raw, delayCs); File.WriteAllBytes(outputPath, bytes); return outputPath; } /// /// Rewrites a static multi-frame GIF into a looping animation. On any parsing surprise it falls /// back to the original bytes (still a valid GIF), so a capture is never lost. /// private static byte[] TryAddAnimation(byte[] src, byte delayCs) { try { var outBytes = new List(src.Length + 64); int pos = 0; // Header (6) + Logical Screen Descriptor (7) outBytes.AddRange(src[..13]); pos = 13; // Global Color Table (if present) var lsdPacked = src[10]; if ((lsdPacked & 0x80) != 0) { int gctBytes = 3 * (1 << ((lsdPacked & 0x07) + 1)); outBytes.AddRange(src[pos..(pos + gctBytes)]); pos += gctBytes; } // Insert the loop-forever application extension once. outBytes.AddRange(NetscapeLoopBlock()); while (pos < src.Length) { byte block = src[pos]; if (block == 0x3B) // trailer { outBytes.Add(block); pos++; break; } if (block == 0x21) // extension { outBytes.Add(src[pos++]); // introducer outBytes.Add(src[pos++]); // label pos = CopySubBlocks(src, pos, outBytes); } else if (block == 0x2C) // image descriptor { outBytes.AddRange(GraphicControlExtension(delayCs)); // our delay, before the image outBytes.AddRange(src[pos..(pos + 10)]); // descriptor (separator + 9) byte imgPacked = src[pos + 9]; pos += 10; if ((imgPacked & 0x80) != 0) // local color table { int lctBytes = 3 * (1 << ((imgPacked & 0x07) + 1)); outBytes.AddRange(src[pos..(pos + lctBytes)]); pos += lctBytes; } outBytes.Add(src[pos++]); // LZW minimum code size pos = CopySubBlocks(src, pos, outBytes); // image data } else { // Unknown — bail out and keep the original (safe fallback). return src; } } return outBytes.ToArray(); } catch { return src; } } private static int CopySubBlocks(byte[] src, int pos, List outBytes) { while (true) { byte size = src[pos]; outBytes.Add(size); pos++; if (size == 0) break; outBytes.AddRange(src[pos..(pos + size)]); pos += size; } return pos; } private static byte[] NetscapeLoopBlock() { var b = new List { 0x21, 0xFF, 0x0B }; b.AddRange(Encoding.ASCII.GetBytes("NETSCAPE2.0")); b.AddRange(new byte[] { 0x03, 0x01, 0x00, 0x00, 0x00 }); // sub-block, id=1, loop count 0 = forever, terminator return b.ToArray(); } private static byte[] GraphicControlExtension(byte delayCs) => // introducer, label, block size, packed (disposal=1), delay lo/hi, transparent index, terminator new byte[] { 0x21, 0xF9, 0x04, 0x04, delayCs, 0x00, 0x00, 0x00 }; private static (int w, int h) TargetSize(string firstFramePath) { var probe = new BitmapImage(); probe.BeginInit(); probe.UriSource = new Uri(firstFramePath); probe.CacheOption = BitmapCacheOption.OnLoad; probe.EndInit(); int h = (int)Math.Round(TargetWidth * (probe.PixelHeight / (double)probe.PixelWidth)); return (TargetWidth, Math.Max(1, h)); } private static BitmapSource ToUniformFrame(string path, int w, int h) { var src = new BitmapImage(); src.BeginInit(); src.UriSource = new Uri(path); src.CacheOption = BitmapCacheOption.OnLoad; src.DecodePixelWidth = w; src.EndInit(); src.Freeze(); var visual = new DrawingVisual(); using (var dc = visual.RenderOpen()) dc.DrawImage(src, new Rect(0, 0, w, h)); var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32); rtb.Render(visual); rtb.Freeze(); return rtb; } }