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 }