lib/gif/src/encode/test.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const decode = @import("../decode/root.zig");
  3 const encode = @import("root.zig");
  4 
  5 const FrameView = encode.FrameView;
  6 const AnimationView = encode.AnimationView;
  7 const Bounds = encode.Bounds;
  8 const Plan = encode.Plan;
  9 const Storage = encode.Storage;
 10 const Scratch = encode.Scratch;
 11 const bytesFromAnimation = encode.bytesFromAnimation;
 12 
 13 const test_decode_bounds = decode.Bounds{
 14     .canvas_pixels = 1024,
 15     .frame_pixels = 1024,
 16     .frames = 8,
 17     .compressed_bytes = 4096,
 18     .retained_rgba8_bytes = 32 * 1024,
 19 };
 20 
 21 const TestDecode = struct {
 22     storage: decode.Storage,
 23     animation: decode.Animation,
 24 
 25     fn init(allocator: std.mem.Allocator, bytes: []const u8) !TestDecode {
 26         var storage = try decode.Storage.init(allocator, .{
 27             .bytes = bytes,
 28             .bounds = test_decode_bounds,
 29         });
 30         errdefer storage.deinit(allocator);
 31         storage.activate();
 32         const animation = try decode.animationFromBytes(&storage, bytes);
 33         return .{ .storage = storage, .animation = animation };
 34     }
 35 
 36     fn deinit(self: *TestDecode, allocator: std.mem.Allocator) void {
 37         self.storage.reset();
 38         self.storage.deinit(allocator);
 39         self.* = undefined;
 40     }
 41 };
 42 
 43 const TestEncode = struct {
 44     storage: Storage,
 45     bytes: []const u8,
 46 
 47     fn init(
 48         allocator: std.mem.Allocator,
 49         animation: AnimationView,
 50         bounds: Bounds,
 51     ) !TestEncode {
 52         var scratch: Scratch = undefined;
 53         var storage = try Storage.init(allocator, .{
 54             .animation = animation,
 55             .bounds = bounds,
 56             .scratch = &scratch,
 57         });
 58         errdefer storage.deinit(allocator);
 59         storage.activate();
 60         const bytes = try bytesFromAnimation(&storage, animation);
 61         return .{ .storage = storage, .bytes = bytes };
 62     }
 63 
 64     fn deinit(self: *TestEncode, allocator: std.mem.Allocator) void {
 65         self.storage.reset();
 66         self.storage.deinit(allocator);
 67         self.* = undefined;
 68     }
 69 };
 70 
 71 test "single opaque frame round-trips through the decoder" {
 72     const allocator = std.testing.allocator;
 73     const rgba = [_]u8{
 74         255, 0, 0,   255, 0,   255, 0, 255,
 75         0,   0, 255, 255, 255, 255, 0, 255,
 76     };
 77     const animation = AnimationView{
 78         .width = 2,
 79         .height = 2,
 80         .frames = &.{.{ .rgba8 = &rgba, .delay_cs = 7 }},
 81     };
 82     var encoded = try TestEncode.init(allocator, animation, .{
 83         .canvas_pixels = 4,
 84         .frames = 1,
 85         .palette_entries = 4,
 86     });
 87     defer encoded.deinit(allocator);
 88 
 89     var decoded = try TestDecode.init(allocator, encoded.bytes);
 90     defer decoded.deinit(allocator);
 91     try std.testing.expectEqual(@as(u32, 2), decoded.animation.width);
 92     try std.testing.expectEqual(@as(u16, 7), decoded.animation.frames[0].delay_cs);
 93     try std.testing.expectEqualSlices(u8, &rgba, decoded.animation.frames[0].rgba8);
 94 }
 95 
 96 test "transparency and loop count round-trip" {
 97     const allocator = std.testing.allocator;
 98     const first = [_]u8{ 251, 73, 52, 255, 0, 0, 0, 0 };
 99     const second = [_]u8{ 0, 0, 0, 0, 235, 219, 178, 255 };
100     const animation = AnimationView{
101         .width = 2,
102         .height = 1,
103         .frames = &.{
104             .{ .rgba8 = &first, .delay_cs = 25 },
105             .{ .rgba8 = &second, .delay_cs = 50 },
106         },
107         .loop_count = 3,
108     };
109     var encoded = try TestEncode.init(allocator, animation, .{
110         .canvas_pixels = 2,
111         .frames = 2,
112         .palette_entries = 3,
113     });
114     defer encoded.deinit(allocator);
115     var decoded = try TestDecode.init(allocator, encoded.bytes);
116     defer decoded.deinit(allocator);
117     try std.testing.expectEqual(@as(?u16, 3), decoded.animation.loop_count);
118     try std.testing.expectEqualSlices(u8, &first, decoded.animation.frames[0].rgba8);
119     try std.testing.expectEqualSlices(u8, &second, decoded.animation.frames[1].rgba8);
120 }
121 
122 test "partial alpha thresholds at the dither convention" {
123     const allocator = std.testing.allocator;
124     const rgba = [_]u8{ 10, 20, 30, 200, 10, 20, 30, 127 };
125     const animation = AnimationView{
126         .width = 2,
127         .height = 1,
128         .frames = &.{.{ .rgba8 = &rgba }},
129     };
130     var encoded = try TestEncode.init(allocator, animation, .{
131         .canvas_pixels = 2,
132         .frames = 1,
133         .palette_entries = 2,
134     });
135     defer encoded.deinit(allocator);
136     var decoded = try TestDecode.init(allocator, encoded.bytes);
137     defer decoded.deinit(allocator);
138     try std.testing.expectEqualSlices(u8, &.{
139         10, 20, 30, 255, 0, 0, 0, 0,
140     }, decoded.animation.frames[0].rgba8);
141 }
142 
143 test "encoding writes the expected bytes for a two pixel witness" {
144     const rgba = [_]u8{ 255, 0, 0, 255, 0, 0, 0, 255 };
145     const animation = AnimationView{
146         .width = 2,
147         .height = 1,
148         .frames = &.{.{ .rgba8 = &rgba }},
149     };
150     var encoded = try TestEncode.init(std.testing.allocator, animation, .{
151         .canvas_pixels = 2,
152         .frames = 1,
153         .palette_entries = 2,
154     });
155     defer encoded.deinit(std.testing.allocator);
156     try std.testing.expectEqualSlices(u8, &.{
157         0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
158         0x02, 0x00, 0x01, 0x00, 0xf0, 0x00,
159         0x00, 0xff, 0x00, 0x00, 0x00, 0x00,
160         0x00, 0x21, 0xf9, 0x04, 0x08, 0x00,
161         0x00, 0x00, 0x00, 0x2c, 0x00, 0x00,
162         0x00, 0x00, 0x02, 0x00, 0x01, 0x00,
163         0x00, 0x02, 0x02, 0x44, 0x0a, 0x00,
164         0x3b,
165     }, encoded.bytes);
166 }
167 
168 test "rejects invalid dimensions frames and palettes" {
169     const rgba = [_]u8{ 0, 0, 0, 255 };
170     var scratch: Scratch = undefined;
171     const bounds = Bounds{ .canvas_pixels = 4, .frames = 1, .palette_entries = 2 };
172     try std.testing.expectError(error.InvalidDimensions, Plan.inspect(.{
173         .width = 0,
174         .height = 1,
175         .frames = &.{.{ .rgba8 = &rgba }},
176     }, bounds, &scratch));
177     try std.testing.expectError(error.InvalidDimensions, Plan.inspect(.{
178         .width = 70_000,
179         .height = 1,
180         .frames = &.{.{ .rgba8 = &rgba }},
181     }, bounds, &scratch));
182     try std.testing.expectError(error.NoFrames, Plan.inspect(.{
183         .width = 1,
184         .height = 1,
185         .frames = &.{},
186     }, bounds, &scratch));
187     try std.testing.expectError(error.FrameSizeMismatch, Plan.inspect(.{
188         .width = 2,
189         .height = 2,
190         .frames = &.{.{ .rgba8 = &rgba }},
191     }, bounds, &scratch));
192 }
193 
194 test "rejects more than 256 distinct colors" {
195     var rgba: [257 * 4]u8 = undefined;
196     for (0..257) |i| {
197         rgba[i * 4] = @intCast(i & 0xff);
198         rgba[i * 4 + 1] = @intCast(i >> 8);
199         rgba[i * 4 + 2] = 0;
200         rgba[i * 4 + 3] = 255;
201     }
202     var scratch: Scratch = undefined;
203     try std.testing.expectError(error.TooManyColors, Plan.inspect(.{
204         .width = 257,
205         .height = 1,
206         .frames = &.{.{ .rgba8 = &rgba }},
207     }, .{ .canvas_pixels = 257, .frames = 1, .palette_entries = 256 }, &scratch));
208 }
209 
210 test "GIF encode planning accepts each maximum and rejects max plus one" {
211     comptime {
212         @stardustClaim(
213             @import("alloc_phase").capacity.witness(@import("./root.zig").Storage, "gif_encode_boundaries"),
214             null,
215             null,
216             null,
217             null,
218             null,
219             null,
220         );
221     }
222 
223     const rgba = [_]u8{ 255, 0, 0, 255, 0, 0, 0, 0 };
224     const animation = AnimationView{
225         .width = 2,
226         .height = 1,
227         .frames = &.{.{ .rgba8 = &rgba }},
228     };
229     var scratch: Scratch = undefined;
230     const exact = Bounds{ .canvas_pixels = 2, .frames = 1, .palette_entries = 2 };
231     _ = try Plan.inspect(animation, exact, &scratch);
232     try std.testing.expectError(error.CanvasPixelCapacityExceeded, Plan.inspect(
233         animation,
234         .{ .canvas_pixels = 1, .frames = 1, .palette_entries = 2 },
235         &scratch,
236     ));
237     try std.testing.expectError(error.FrameCapacityExceeded, Plan.inspect(
238         animation,
239         .{ .canvas_pixels = 2, .frames = 0, .palette_entries = 2 },
240         &scratch,
241     ));
242     try std.testing.expectError(error.PaletteCapacityExceeded, Plan.inspect(
243         animation,
244         .{ .canvas_pixels = 2, .frames = 1, .palette_entries = 1 },
245         &scratch,
246     ));
247 }
248 
249 test "GIF encode storage rejects concurrent use and resets after encode" {
250     comptime {
251         @stardustClaim(
252             @import("alloc_phase").capacity.witness(@import("./root.zig").Storage, "gif_encode_reuse"),
253             null,
254             null,
255             null,
256             null,
257             null,
258             null,
259         );
260     }
261 
262     const rgba = [_]u8{ 255, 0, 0, 255 };
263     const animation = AnimationView{
264         .width = 1,
265         .height = 1,
266         .frames = &.{.{ .rgba8 = &rgba }},
267     };
268     var scratch: Scratch = undefined;
269     var storage = try Storage.init(std.testing.allocator, .{
270         .animation = animation,
271         .bounds = .{ .canvas_pixels = 1, .frames = 1, .palette_entries = 1 },
272         .scratch = &scratch,
273     });
274     defer storage.deinit(std.testing.allocator);
275     storage.activate();
276     const first = try bytesFromAnimation(&storage, animation);
277     const first_hash = std.hash.Wyhash.hash(0, first);
278     try std.testing.expectError(error.EncodeStorageInUse, bytesFromAnimation(&storage, animation));
279     storage.reset();
280     const second = try bytesFromAnimation(&storage, animation);
281     defer storage.reset();
282     try std.testing.expectEqual(@intFromPtr(first.ptr), @intFromPtr(second.ptr));
283     try std.testing.expectEqual(first_hash, std.hash.Wyhash.hash(0, second));
284 }
285 
286 test "GIF encode rejects changed input before output mutation" {
287     const rgba = [_]u8{ 255, 0, 0, 255 };
288     const animation = AnimationView{
289         .width = 1,
290         .height = 1,
291         .frames = &.{.{ .rgba8 = &rgba, .delay_cs = 1 }},
292     };
293     var scratch: Scratch = undefined;
294     var storage = try Storage.init(std.testing.allocator, .{
295         .animation = animation,
296         .bounds = .{ .canvas_pixels = 1, .frames = 1, .palette_entries = 1 },
297         .scratch = &scratch,
298     });
299     defer storage.deinit(std.testing.allocator);
300     storage.activate();
301     const changed = AnimationView{
302         .width = 1,
303         .height = 1,
304         .frames = &.{.{ .rgba8 = &rgba, .delay_cs = 2 }},
305     };
306     @memset(storage.output, 0xa5);
307     const output_hash = std.hash.Wyhash.hash(0, storage.output);
308     try std.testing.expectError(error.EncodeInputMismatch, bytesFromAnimation(&storage, changed));
309     try std.testing.expectEqual(output_hash, std.hash.Wyhash.hash(0, storage.output));
310     const bytes = try bytesFromAnimation(&storage, animation);
311     defer storage.reset();
312     try std.testing.expectEqual(storage.status().output_bytes, bytes.len);
313 }
314 
315 test "Activated GIF encode storage performs no backing allocation" {
316     comptime {
317         @stardustClaim(
318             @import("alloc_phase").capacity.witness(@import("./root.zig").Storage, "gif_encode_sealed"),
319             null,
320             null,
321             null,
322             null,
323             null,
324             null,
325         );
326     }
327 
328     var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
329     const rgba = [_]u8{ 255, 0, 0, 255 };
330     const animation = AnimationView{
331         .width = 1,
332         .height = 1,
333         .frames = &.{.{ .rgba8 = &rgba }},
334     };
335     var scratch: Scratch = undefined;
336     var storage = try Storage.init(counting.allocator(), .{
337         .animation = animation,
338         .bounds = .{ .canvas_pixels = 1, .frames = 1, .palette_entries = 1 },
339         .scratch = &scratch,
340     });
341     defer storage.deinit(counting.allocator());
342     storage.activate();
343     const bytes = try bytesFromAnimation(&storage, animation);
344     defer storage.reset();
345     try std.testing.expectEqual(@as(usize, 1), counting.alloc_index);
346     try std.testing.expectEqual(@as(usize, 0), counting.resize_index);
347     try std.testing.expectEqual(storage.status().output_bytes, bytes.len);
348 }
349 
350 test "encoding allocation baseline" {
351     const width = 320;
352     const height = 180;
353     const frame_count = 32;
354     const rgba = try std.testing.allocator.alloc(u8, width * height * 4);
355     defer std.testing.allocator.free(rgba);
356     var pixel: usize = 0;
357     while (pixel < width * height) : (pixel += 1) {
358         rgba[pixel * 4] = @intCast((pixel % 16) * 16);
359         rgba[pixel * 4 + 1] = @intCast((15 - (pixel % 16)) * 16);
360         rgba[pixel * 4 + 2] = 128;
361         rgba[pixel * 4 + 3] = 255;
362     }
363     var frames: [frame_count]FrameView = undefined;
364     for (&frames) |*frame| frame.* = .{ .rgba8 = rgba, .delay_cs = 6 };
365     const animation = AnimationView{
366         .width = width,
367         .height = height,
368         .frames = &frames,
369         .loop_count = 0,
370     };
371 
372     var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{});
373     var scratch: Scratch = undefined;
374     var storage = try Storage.init(counting.allocator(), .{
375         .animation = animation,
376         .bounds = .{
377             .canvas_pixels = width * height,
378             .frames = frame_count,
379             .palette_entries = 16,
380         },
381         .scratch = &scratch,
382     });
383     storage.activate();
384     const bytes = try bytesFromAnimation(&storage, animation);
385     try std.testing.expectEqual(@as(usize, 52_753), bytes.len);
386     try std.testing.expectEqual(@as(usize, 1), counting.alloc_index);
387     try std.testing.expectEqual(@as(usize, 0), counting.resize_index);
388     try std.testing.expectEqual(@as(usize, 103_524), storage.status().storage_bytes);
389     try std.testing.expectEqual(storage.status().storage_bytes, counting.allocated_bytes);
390     storage.reset();
391     storage.deinit(counting.allocator());
392     try std.testing.expectEqual(counting.allocated_bytes, counting.freed_bytes);
393 }