181 lines
6.1 KiB
C#
181 lines
6.1 KiB
C#
using System.IO;
|
|
using System.Text;
|
|
using System.Windows;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
|
|
namespace CamBooth.App.Features.Capture;
|
|
|
|
/// <summary>
|
|
/// Creates an animated GIF from several frames using WPF's <see cref="GifBitmapEncoder"/>.
|
|
/// 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.
|
|
/// </summary>
|
|
public static class GifEncoder
|
|
{
|
|
private const int TargetWidth = 480;
|
|
|
|
/// <summary>
|
|
/// Builds an animated, looping GIF from <paramref name="framePaths"/> and writes it to
|
|
/// <paramref name="outputPath"/>. <paramref name="frameDelayMs"/> is the per-frame display time.
|
|
/// </summary>
|
|
public static string CreateGif(IReadOnlyList<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static byte[] TryAddAnimation(byte[] src, byte delayCs)
|
|
{
|
|
try
|
|
{
|
|
var outBytes = new List<byte>(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<byte> 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<byte> { 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;
|
|
}
|
|
}
|