lib/gui/src/paint/image.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

   1 const std = @import("std");
   2 const gpu = @import("gpu");
   3 const choir_abi = @import("choir_abi");
   4 const accy = @import("accy");
   5 const alloc_phase = @import("alloc_phase");
   6 
   7 const command = @import("command.zig");
   8 const cpu = @import("cpu/root.zig");
   9 
  10 const Allocator = std.mem.Allocator;
  11 const library = accy.kernel.library;
  12 const image_library = library.image;
  13 const host_loop_launch_shape_arg_count: usize = choir_abi.launch_shape_argument_count;
  14 const runtime_argument_count_max: usize = 7;
  15 const scalar_argument_count_max: usize = runtime_argument_count_max + host_loop_launch_shape_arg_count;
  16 const Color = @import("../root.zig").model.UiColor;
  17 const Rect = @import("../root.zig").layout.Rect;
  18 const ImageThreads = @TypeOf(image_library.imageThreadsForExtents(1, 1));
  19 
  20 pub const Axis = image_library.Axis;
  21 
  22 const ImageLimits = struct {
  23     dst_pixels: usize = 0,
  24     src_pixels: usize = 0,
  25     scratch_pixels: usize = 0,
  26     weight_taps: usize = 0,
  27 
  28     fn merged(self: Limits, demand: Limits) Limits {
  29         return .{
  30             .dst_pixels = @max(self.dst_pixels, demand.dst_pixels),
  31             .src_pixels = @max(self.src_pixels, demand.src_pixels),
  32             .scratch_pixels = @max(self.scratch_pixels, demand.scratch_pixels),
  33             .weight_taps = @max(self.weight_taps, demand.weight_taps),
  34         };
  35     }
  36 };
  37 pub const Limits = ImageLimits;
  38 
  39 const ImageCapacity = struct {
  40     limits: Limits,
  41     host_storage_bytes: usize,
  42     device_storage_bytes: usize,
  43     device_buffer_count: usize,
  44     total_storage_bytes: usize,
  45 
  46     pub fn derive(limits: Limits) error{CapacityOverflow}!Capacity {
  47         const host_elements = std.math.add(usize, limits.dst_pixels, limits.weight_taps) catch return error.CapacityOverflow;
  48         const host_storage_bytes = std.math.mul(usize, host_elements, @sizeOf(u32)) catch return error.CapacityOverflow;
  49         var device_elements: usize = 0;
  50         var device_buffer_count: usize = 0;
  51         const counts = [_]usize{ limits.dst_pixels, limits.src_pixels, limits.scratch_pixels, limits.weight_taps };
  52         for (counts) |count| {
  53             device_elements = std.math.add(usize, device_elements, count) catch return error.CapacityOverflow;
  54             device_buffer_count += @intFromBool(count != 0);
  55         }
  56         const device_storage_bytes = std.math.mul(usize, device_elements, @sizeOf(u32)) catch return error.CapacityOverflow;
  57         return .{
  58             .limits = limits,
  59             .host_storage_bytes = host_storage_bytes,
  60             .device_storage_bytes = device_storage_bytes,
  61             .device_buffer_count = device_buffer_count,
  62             .total_storage_bytes = std.math.add(usize, host_storage_bytes, device_storage_bytes) catch return error.CapacityOverflow,
  63         };
  64     }
  65 
  66     pub fn admits(self: Capacity, demand: Limits) bool {
  67         return self.limits.dst_pixels >= demand.dst_pixels and
  68             self.limits.src_pixels >= demand.src_pixels and
  69             self.limits.scratch_pixels >= demand.scratch_pixels and
  70             self.limits.weight_taps >= demand.weight_taps;
  71     }
  72 };
  73 pub const Capacity = ImageCapacity;
  74 
  75 pub const StorageStatus = struct {
  76     limits: ?Limits,
  77     capacity: ?Capacity,
  78     replacements: usize,
  79 };
  80 
  81 pub const Options = struct {
  82     artifact_format: ?gpu.ArtifactFormat = null,
  83     initial_storage: ?Limits = null,
  84 };
  85 
  86 const ImageLaunchScalars = struct {
  87     storage: [scalar_argument_count_max]choir_abi.ScalarArgument,
  88     count: usize,
  89 
  90     fn slice(self: *const ImageLaunchScalars) []const choir_abi.ScalarArgument {
  91         return self.storage[0..self.count];
  92     }
  93 };
  94 
  95 fn imageLaunchScalars(
  96     entry: anytype,
  97     format: gpu.ArtifactFormat,
  98     geometry: choir_abi.LaunchGeometry,
  99     runtime_arguments: []const choir_abi.ScalarArgument,
 100 ) !ImageLaunchScalars {
 101     if (runtime_arguments.len > runtime_argument_count_max) return error.LaunchArgumentMismatch;
 102     var result = ImageLaunchScalars{ .storage = undefined, .count = runtime_arguments.len };
 103     @memcpy(result.storage[0..runtime_arguments.len], runtime_arguments);
 104     if (gpu.artifactFormatUsesHostLoopLaunch(format)) {
 105         if (entry.static_arguments.len != host_loop_launch_shape_arg_count) return error.InvalidArtifact;
 106         const shape = try choir_abi.launchShape(try geometry.threadCount(), geometry);
 107         try shape.scalarArguments(result.storage[result.count..]);
 108         result.count += host_loop_launch_shape_arg_count;
 109     } else {
 110         if (entry.static_arguments.len > host_loop_launch_shape_arg_count) return error.InvalidArtifact;
 111         @memcpy(result.storage[result.count..][0..entry.static_arguments.len], entry.static_arguments);
 112         result.count += entry.static_arguments.len;
 113     }
 114     return result;
 115 }
 116 
 117 pub const ShadowOptions = struct {
 118     image_index: u32 = 0,
 119     sigma: ?f32 = null,
 120 };
 121 
 122 pub const ShadowExpansionOptions = struct {
 123     sigma: ?f32 = null,
 124 };
 125 
 126 pub const ThumbnailOptions = struct {
 127     max_width: u32,
 128     max_height: u32,
 129 };
 130 
 131 pub const ResizedImage = struct {
 132     image: command.Image,
 133     pixels: []u32,
 134 
 135     pub fn deinit(self: *ResizedImage, allocator: Allocator) void {
 136         allocator.free(self.pixels);
 137         self.* = undefined;
 138     }
 139 };
 140 
 141 pub const Thumbnail = ResizedImage;
 142 
 143 pub const Shadow = struct {
 144     command: command.Command,
 145     image: command.Image,
 146     pixels: []u32,
 147 
 148     pub fn deinit(self: *Shadow, allocator: Allocator) void {
 149         allocator.free(self.pixels);
 150         self.* = undefined;
 151     }
 152 };
 153 
 154 pub const ShadowExpansion = struct {
 155     allocator: Allocator,
 156     commands: []const command.Command,
 157     image_entries: []const command.Image,
 158     shadows: []Shadow,
 159 
 160     pub fn imageSet(self: *const ShadowExpansion) command.ImageSet {
 161         return .{ .images = self.image_entries };
 162     }
 163 
 164     pub fn refreshReusableCommands(self: *ShadowExpansion, commands: []const command.Command) !void {
 165         if (commands.len != self.commands.len) return error.InvalidShadowCommand;
 166         const rewritten = @constCast(self.commands);
 167         var shadow_index: usize = 0;
 168         for (commands, 0..) |paint, index| {
 169             if (paint.kind == .shadow) {
 170                 if (shadow_index >= self.shadows.len) return error.InvalidShadowCommand;
 171                 tintShadowPixels(self.shadows[shadow_index].pixels, paint.color);
 172                 rewritten[index] = self.shadows[shadow_index].command;
 173                 shadow_index += 1;
 174             } else {
 175                 rewritten[index] = paint;
 176             }
 177         }
 178         if (shadow_index != self.shadows.len) return error.InvalidShadowCommand;
 179     }
 180 
 181     pub fn deinit(self: *ShadowExpansion) void {
 182         for (self.shadows) |*shadow| shadow.deinit(self.allocator);
 183         self.allocator.free(@constCast(self.commands));
 184         self.allocator.free(@constCast(self.image_entries));
 185         self.allocator.free(self.shadows);
 186         self.* = undefined;
 187     }
 188 };
 189 
 190 pub const Processor = struct {
 191     allocator: Allocator,
 192     handle: gpu.BackendHandle,
 193     format: gpu.ArtifactFormat,
 194     kernels: std.ArrayListUnmanaged(CachedImageKernel) = .empty,
 195     buffers: ImageBuffers = .{},
 196     shadow_alpha: ?CachedShadowAlpha = null,
 197     storage_replacements: usize = 0,
 198 
 199     pub fn init(allocator: Allocator, handle: gpu.BackendHandle, options: Options) !Processor {
 200         var result = Processor{
 201             .allocator = allocator,
 202             .handle = handle,
 203             .format = options.artifact_format orelse try defaultFormat(handle),
 204         };
 205         if (options.initial_storage) |limits| {
 206             result.buffers = try ImageBuffers.init(allocator, handle, limits);
 207         }
 208         return result;
 209     }
 210 
 211     pub fn deinit(self: *Processor) void {
 212         if (self.shadow_alpha) |*cached| cached.deinit(self.allocator);
 213         self.buffers.deinit(self.allocator, self.handle);
 214         for (self.kernels.items) |*cached| cached.deinit(self.handle);
 215         self.kernels.deinit(self.allocator);
 216         self.* = undefined;
 217     }
 218 
 219     pub fn storageStatus(self: *const Processor) StorageStatus {
 220         return .{
 221             .limits = if (self.buffers.capacity) |capacity| capacity.limits else null,
 222             .capacity = self.buffers.capacity,
 223             .replacements = self.storage_replacements,
 224         };
 225     }
 226 
 227     pub fn blurPass(
 228         self: *Processor,
 229         dst: []u32,
 230         src: []const u32,
 231         width: u32,
 232         height: u32,
 233         radius: u32,
 234         sigma: f32,
 235         axis: Axis,
 236     ) !void {
 237         const pixels = try validateImageSlices(dst, src, width, height);
 238         const taps = try blurTapCount(radius);
 239         try self.ensureStorage(.{
 240             .dst_pixels = pixels,
 241             .src_pixels = pixels,
 242             .weight_taps = taps,
 243         });
 244 
 245         const dst_buffer = self.buffers.dst.?.handle;
 246         const src_buffer = self.buffers.src.?.handle;
 247         const weights_buffer = try self.buffers.ensureWeightsData(self.handle, radius, sigma);
 248 
 249         try self.handle.writeBuffer(.{
 250             .handle = src_buffer,
 251             .bytes = std.mem.sliceAsBytes(src[0..pixels]),
 252         });
 253         try self.launchBlurPass(dst_buffer, src_buffer, weights_buffer, width, height, radius, axis);
 254         try self.buffers.readPixels(self.handle, dst_buffer, dst, pixels);
 255     }
 256 
 257     pub fn resizeBilinear(
 258         self: *Processor,
 259         dst: []u32,
 260         src: []const u32,
 261         dst_width: u32,
 262         dst_height: u32,
 263         src_width: u32,
 264         src_height: u32,
 265     ) !void {
 266         const dst_pixels = try pixelCount(dst_width, dst_height);
 267         const src_pixels = try pixelCount(src_width, src_height);
 268         if (dst.len < dst_pixels or src.len < src_pixels) return error.BufferTooSmall;
 269         try self.ensureStorage(.{
 270             .dst_pixels = dst_pixels,
 271             .src_pixels = src_pixels,
 272         });
 273 
 274         const dst_buffer = self.buffers.dst.?.handle;
 275         const src_buffer = self.buffers.src.?.handle;
 276 
 277         try self.handle.writeBuffer(.{
 278             .handle = src_buffer,
 279             .bytes = std.mem.sliceAsBytes(src[0..src_pixels]),
 280         });
 281         try self.launchResizeBilinear(dst_buffer, src_buffer, dst_width, dst_height, src_width, src_height);
 282         try self.buffers.readPixels(self.handle, dst_buffer, dst, dst_pixels);
 283     }
 284 
 285     pub fn resizeAlloc(
 286         self: *Processor,
 287         source: command.Image,
 288         width: u32,
 289         height: u32,
 290     ) !ResizedImage {
 291         try source.validate();
 292         const pixels = try pixelCount(width, height);
 293         const dst = try self.allocator.alloc(u32, pixels);
 294         errdefer self.allocator.free(dst);
 295         try self.resizeBilinear(dst, source.pixels, width, height, source.width, source.height);
 296         return .{
 297             .image = .{
 298                 .width = width,
 299                 .height = height,
 300                 .pixels = dst,
 301             },
 302             .pixels = dst,
 303         };
 304     }
 305 
 306     pub fn thumbnailAlloc(
 307         self: *Processor,
 308         source: command.Image,
 309         options: ThumbnailOptions,
 310     ) !Thumbnail {
 311         try source.validate();
 312         const extent = try thumbnailExtent(source.width, source.height, options.max_width, options.max_height);
 313         if (extent.width == source.width and extent.height == source.height) {
 314             const pixels = try pixelCount(source.width, source.height);
 315             const dst = try self.allocator.dupe(u32, source.pixels[0..pixels]);
 316             errdefer self.allocator.free(dst);
 317             return .{
 318                 .image = .{
 319                     .width = source.width,
 320                     .height = source.height,
 321                     .pixels = dst,
 322                 },
 323                 .pixels = dst,
 324             };
 325         }
 326         return self.resizeAlloc(source, extent.width, extent.height);
 327     }
 328 
 329     pub fn shadowAlloc(self: *Processor, paint: command.Command, options: ShadowOptions) !Shadow {
 330         const plan = try planShadow(paint, options);
 331 
 332         const pixels = try self.allocator.alloc(u32, plan.pixels);
 333         errdefer self.allocator.free(pixels);
 334         if (self.useShadowAlphaCache(plan.key, pixels)) {
 335             tintShadowPixels(pixels, paint.color);
 336             return plan.shadow(pixels);
 337         }
 338 
 339         const mask = try self.allocator.alloc(u32, plan.pixels);
 340         defer self.allocator.free(mask);
 341         rasterShadowMask(mask, plan.frame, paint);
 342 
 343         if (plan.blur_radius > 0) {
 344             try self.blurTwoPass(
 345                 pixels,
 346                 mask,
 347                 plan.width,
 348                 plan.height,
 349                 plan.blur_radius,
 350                 plan.sigma,
 351             );
 352         } else {
 353             @memcpy(pixels, mask);
 354         }
 355         try self.storeShadowAlpha(plan.key, pixels);
 356         tintShadowPixels(pixels, paint.color);
 357         return plan.shadow(pixels);
 358     }
 359 
 360     pub fn expandShadowsAlloc(
 361         self: *Processor,
 362         commands: []const command.Command,
 363         images: command.ImageSet,
 364         options: ShadowExpansionOptions,
 365     ) !ShadowExpansion {
 366         const shadow_count = countShadows(commands);
 367         const image_count = std.math.add(usize, images.images.len, shadow_count) catch return error.ImageCountTooLarge;
 368         if (image_count > std.math.maxInt(u32)) return error.ImageCountTooLarge;
 369 
 370         const rewritten = try self.allocator.alloc(command.Command, commands.len);
 371         errdefer self.allocator.free(rewritten);
 372         const image_entries = try self.allocator.alloc(command.Image, image_count);
 373         errdefer self.allocator.free(image_entries);
 374         if (images.images.len != 0) @memcpy(image_entries[0..images.images.len], images.images);
 375 
 376         const shadows = try self.allocator.alloc(Shadow, shadow_count);
 377         var shadow_init: usize = 0;
 378         errdefer {
 379             for (shadows[0..shadow_init]) |*shadow| shadow.deinit(self.allocator);
 380             self.allocator.free(shadows);
 381         }
 382 
 383         var generated_index: usize = 0;
 384         for (commands, 0..) |paint, index| {
 385             if (paint.kind == .shadow) {
 386                 const image_index_usize = images.images.len + generated_index;
 387                 const image_index: u32 = @intCast(image_index_usize);
 388                 const generated = try self.shadowAlloc(paint, .{
 389                     .image_index = image_index,
 390                     .sigma = options.sigma,
 391                 });
 392                 shadows[generated_index] = generated;
 393                 shadow_init += 1;
 394                 image_entries[image_index_usize] = generated.image;
 395                 rewritten[index] = generated.command;
 396                 generated_index += 1;
 397             } else {
 398                 rewritten[index] = paint;
 399             }
 400         }
 401 
 402         return .{
 403             .allocator = self.allocator,
 404             .commands = rewritten,
 405             .image_entries = image_entries,
 406             .shadows = shadows,
 407         };
 408     }
 409 
 410     fn blurTwoPass(
 411         self: *Processor,
 412         dst: []u32,
 413         src: []const u32,
 414         width: u32,
 415         height: u32,
 416         radius: u32,
 417         sigma: f32,
 418     ) !void {
 419         const pixels = try validateImageSlices(dst, src, width, height);
 420         const taps = try blurTapCount(radius);
 421         try self.ensureStorage(.{
 422             .dst_pixels = pixels,
 423             .src_pixels = pixels,
 424             .scratch_pixels = pixels,
 425             .weight_taps = taps,
 426         });
 427 
 428         const dst_buffer = self.buffers.dst.?.handle;
 429         const scratch_buffer = self.buffers.scratch.?.handle;
 430         const src_buffer = self.buffers.src.?.handle;
 431         const weights_buffer = try self.buffers.ensureWeightsData(self.handle, radius, sigma);
 432 
 433         try self.handle.writeBuffer(.{
 434             .handle = src_buffer,
 435             .bytes = std.mem.sliceAsBytes(src[0..pixels]),
 436         });
 437         try self.queueBlurPass(scratch_buffer, src_buffer, weights_buffer, width, height, radius, .horizontal);
 438         errdefer self.handle.synchronize(.{ .scope = .device }) catch {};
 439         try self.queueBlurPass(dst_buffer, scratch_buffer, weights_buffer, width, height, radius, .vertical);
 440         try self.handle.synchronize(.{ .scope = .device });
 441         try self.buffers.readPixels(self.handle, dst_buffer, dst, pixels);
 442     }
 443 
 444     fn ensureStorage(self: *Processor, demand: Limits) !void {
 445         if (self.buffers.capacity) |capacity| {
 446             if (capacity.admits(demand)) return;
 447         }
 448         const next_limits = if (self.buffers.capacity) |capacity|
 449             capacity.limits.merged(demand)
 450         else
 451             demand;
 452         const next = try ImageBuffers.init(self.allocator, self.handle, next_limits);
 453         const replacing = self.buffers.capacity != null;
 454         self.buffers.deinit(self.allocator, self.handle);
 455         self.buffers = next;
 456         if (replacing) self.storage_replacements += 1;
 457     }
 458 
 459     pub fn launchBlurPass(
 460         self: *Processor,
 461         dst: gpu.BufferHandle,
 462         src: gpu.BufferHandle,
 463         weights: gpu.BufferHandle,
 464         width: u32,
 465         height: u32,
 466         radius: u32,
 467         axis: Axis,
 468     ) !void {
 469         try self.queueBlurPass(dst, src, weights, width, height, radius, axis);
 470         try self.handle.synchronize(.{ .scope = .device });
 471     }
 472 
 473     fn queueBlurPass(
 474         self: *Processor,
 475         dst: gpu.BufferHandle,
 476         src: gpu.BufferHandle,
 477         weights: gpu.BufferHandle,
 478         width: u32,
 479         height: u32,
 480         radius: u32,
 481         axis: Axis,
 482     ) !void {
 483         const pixels = try pixelCount(width, height);
 484         const taps = try blurTapCount(radius);
 485         try expectBufferSize(dst, pixels, @sizeOf(u32));
 486         try expectBufferSize(src, pixels, @sizeOf(u32));
 487         try expectBufferSize(weights, taps, @sizeOf(f32));
 488 
 489         const runtime_arguments = try blurRuntimeArguments(width, height);
 490         const bindings = [_]gpu.BufferBinding{
 491             bufferBinding(dst, .read_write),
 492             bufferBinding(src, .read_only),
 493             bufferBinding(weights, .read_only),
 494         };
 495         try self.queueCachedKernel(.{ .blur_pass = .{
 496             .width = width,
 497             .height = height,
 498             .radius = radius,
 499             .axis = axis,
 500             .threads = image_library.imageThreadsForExtents(width, height),
 501         } }, bindings[0..], runtime_arguments[0..], "gui/paint/image/blur-pass");
 502     }
 503 
 504     pub fn launchResizeBilinear(
 505         self: *Processor,
 506         dst: gpu.BufferHandle,
 507         src: gpu.BufferHandle,
 508         dst_width: u32,
 509         dst_height: u32,
 510         src_width: u32,
 511         src_height: u32,
 512     ) !void {
 513         try expectBufferSize(dst, try pixelCount(dst_width, dst_height), @sizeOf(u32));
 514         try expectBufferSize(src, try pixelCount(src_width, src_height), @sizeOf(u32));
 515 
 516         const runtime_arguments = try resizeRuntimeArguments(dst_width, dst_height, src_width, src_height);
 517         const bindings = [_]gpu.BufferBinding{
 518             bufferBinding(dst, .read_write),
 519             bufferBinding(src, .read_only),
 520         };
 521         try self.launchCachedKernel(.{ .resize_bilinear = .{
 522             .dst_width = dst_width,
 523             .dst_height = dst_height,
 524             .src_width = src_width,
 525             .src_height = src_height,
 526             .threads = image_library.imageThreadsForExtents(dst_width, dst_height),
 527         } }, bindings[0..], runtime_arguments[0..], "gui/paint/image/resize-bilinear");
 528     }
 529 
 530     fn launchCachedKernel(
 531         self: *Processor,
 532         key: ImageKernelKey,
 533         bindings: []const gpu.BufferBinding,
 534         runtime_arguments: []const choir_abi.ScalarArgument,
 535         diagnostic_id: []const u8,
 536     ) !void {
 537         try self.queueCachedKernel(key, bindings, runtime_arguments, diagnostic_id);
 538         try self.handle.synchronize(.{ .scope = .device });
 539     }
 540 
 541     fn queueCachedKernel(
 542         self: *Processor,
 543         key: ImageKernelKey,
 544         bindings: []const gpu.BufferBinding,
 545         runtime_arguments: []const choir_abi.ScalarArgument,
 546         diagnostic_id: []const u8,
 547     ) !void {
 548         const cached = try self.cachedKernel(key, diagnostic_id);
 549         const entry = cached.call_artifact.entry();
 550         if (entry.element_count_argument != .none) return error.UnsupportedImageOperation;
 551         const expected_runtime_scalar_count: usize = @intCast(entry.runtime_scalar_argument_count);
 552         if (runtime_arguments.len != expected_runtime_scalar_count) return error.LaunchArgumentMismatch;
 553 
 554         const geometry = try accy.kernel.kernelCallEntryLaunchGeometry(entry, runtime_arguments);
 555         const scalars = try imageLaunchScalars(entry, cached.artifact.format, geometry, runtime_arguments);
 556 
 557         try self.handle.launch(.{
 558             .artifact = &cached.artifact,
 559             .loaded_artifact = cached.loaded,
 560             .buffers = bindings,
 561             .scalar_arguments = scalars.slice(),
 562             .geometry = geometry,
 563             .diagnostic_id = diagnostic_id,
 564         });
 565     }
 566 
 567     fn cachedKernel(self: *Processor, key: ImageKernelKey, diagnostic_id: []const u8) !*CachedImageKernel {
 568         for (self.kernels.items) |*cached| {
 569             if (cached.key.eql(key)) return cached;
 570         }
 571 
 572         var descriptor = (try library.selectOwned(self.allocator, .{ .image = key.query() })) orelse return error.UnsupportedImageOperation;
 573         defer descriptor.deinit();
 574 
 575         var call_artifact = try library.createOwnedKernelCallArtifact(self.allocator, self.handle, descriptor, .{
 576             .limits = accy.kernel.Limits.standard,
 577             .format = self.format,
 578         });
 579         errdefer call_artifact.deinit();
 580 
 581         var artifact = try accy.kernel.createBackendArtifactFromKernelCallEntry(
 582             self.allocator,
 583             self.handle,
 584             call_artifact.entry(),
 585             diagnostic_id,
 586         );
 587         errdefer artifact.deinit();
 588 
 589         const loaded = try self.handle.loadArtifact(&artifact);
 590         errdefer self.handle.destroyObject(loaded.id);
 591 
 592         try self.kernels.append(self.allocator, .{
 593             .key = key,
 594             .call_artifact = call_artifact,
 595             .artifact = artifact,
 596             .loaded = loaded,
 597         });
 598         return &self.kernels.items[self.kernels.items.len - 1];
 599     }
 600 
 601     fn useShadowAlphaCache(self: *Processor, key: ShadowAlphaKey, dst: []u32) bool {
 602         if (self.shadow_alpha) |*cached| {
 603             if (cached.matches(key, dst.len)) {
 604                 @memcpy(dst, cached.pixels);
 605                 return true;
 606             }
 607         }
 608         return false;
 609     }
 610 
 611     fn storeShadowAlpha(self: *Processor, key: ShadowAlphaKey, pixels: []const u32) !void {
 612         if (self.shadow_alpha) |*cached| {
 613             if (cached.pixels.len == pixels.len) {
 614                 cached.key = key;
 615                 @memcpy(cached.pixels, pixels);
 616                 return;
 617             }
 618             cached.deinit(self.allocator);
 619             self.shadow_alpha = null;
 620         }
 621         const copy = try self.allocator.dupe(u32, pixels);
 622         self.shadow_alpha = .{
 623             .key = key,
 624             .pixels = copy,
 625         };
 626     }
 627 };
 628 
 629 const CachedShadowAlpha = struct {
 630     key: ShadowAlphaKey,
 631     pixels: []u32,
 632 
 633     fn deinit(self: *CachedShadowAlpha, allocator: Allocator) void {
 634         allocator.free(self.pixels);
 635         self.* = undefined;
 636     }
 637 
 638     fn matches(self: *const CachedShadowAlpha, key: ShadowAlphaKey, pixel_count: usize) bool {
 639         return self.pixels.len == pixel_count and self.key.eql(key);
 640     }
 641 };
 642 
 643 const ImageKernelKey = union(enum) {
 644     blur_pass: struct {
 645         width: u32,
 646         height: u32,
 647         radius: u32,
 648         axis: Axis,
 649         threads: ImageThreads,
 650     },
 651     resize_bilinear: struct {
 652         dst_width: u32,
 653         dst_height: u32,
 654         src_width: u32,
 655         src_height: u32,
 656         threads: ImageThreads,
 657     },
 658 
 659     fn eql(self: ImageKernelKey, other: ImageKernelKey) bool {
 660         return switch (self) {
 661             .blur_pass => |lhs| switch (other) {
 662                 .blur_pass => |rhs| lhs.width == rhs.width and
 663                     lhs.height == rhs.height and
 664                     lhs.radius == rhs.radius and
 665                     lhs.axis == rhs.axis and
 666                     threadsEql(lhs.threads, rhs.threads),
 667                 else => false,
 668             },
 669             .resize_bilinear => |lhs| switch (other) {
 670                 .resize_bilinear => |rhs| lhs.dst_width == rhs.dst_width and
 671                     lhs.dst_height == rhs.dst_height and
 672                     lhs.src_width == rhs.src_width and
 673                     lhs.src_height == rhs.src_height and
 674                     threadsEql(lhs.threads, rhs.threads),
 675                 else => false,
 676             },
 677         };
 678     }
 679 
 680     fn query(self: ImageKernelKey) library.ImageQuery {
 681         return switch (self) {
 682             .blur_pass => |key| .{
 683                 .dtype = .u32,
 684                 .kind = .{ .blur_pass = .{ .radius = key.radius, .axis = key.axis } },
 685                 .width = key.width,
 686                 .height = key.height,
 687                 .schedule = .{ .thread_blocks = key.threads },
 688             },
 689             .resize_bilinear => |key| .{
 690                 .dtype = .u32,
 691                 .kind = .{ .resize_bilinear = .{ .src_width = key.src_width, .src_height = key.src_height } },
 692                 .width = key.dst_width,
 693                 .height = key.dst_height,
 694                 .schedule = .{ .thread_blocks = key.threads },
 695             },
 696         };
 697     }
 698 };
 699 
 700 const CachedImageKernel = struct {
 701     key: ImageKernelKey,
 702     call_artifact: accy.kernel.OwnedKernelCallArtifact,
 703     artifact: gpu.KernelArtifact,
 704     loaded: gpu.LoadedArtifact,
 705 
 706     fn deinit(self: *CachedImageKernel, handle: gpu.BackendHandle) void {
 707         handle.destroyObject(self.loaded.id);
 708         self.artifact.deinit();
 709         self.call_artifact.deinit();
 710         self.* = undefined;
 711     }
 712 };
 713 
 714 pub const ImageHostStorage = struct {
 715     pub const Limits = ImageLimits;
 716     pub const Capacity = ImageCapacity;
 717 
 718     pub const claim: alloc_phase.capacity.Declaration = .{
 719         .source = .{
 720             .id = "gui.paint_image_host_storage",
 721             .kind = .phase_static,
 722             .limit_source = .caller,
 723             .storage = .{
 724                 .covered = &.{
 725                     .{
 726                         .id = "caller_sized_image_readback_staging",
 727                         .lifetime = .steady,
 728                         .detail = "caller-sized image readback staging",
 729                     },
 730                     .{
 731                         .id = "caller_sized_gaussian_weight_staging",
 732                         .lifetime = .steady,
 733                         .detail = "caller-sized Gaussian weight staging",
 734                     },
 735                 },
 736                 .excluded = &.{
 737                     "destination source scratch and weight backend buffers and their foreign allocation",
 738                     "caller-owned source destination and allocating result pixels",
 739                     "shadow mask output expansion and retained alpha cache pixels",
 740                     "compiled loaded and indexed image-family kernel cache artifacts",
 741                     "transactional complete-epoch replacement by Processor",
 742                 },
 743             },
 744             .capacity = .{
 745                 .inputs = &.{
 746                     alloc_phase.capacity.bindInput(ImageLimits, "dst_pixels", "dst_pixels"),
 747                     alloc_phase.capacity.bindInput(ImageLimits, "weight_taps", "weight_taps"),
 748                 },
 749                 .type_selectors = &.{},
 750                 .nodes = &.{
 751                     .{ .input = 0 },
 752                     .{ .input = 1 },
 753                     .{ .add = .{ .left = 0, .right = 1 } },
 754                     .{ .scale = .{ .node = 2, .coefficient = .{ .literal = 4 } } },
 755                 },
 756                 .assertions = &.{.{
 757                     .scope = .closure_total,
 758                     .measure = .retained,
 759                     .relation = .exact,
 760                     .expression = 3,
 761                 }},
 762             },
 763             .overload = .{
 764                 .kind = .reject_before_seal,
 765                 .detail = "checked capacity derivation and allocation failure reject before host storage activation; Processor may acquire a separate larger epoch",
 766             },
 767             .risks = .{
 768                 .transitive = .{
 769                     .status = .open,
 770                     .detail = "weight generation readback and backend transfer helpers are exercised but lack a machine-checked call-graph closure certificate",
 771                 },
 772                 .foreign = .{
 773                     .status = .open,
 774                     .detail = "backend buffer acquisition is capacity-accounted by Processor but remains outside this host-storage claim",
 775                 },
 776             },
 777             .obligations = &.{
 778                 .{ .key = "gui_paint_image_capacity_capacity_model", .role = .capacity_model },
 779                 .{ .key = "gui_paint_image_capacity_overload", .role = .overload },
 780                 .{ .key = "gui_paint_image_acquisition", .role = .custom },
 781                 .{ .key = "gui_paint_image_host_oom", .role = .overload },
 782                 .{ .key = "gui_paint_image_boundary", .role = .custom },
 783                 .{ .key = "gui_paint_image_atomic", .role = .custom },
 784                 .{ .key = "gui_paint_image_steady", .role = .custom },
 785                 .{ .key = "gui_paint_image_scalars", .role = .custom },
 786             },
 787         },
 788         .bindings = .{
 789             .owner = @This(),
 790             .seal = .{
 791                 .family = alloc_phase.capacity.selector(@This().activate),
 792                 .premise = .{
 793                     .class = .checked_semantic_fact,
 794                     .authority = .checker,
 795                 },
 796             },
 797             .teardown = .{
 798                 .family = alloc_phase.capacity.selector(@This().deinit),
 799                 .premise = .{
 800                     .class = .checked_semantic_fact,
 801                     .authority = .checker,
 802                 },
 803             },
 804         },
 805     };
 806 
 807     phase: alloc_phase.capacity.Phase,
 808     capacity: ImageCapacity,
 809     limits: ImageLimits,
 810     bytes: []align(@alignOf(u32)) u8,
 811 
 812     pub fn init(allocator: Allocator, limits: ImageLimits) !ImageHostStorage {
 813         const capacity = try ImageCapacity.derive(limits);
 814         const bytes = if (capacity.host_storage_bytes == 0)
 815             @as([]align(@alignOf(u32)) u8, &.{})
 816         else
 817             try allocator.alignedAlloc(
 818                 u8,
 819                 .fromByteUnits(@alignOf(u32)),
 820                 capacity.host_storage_bytes,
 821             );
 822         return .{
 823             .phase = .initialization,
 824             .capacity = capacity,
 825             .limits = limits,
 826             .bytes = bytes,
 827         };
 828     }
 829 
 830     pub fn activate(self: *ImageHostStorage) void {
 831         std.debug.assert(self.phase == .initialization);
 832         std.debug.assert(std.meta.eql(self.capacity.limits, self.limits));
 833         std.debug.assert(self.bytes.len == self.capacity.host_storage_bytes);
 834         self.phase = .steady;
 835     }
 836 
 837     pub fn deinit(self: *ImageHostStorage, allocator: Allocator) void {
 838         std.debug.assert(self.phase != .teardown);
 839         self.phase = .teardown;
 840         allocator.free(self.bytes);
 841         self.* = undefined;
 842     }
 843 };
 844 
 845 comptime {
 846     alloc_phase.capacity.requireAllocatorExactOwnerShape(ImageHostStorage);
 847 }
 848 
 849 const ImageBuffers = struct {
 850     capacity: ?Capacity = null,
 851     host: ?ImageHostStorage = null,
 852     dst: ?RetainedDeviceBuffer = null,
 853     src: ?RetainedDeviceBuffer = null,
 854     scratch: ?RetainedDeviceBuffer = null,
 855     weights: ?RetainedDeviceBuffer = null,
 856     weights_valid: bool = false,
 857     weights_radius: u32 = 0,
 858     weights_sigma: f32 = 0,
 859     readback: []u32 = &.{},
 860     weight_staging: []f32 = &.{},
 861 
 862     fn init(allocator: Allocator, handle: gpu.BackendHandle, limits: Limits) !ImageBuffers {
 863         var host_storage = try ImageHostStorage.init(allocator, limits);
 864         errdefer host_storage.deinit(allocator);
 865         const capacity = host_storage.capacity;
 866         var host = ImageHostCursor{ .bytes = host_storage.bytes };
 867         const readback = host.take(u32, limits.dst_pixels);
 868         const weight_staging = host.take(f32, limits.weight_taps);
 869         std.debug.assert(host.offset == host_storage.bytes.len);
 870 
 871         var dst: ?RetainedDeviceBuffer = null;
 872         errdefer if (dst) |*buffer| buffer.deinit(handle);
 873         var src: ?RetainedDeviceBuffer = null;
 874         errdefer if (src) |*buffer| buffer.deinit(handle);
 875         var scratch: ?RetainedDeviceBuffer = null;
 876         errdefer if (scratch) |*buffer| buffer.deinit(handle);
 877         var weights: ?RetainedDeviceBuffer = null;
 878         errdefer if (weights) |*buffer| buffer.deinit(handle);
 879         if (limits.dst_pixels != 0) dst = try retainedDeviceBuffer(handle, u32, .u32, limits.dst_pixels);
 880         if (limits.src_pixels != 0) src = try retainedDeviceBuffer(handle, u32, .u32, limits.src_pixels);
 881         if (limits.scratch_pixels != 0) scratch = try retainedDeviceBuffer(handle, u32, .u32, limits.scratch_pixels);
 882         if (limits.weight_taps != 0) weights = try retainedDeviceBuffer(handle, f32, .f32, limits.weight_taps);
 883         host_storage.activate();
 884         return .{
 885             .capacity = capacity,
 886             .host = host_storage,
 887             .dst = dst,
 888             .src = src,
 889             .scratch = scratch,
 890             .weights = weights,
 891             .readback = readback,
 892             .weight_staging = weight_staging,
 893         };
 894     }
 895 
 896     fn deinit(self: *ImageBuffers, allocator: Allocator, handle: gpu.BackendHandle) void {
 897         if (self.dst) |*buffer| buffer.deinit(handle);
 898         if (self.src) |*buffer| buffer.deinit(handle);
 899         if (self.scratch) |*buffer| buffer.deinit(handle);
 900         if (self.weights) |*buffer| buffer.deinit(handle);
 901         if (self.host) |*host| host.deinit(allocator);
 902         self.* = .{};
 903     }
 904 
 905     fn ensureWeightsData(
 906         self: *ImageBuffers,
 907         handle: gpu.BackendHandle,
 908         radius: u32,
 909         sigma: f32,
 910     ) !gpu.BufferHandle {
 911         _ = try blurTapCount(radius);
 912         const weights = self.weights orelse return error.BufferTooSmall;
 913         if (self.weights_valid and self.weights_radius == radius and self.weights_sigma == sigma) {
 914             return weights.handle;
 915         }
 916 
 917         self.weights_valid = false;
 918         const weights_data = try image_library.gaussianWeights(self.weight_staging, radius, sigma);
 919         try handle.writeBuffer(.{
 920             .handle = weights.handle,
 921             .bytes = std.mem.sliceAsBytes(weights_data),
 922         });
 923         self.weights_valid = true;
 924         self.weights_radius = radius;
 925         self.weights_sigma = sigma;
 926         return weights.handle;
 927     }
 928 
 929     fn readPixels(
 930         self: *ImageBuffers,
 931         handle: gpu.BackendHandle,
 932         source: gpu.BufferHandle,
 933         dst: []u32,
 934         count: usize,
 935     ) !void {
 936         const retained_count = source.byte_size / @sizeOf(u32);
 937         if (self.readback.len < retained_count) return error.BufferTooSmall;
 938         try handle.readBuffer(.{
 939             .handle = source,
 940             .bytes = std.mem.sliceAsBytes(self.readback[0..retained_count]),
 941         });
 942         @memcpy(dst[0..count], self.readback[0..count]);
 943     }
 944 };
 945 
 946 const ImageHostCursor = struct {
 947     bytes: []align(@alignOf(u32)) u8,
 948     offset: usize = 0,
 949 
 950     fn take(self: *ImageHostCursor, comptime T: type, count: usize) []T {
 951         comptime std.debug.assert(@sizeOf(T) == @sizeOf(u32));
 952         comptime std.debug.assert(@alignOf(T) <= @alignOf(u32));
 953         const byte_count = count * @sizeOf(T);
 954         std.debug.assert(self.offset + byte_count <= self.bytes.len);
 955         const pointer: [*]T = @ptrCast(@alignCast(self.bytes.ptr + self.offset));
 956         self.offset += byte_count;
 957         return pointer[0..count];
 958     }
 959 };
 960 
 961 const RetainedDeviceBuffer = struct {
 962     handle: gpu.BufferHandle,
 963     element_count: usize,
 964 
 965     fn deinit(self: *RetainedDeviceBuffer, handle: gpu.BackendHandle) void {
 966         handle.destroyObject(self.handle.id);
 967         self.* = undefined;
 968     }
 969 };
 970 
 971 fn retainedDeviceBuffer(
 972     handle: gpu.BackendHandle,
 973     comptime T: type,
 974     dtype: choir_abi.DType,
 975     count: usize,
 976 ) !RetainedDeviceBuffer {
 977     const next = try allocateDeviceBuffer(handle, T, dtype, count);
 978     return .{
 979         .handle = next,
 980         .element_count = count,
 981     };
 982 }
 983 
 984 fn threadsEql(lhs: ImageThreads, rhs: ImageThreads) bool {
 985     return lhs.x == rhs.x and lhs.y == rhs.y;
 986 }
 987 
 988 fn countShadows(commands: []const command.Command) usize {
 989     var count: usize = 0;
 990     for (commands) |paint| {
 991         if (paint.kind == .shadow) count += 1;
 992     }
 993     return count;
 994 }
 995 
 996 const ShadowPlan = struct {
 997     command: command.Command,
 998     frame: ShadowFrame,
 999     pixels: usize,
1000     width: u32,
1001     height: u32,
1002     blur_radius: u32,
1003     sigma: f32,
1004     key: ShadowAlphaKey,
1005 
1006     fn shadow(self: ShadowPlan, pixels: []u32) Shadow {
1007         return .{
1008             .command = self.command,
1009             .image = .{
1010                 .width = self.width,
1011                 .height = self.height,
1012                 .pixels = pixels,
1013             },
1014             .pixels = pixels,
1015         };
1016     }
1017 };
1018 
1019 const PreparedShadow = struct {
1020     plan: ShadowPlan,
1021     mask: []u32,
1022 
1023     fn deinitMask(self: *const PreparedShadow, allocator: Allocator) void {
1024         allocator.free(self.mask);
1025     }
1026 
1027     fn deinit(self: PreparedShadow, allocator: Allocator) void {
1028         allocator.free(self.mask);
1029     }
1030 
1031     fn shadow(self: PreparedShadow, pixels: []u32) Shadow {
1032         return self.plan.shadow(pixels);
1033     }
1034 };
1035 
1036 fn defaultFormat(handle: gpu.BackendHandle) !gpu.ArtifactFormat {
1037     const kind = handle.backendKind() orelse (try handle.queryCapabilities()).identity.backend;
1038     return switch (kind) {
1039         .cuda => .cuda_ptx,
1040         .vulkan => .vulkan_spirv,
1041         .metal => .metal_msl,
1042         .webgpu => .webgpu_wgsl,
1043         .cpu => .cpu_object,
1044         .wasm => .webassembly_module,
1045         .external => error.UnsupportedArtifactFormat,
1046     };
1047 }
1048 
1049 fn planShadow(paint: command.Command, options: ShadowOptions) !ShadowPlan {
1050     if (paint.kind != .shadow) return error.InvalidShadowCommand;
1051     const blur_radius = try shadowBlurRadius(paint.width);
1052     const sigma = try shadowSigma(blur_radius, options.sigma);
1053     const frame = try shadowFrame(paint, blur_radius);
1054     const pixels = try pixelCount(frame.width, frame.height);
1055     return .{
1056         .command = shadowImageCommand(paint, frame, options.image_index),
1057         .frame = frame,
1058         .pixels = pixels,
1059         .width = frame.width,
1060         .height = frame.height,
1061         .blur_radius = blur_radius,
1062         .sigma = sigma,
1063         .key = ShadowAlphaKey.init(paint, frame, blur_radius, sigma),
1064     };
1065 }
1066 
1067 fn prepareShadow(allocator: Allocator, paint: command.Command, options: ShadowOptions) !PreparedShadow {
1068     const plan = try planShadow(paint, options);
1069     const mask = try allocator.alloc(u32, plan.pixels);
1070     errdefer allocator.free(mask);
1071     rasterShadowMask(mask, plan.frame, paint);
1072     return .{
1073         .plan = plan,
1074         .mask = mask,
1075     };
1076 }
1077 
1078 const ShadowFrame = struct {
1079     rect: Rect,
1080     width: u32,
1081     height: u32,
1082 };
1083 
1084 const ShadowAlphaKey = struct {
1085     paint_rect: Rect,
1086     frame_rect: Rect,
1087     radius: f32,
1088     alpha: u8,
1089     width: u32,
1090     height: u32,
1091     blur_radius: u32,
1092     sigma: f32,
1093 
1094     fn init(paint: command.Command, frame: ShadowFrame, blur_radius: u32, sigma: f32) ShadowAlphaKey {
1095         return .{
1096             .paint_rect = paint.rect,
1097             .frame_rect = frame.rect,
1098             .radius = paint.radius,
1099             .alpha = paint.color.a,
1100             .width = frame.width,
1101             .height = frame.height,
1102             .blur_radius = blur_radius,
1103             .sigma = sigma,
1104         };
1105     }
1106 
1107     fn eql(self: ShadowAlphaKey, other: ShadowAlphaKey) bool {
1108         return rectEqual(self.paint_rect, other.paint_rect) and
1109             rectEqual(self.frame_rect, other.frame_rect) and
1110             self.radius == other.radius and
1111             self.alpha == other.alpha and
1112             self.width == other.width and
1113             self.height == other.height and
1114             self.blur_radius == other.blur_radius and
1115             self.sigma == other.sigma;
1116     }
1117 };
1118 
1119 fn rectEqual(left: Rect, right: Rect) bool {
1120     return left.x == right.x and
1121         left.y == right.y and
1122         left.width == right.width and
1123         left.height == right.height;
1124 }
1125 
1126 fn shadowFrame(paint: command.Command, blur_radius: u32) !ShadowFrame {
1127     if (!shadowRectFinite(paint.rect) or !shadowRectFinite(paint.clip)) return error.InvalidShadowCommand;
1128     if (paint.rect.width <= 0 or paint.rect.height <= 0) return error.InvalidShadowCommand;
1129     const margin: f32 = @floatFromInt(blur_radius);
1130     const rect = Rect{
1131         .x = paint.rect.x - margin,
1132         .y = paint.rect.y - margin,
1133         .width = paint.rect.width + margin * 2,
1134         .height = paint.rect.height + margin * 2,
1135     };
1136     return .{
1137         .rect = rect,
1138         .width = try extentFromFloat(rect.width),
1139         .height = try extentFromFloat(rect.height),
1140     };
1141 }
1142 
1143 fn shadowRectFinite(rect: Rect) bool {
1144     return std.math.isFinite(rect.x) and
1145         std.math.isFinite(rect.y) and
1146         std.math.isFinite(rect.width) and
1147         std.math.isFinite(rect.height);
1148 }
1149 
1150 fn extentFromFloat(value: f32) !u32 {
1151     if (!std.math.isFinite(value) or value <= 0) return error.InvalidImage;
1152     const ceiled = @ceil(value);
1153     if (ceiled > @as(f32, @floatFromInt(std.math.maxInt(u32)))) return error.DimensionsTooLarge;
1154     return @intFromFloat(ceiled);
1155 }
1156 
1157 fn shadowBlurRadius(width: f32) !u32 {
1158     if (std.math.isNan(width)) return error.InvalidShadowCommand;
1159     if (width <= 0) return 0;
1160     if (!std.math.isFinite(width)) return error.InvalidShadowCommand;
1161     const ceiled = @ceil(width);
1162     if (ceiled > @as(f32, @floatFromInt(image_library.blur_radius_max))) return error.UnsupportedImageOperation;
1163     return @intFromFloat(ceiled);
1164 }
1165 
1166 fn shadowSigma(radius: u32, override: ?f32) !f32 {
1167     if (override) |value| {
1168         if (!(value > 0) or !std.math.isFinite(value)) return error.UnsupportedImageOperation;
1169         return value;
1170     }
1171     if (radius == 0) return 1;
1172     return @max(@as(f32, @floatFromInt(radius)) * 0.5, @as(f32, 0.5));
1173 }
1174 
1175 fn rasterShadowMask(dst: []u32, frame: ShadowFrame, paint: command.Command) void {
1176     var y: u32 = 0;
1177     while (y < frame.height) : (y += 1) {
1178         var x: u32 = 0;
1179         while (x < frame.width) : (x += 1) {
1180             var coverage: u32 = 0;
1181             inline for (.{ -0.25, 0.25 }) |dy| {
1182                 inline for (.{ -0.25, 0.25 }) |dx| {
1183                     const px = frame.rect.x + @as(f32, @floatFromInt(x)) + 0.5 + dx;
1184                     const py = frame.rect.y + @as(f32, @floatFromInt(y)) + 0.5 + dy;
1185                     if (cpu.geometry.insideRounded(paint.rect, @max(paint.radius, 0), px, py)) coverage += 1;
1186                 }
1187             }
1188             const alpha = cpu.pixel.coverageAlpha(paint.color.a, coverage);
1189             dst[@as(usize, y) * frame.width + x] = cpu.pixel.packRgba(.{ .a = alpha });
1190         }
1191     }
1192 }
1193 
1194 fn tintShadowPixels(pixels: []u32, color: Color) void {
1195     for (pixels) |*pixel_value| {
1196         const alpha: u8 = @truncate(pixel_value.* >> 24);
1197         pixel_value.* = cpu.pixel.packRgba(.{
1198             .r = color.r,
1199             .g = color.g,
1200             .b = color.b,
1201             .a = alpha,
1202         });
1203     }
1204 }
1205 
1206 fn shadowImageCommand(paint: command.Command, frame: ShadowFrame, image_index: u32) command.Command {
1207     return .{
1208         .kind = .image,
1209         .rect = .{
1210             .x = frame.rect.x,
1211             .y = frame.rect.y,
1212             .width = @floatFromInt(frame.width),
1213             .height = @floatFromInt(frame.height),
1214         },
1215         .clip = paint.clip,
1216         .color = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
1217         .image_index = image_index,
1218         .order = paint.order,
1219     };
1220 }
1221 
1222 fn validateImageSlices(dst: []u32, src: []const u32, width: u32, height: u32) !usize {
1223     const source = command.Image{ .width = width, .height = height, .pixels = src };
1224     try source.validate();
1225     const pixels = try pixelCount(width, height);
1226     if (dst.len < pixels) return error.BufferTooSmall;
1227     return pixels;
1228 }
1229 
1230 fn pixelCount(width: u32, height: u32) !usize {
1231     if (width == 0 or height == 0) return error.InvalidImage;
1232     return std.math.mul(usize, @as(usize, width), @as(usize, height)) catch return error.DimensionsTooLarge;
1233 }
1234 
1235 const ImageExtent = struct {
1236     width: u32,
1237     height: u32,
1238 };
1239 
1240 fn thumbnailExtent(
1241     src_width: u32,
1242     src_height: u32,
1243     max_width: u32,
1244     max_height: u32,
1245 ) !ImageExtent {
1246     if (src_width == 0 or src_height == 0 or max_width == 0 or max_height == 0) return error.InvalidImage;
1247     if (src_width <= max_width and src_height <= max_height) {
1248         return .{ .width = src_width, .height = src_height };
1249     }
1250     const width_limited = @as(u64, max_width) * @as(u64, src_height) <= @as(u64, max_height) * @as(u64, src_width);
1251     if (width_limited) {
1252         return .{
1253             .width = max_width,
1254             .height = scaledExtent(src_height, max_width, src_width),
1255         };
1256     }
1257     return .{
1258         .width = scaledExtent(src_width, max_height, src_height),
1259         .height = max_height,
1260     };
1261 }
1262 
1263 fn scaledExtent(value: u32, numerator: u32, denominator: u32) u32 {
1264     const scaled = (@as(u64, value) * @as(u64, numerator)) / @as(u64, denominator);
1265     return @intCast(@max(scaled, 1));
1266 }
1267 
1268 fn blurTapCount(radius: u32) !usize {
1269     if (radius == 0 or radius > image_library.blur_radius_max) return error.UnsupportedImageOperation;
1270     return @intCast(radius * 2 + 1);
1271 }
1272 
1273 fn blurRuntimeArguments(width: u32, height: u32) ![2]choir_abi.ScalarArgument {
1274     if (width == 0 or height == 0) return error.UnsupportedImageOperation;
1275     return .{
1276         .{ .u32 = width },
1277         .{ .u32 = height },
1278     };
1279 }
1280 
1281 fn resizeRuntimeArguments(dst_width: u32, dst_height: u32, src_width: u32, src_height: u32) ![7]choir_abi.ScalarArgument {
1282     if (dst_width == 0 or dst_height == 0 or src_width == 0 or src_height == 0) return error.UnsupportedImageOperation;
1283     return .{
1284         .{ .u32 = dst_width },
1285         .{ .u32 = dst_height },
1286         .{ .u32 = src_width },
1287         .{ .f32 = image_library.resizeScale(src_width, dst_width) },
1288         .{ .f32 = image_library.resizeScale(src_height, dst_height) },
1289         .{ .f32 = @floatFromInt(src_width - 1) },
1290         .{ .f32 = @floatFromInt(src_height - 1) },
1291     };
1292 }
1293 
1294 fn expectBufferSize(handle: gpu.BufferHandle, count: usize, element_size: usize) !void {
1295     const byte_size = std.math.mul(usize, count, element_size) catch return error.BufferTooLarge;
1296     if (handle.byte_size < byte_size) return error.BufferTooSmall;
1297 }
1298 
1299 fn allocateDeviceBuffer(handle: gpu.BackendHandle, comptime T: type, dtype: choir_abi.DType, count: usize) !gpu.BufferHandle {
1300     const byte_size = std.math.mul(usize, count, @sizeOf(T)) catch return error.BufferTooLarge;
1301     return handle.allocateBuffer(.{
1302         .byte_size = byte_size,
1303         .alignment = 256,
1304         .dtype = dtype,
1305         .element_count = std.math.cast(u64, count) orelse return error.BufferTooLarge,
1306     });
1307 }
1308 
1309 fn bufferBinding(handle: gpu.BufferHandle, access: gpu.BufferAccess) gpu.BufferBinding {
1310     return .{
1311         .handle = handle,
1312         .access = access,
1313         .ownership = handle.ownership,
1314         .byte_size = handle.byte_size,
1315     };
1316 }
1317 
1318 fn testPixel(seed: usize) u32 {
1319     var value: u32 = @truncate(seed *% 2654435761);
1320     value ^= value >> 13;
1321     value *%= 0x5bd1e995;
1322     value ^= value >> 15;
1323     return value;
1324 }
1325 
1326 fn referenceShadowAlloc(allocator: Allocator, paint: command.Command, options: ShadowOptions) !Shadow {
1327     const prepared = try prepareShadow(allocator, paint, options);
1328     var prepared_owned = true;
1329     defer if (prepared_owned) prepared.deinit(allocator);
1330 
1331     const pixels = try allocator.alloc(u32, prepared.plan.pixels);
1332     errdefer allocator.free(pixels);
1333     if (prepared.plan.blur_radius > 0) {
1334         const scratch = try allocator.alloc(u32, prepared.plan.pixels);
1335         defer allocator.free(scratch);
1336         const weights = try image_library.gaussianWeightsAlloc(allocator, prepared.plan.blur_radius, prepared.plan.sigma);
1337         defer allocator.free(weights);
1338         image_library.referenceBlurPass(scratch, prepared.mask, weights, prepared.plan.width, prepared.plan.height, .horizontal);
1339         image_library.referenceBlurPass(pixels, scratch, weights, prepared.plan.width, prepared.plan.height, .vertical);
1340     } else {
1341         @memcpy(pixels, prepared.mask);
1342     }
1343     tintShadowPixels(pixels, paint.color);
1344     const result = prepared.shadow(pixels);
1345     prepared_owned = false;
1346     prepared.deinitMask(allocator);
1347     return result;
1348 }
1349 
1350 test "paint image capacity matches an independent host and device byte model" {
1351     comptime {
1352         @stardustClaim(
1353             @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_capacity_capacity_model"),
1354             null,
1355             null,
1356             null,
1357             null,
1358             null,
1359             null,
1360         );
1361     }
1362     comptime {
1363         @stardustClaim(
1364             @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_capacity_overload"),
1365             null,
1366             null,
1367             null,
1368             null,
1369             null,
1370             null,
1371         );
1372     }
1373 
1374     const cases = [_]Limits{
1375         .{},
1376         .{ .dst_pixels = 4096, .src_pixels = 2048 },
1377         .{ .dst_pixels = 257, .src_pixels = 129, .scratch_pixels = 257, .weight_taps = 31 },
1378     };
1379     for (cases) |limits| {
1380         const capacity = try Capacity.derive(limits);
1381         const host_elements = @as(u128, limits.dst_pixels) + limits.weight_taps;
1382         const device_elements = @as(u128, limits.dst_pixels) + limits.src_pixels + limits.scratch_pixels + limits.weight_taps;
1383         const device_buffer_count = @as(usize, @intFromBool(limits.dst_pixels != 0)) +
1384             @as(usize, @intFromBool(limits.src_pixels != 0)) +
1385             @as(usize, @intFromBool(limits.scratch_pixels != 0)) +
1386             @as(usize, @intFromBool(limits.weight_taps != 0));
1387         try std.testing.expectEqual(@as(usize, @intCast(host_elements * @sizeOf(u32))), capacity.host_storage_bytes);
1388         try std.testing.expectEqual(@as(usize, @intCast(device_elements * @sizeOf(u32))), capacity.device_storage_bytes);
1389         try std.testing.expectEqual(@as(usize, device_buffer_count), capacity.device_buffer_count);
1390         try std.testing.expectEqual(
1391             capacity.host_storage_bytes + capacity.device_storage_bytes,
1392             capacity.total_storage_bytes,
1393         );
1394     }
1395 }
1396 
1397 test "paint image capacity rejects overflowing storage limits" {
1398     try std.testing.expectError(
1399         error.CapacityOverflow,
1400         Capacity.derive(.{
1401             .dst_pixels = std.math.maxInt(usize),
1402             .weight_taps = 1,
1403         }),
1404     );
1405 }
1406 
1407 test "paint image host storage rejects initialization OOM and seals on retry" {
1408     comptime {
1409         @stardustClaim(
1410             @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_host_oom"),
1411             null,
1412             null,
1413             null,
1414             null,
1415             null,
1416             null,
1417         );
1418     }
1419 
1420     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
1421     const limits = Limits{ .dst_pixels = 16, .weight_taps = 7 };
1422     failing.fail_index = failing.alloc_index;
1423     try std.testing.expectError(
1424         error.OutOfMemory,
1425         ImageHostStorage.init(failing.allocator(), limits),
1426     );
1427     try std.testing.expect(failing.has_induced_failure);
1428     failing.fail_index = std.math.maxInt(usize);
1429     var storage = try ImageHostStorage.init(failing.allocator(), limits);
1430     defer storage.deinit(failing.allocator());
1431     try std.testing.expectEqual(alloc_phase.capacity.Phase.initialization, storage.phase);
1432     storage.activate();
1433     try std.testing.expectEqual(alloc_phase.capacity.Phase.steady, storage.phase);
1434     try std.testing.expectEqual(storage.capacity.host_storage_bytes, storage.bytes.len);
1435 }
1436 
1437 test "paint image Processor initial storage exposes the exact chosen capacity" {
1438     comptime {
1439         @stardustClaim(
1440             @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_acquisition"),
1441             null,
1442             null,
1443             null,
1444             null,
1445             null,
1446             null,
1447         );
1448     }
1449 
1450     const allocator = std.testing.allocator;
1451     var state = gpu.recording.BackendState{
1452         .allocator = allocator,
1453         .kind = .vulkan,
1454         .format = .vulkan_spirv,
1455     };
1456     const limits = Limits{
1457         .dst_pixels = 64,
1458         .src_pixels = 32,
1459         .scratch_pixels = 64,
1460         .weight_taps = 7,
1461     };
1462     var processor = try Processor.init(allocator, state.handle(), .{
1463         .artifact_format = .vulkan_spirv,
1464         .initial_storage = limits,
1465     });
1466     defer processor.deinit();
1467     const status = processor.storageStatus();
1468     try std.testing.expectEqual(limits, status.limits.?);
1469     try std.testing.expectEqual(try Capacity.derive(limits), status.capacity.?);
1470     try std.testing.expectEqual(@as(usize, 0), status.replacements);
1471     try std.testing.expectEqual(status.capacity.?.host_storage_bytes, processor.buffers.host.?.bytes.len);
1472     try std.testing.expectEqual(status.capacity.?.device_buffer_count, state.buffer_allocate_count);
1473 }
1474 
1475 test "paint image Processor replaces storage at max plus one and retains its high water mark" {
1476     comptime {
1477         @stardustClaim(
1478             @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_boundary"),
1479             null,
1480             null,
1481             null,
1482             null,
1483             null,
1484             null,
1485         );
1486     }
1487 
1488     const allocator = std.testing.allocator;
1489     var state = gpu.recording.BackendState{
1490         .allocator = allocator,
1491         .kind = .vulkan,
1492         .format = .vulkan_spirv,
1493     };
1494     const limits = Limits{ .dst_pixels = 4, .src_pixels = 4 };
1495     var processor = try Processor.init(allocator, state.handle(), .{
1496         .artifact_format = .vulkan_spirv,
1497         .initial_storage = limits,
1498     });
1499     defer processor.deinit();
1500     var src = @as([4]u32, @splat(0));
1501     var dst = @as([6]u32, @splat(0));
1502     try processor.resizeBilinear(dst[0..4], src[0..], 2, 2, 2, 2);
1503     try std.testing.expectEqual(@as(usize, 0), processor.storageStatus().replacements);
1504     try processor.resizeBilinear(dst[0..], src[0..], 3, 2, 2, 2);
1505     const grown = processor.storageStatus();
1506     try std.testing.expectEqual(@as(usize, 1), grown.replacements);
1507     try std.testing.expectEqual(@as(usize, 6), grown.limits.?.dst_pixels);
1508     try std.testing.expectEqual(@as(usize, 4), grown.limits.?.src_pixels);
1509     try processor.resizeBilinear(dst[0..4], src[0..], 2, 2, 2, 2);
1510     try std.testing.expectEqual(grown, processor.storageStatus());
1511 }
1512 
1513 test "paint image Processor failed storage replacement preserves the prior epoch" {
1514     comptime {
1515         @stardustClaim(
1516             @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_atomic"),
1517             null,
1518             null,
1519             null,
1520             null,
1521             null,
1522             null,
1523         );
1524     }
1525 
1526     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
1527     var state = gpu.recording.BackendState{
1528         .allocator = failing.allocator(),
1529         .kind = .vulkan,
1530         .format = .vulkan_spirv,
1531     };
1532     var processor = try Processor.init(failing.allocator(), state.handle(), .{
1533         .artifact_format = .vulkan_spirv,
1534         .initial_storage = .{ .dst_pixels = 4, .src_pixels = 4 },
1535     });
1536     defer processor.deinit();
1537     const before = processor.storageStatus();
1538     const dst_id = processor.buffers.dst.?.handle.id;
1539     var src = @as([4]u32, @splat(0));
1540     var dst = @as([6]u32, @splat(0));
1541     failing.fail_index = failing.alloc_index;
1542     try std.testing.expectError(
1543         error.OutOfMemory,
1544         processor.resizeBilinear(dst[0..], src[0..], 3, 2, 2, 2),
1545     );
1546     try std.testing.expect(failing.has_induced_failure);
1547     try std.testing.expectEqual(before, processor.storageStatus());
1548     try std.testing.expectEqual(dst_id, processor.buffers.dst.?.handle.id);
1549     failing.fail_index = std.math.maxInt(usize);
1550     try processor.resizeBilinear(dst[0..4], src[0..], 2, 2, 2, 2);
1551 }
1552 
1553 test "paint image Processor cached admitted launch makes no allocator calls" {
1554     comptime {
1555         @stardustClaim(
1556             @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_steady"),
1557             null,
1558             null,
1559             null,
1560             null,
1561             null,
1562             null,
1563         );
1564     }
1565 
1566     var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
1567     var state = gpu.recording.BackendState{
1568         .allocator = failing.allocator(),
1569         .kind = .vulkan,
1570         .format = .vulkan_spirv,
1571     };
1572     var processor = try Processor.init(failing.allocator(), state.handle(), .{
1573         .artifact_format = .vulkan_spirv,
1574         .initial_storage = .{ .dst_pixels = 4, .src_pixels = 4 },
1575     });
1576     defer processor.deinit();
1577     var src = @as([4]u32, @splat(0));
1578     var dst = @as([4]u32, @splat(0));
1579     try processor.resizeBilinear(dst[0..], src[0..], 2, 2, 2, 2);
1580     failing.fail_index = failing.alloc_index;
1581     failing.resize_fail_index = failing.resize_index;
1582     try processor.resizeBilinear(dst[0..], src[0..], 2, 2, 2, 2);
1583     try std.testing.expect(!failing.has_induced_failure);
1584 }
1585 
1586 test "paint image Processor assembles maximum launch scalars in fixed local storage" {
1587     comptime {
1588         @stardustClaim(
1589             @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_scalars"),
1590             null,
1591             null,
1592             null,
1593             null,
1594             null,
1595             null,
1596         );
1597     }
1598 
1599     const runtime_arguments = [_]choir_abi.ScalarArgument{
1600         .{ .u32 = 1 },
1601         .{ .u32 = 2 },
1602         .{ .u32 = 3 },
1603         .{ .u32 = 4 },
1604         .{ .u32 = 5 },
1605         .{ .u32 = 6 },
1606         .{ .u32 = 7 },
1607     };
1608     const static_arguments = @as([host_loop_launch_shape_arg_count]choir_abi.ScalarArgument, @splat(.{ .u32 = 0 }));
1609     const entry = .{ .static_arguments = static_arguments[0..] };
1610     const geometry = choir_abi.LaunchGeometry{
1611         .grid = .{ 2, 1, 1 },
1612         .threadgroup = .{ 4, 1, 1 },
1613     };
1614     const scalars = try imageLaunchScalars(entry, .cpu_object, geometry, runtime_arguments[0..]);
1615     try std.testing.expectEqual(@as(usize, scalar_argument_count_max), scalars.count);
1616     try std.testing.expectEqualSlices(
1617         choir_abi.ScalarArgument,
1618         runtime_arguments[0..],
1619         scalars.storage[0..runtime_arguments.len],
1620     );
1621     try std.testing.expectEqual(@as(u32, 8), scalars.storage[runtime_arguments.len].u32);
1622 }
1623 
1624 test "paint image processor records image-family shadow blur passes" {
1625     const allocator = std.testing.allocator;
1626     var state = gpu.recording.BackendState{
1627         .allocator = allocator,
1628         .kind = .vulkan,
1629         .format = .vulkan_spirv,
1630     };
1631     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
1632     defer processor.deinit();
1633     const paint = command.Command{
1634         .kind = .shadow,
1635         .rect = .{ .x = 2, .y = 3, .width = 5, .height = 4 },
1636         .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1637         .color = .{ .r = 20, .g = 30, .b = 40, .a = 200 },
1638         .radius = 1,
1639         .width = 2,
1640         .order = 9,
1641     };
1642 
1643     var shadow = try processor.shadowAlloc(paint, .{ .image_index = 3 });
1644     defer shadow.deinit(allocator);
1645 
1646     try std.testing.expectEqual(command.Kind.image, shadow.command.kind);
1647     try std.testing.expectEqual(@as(u32, 3), shadow.command.image_index);
1648     try std.testing.expectEqual(@as(u32, 9), shadow.command.order);
1649     try std.testing.expectEqual(@as(u32, 9), shadow.image.width);
1650     try std.testing.expectEqual(@as(u32, 8), shadow.image.height);
1651     try std.testing.expectEqual(@as(usize, 2), state.launch_count);
1652     try std.testing.expectEqual(@as(usize, 2), state.load_count);
1653     try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1654     try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
1655     try std.testing.expectEqual(@as(usize, 2), state.write_count);
1656     try std.testing.expectEqual(@as(usize, 1), state.read_count);
1657     try std.testing.expectEqual(@as(usize, 3), state.last_launch_buffer_count);
1658     try std.testing.expectEqual(gpu.BufferAccess.read_write, state.last_buffer_access[0]);
1659     try std.testing.expectEqual(gpu.BufferAccess.read_only, state.last_buffer_access[1]);
1660     try std.testing.expectEqual(gpu.BufferAccess.read_only, state.last_buffer_access[2]);
1661     try std.testing.expectEqual(@as(usize, 2), state.last_launch_scalar_count);
1662     try std.testing.expectEqual(shadow.image.width, state.last_launch_scalar_u32_values[0]);
1663     try std.testing.expectEqual(shadow.image.height, state.last_launch_scalar_u32_values[1]);
1664 }
1665 
1666 test "paint image processor reuses image-family shadow alpha" {
1667     const allocator = std.testing.allocator;
1668     var state = gpu.recording.BackendState{
1669         .allocator = allocator,
1670         .kind = .vulkan,
1671         .format = .vulkan_spirv,
1672     };
1673     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
1674     defer processor.deinit();
1675     const paint = command.Command{
1676         .kind = .shadow,
1677         .rect = .{ .x = 2, .y = 3, .width = 5, .height = 4 },
1678         .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1679         .color = .{ .r = 20, .g = 30, .b = 40, .a = 200 },
1680         .radius = 1,
1681         .width = 2,
1682         .order = 9,
1683     };
1684 
1685     var first = try processor.shadowAlloc(paint, .{ .image_index = 3 });
1686     defer first.deinit(allocator);
1687     try std.testing.expectEqual(@as(usize, 2), state.load_count);
1688     try std.testing.expectEqual(@as(usize, 2), state.launch_count);
1689     try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1690     try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
1691     try std.testing.expectEqual(@as(usize, 2), state.write_count);
1692     try std.testing.expectEqual(@as(usize, 1), state.read_count);
1693 
1694     var second = try processor.shadowAlloc(paint, .{ .image_index = 4 });
1695     defer second.deinit(allocator);
1696     try std.testing.expectEqual(@as(usize, 2), state.load_count);
1697     try std.testing.expectEqual(@as(usize, 2), state.launch_count);
1698     try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1699     try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
1700     try std.testing.expectEqual(@as(usize, 2), state.write_count);
1701     try std.testing.expectEqual(@as(usize, 1), state.read_count);
1702 
1703     var changed_rgb = paint;
1704     changed_rgb.color.r = 21;
1705     var third = try processor.shadowAlloc(changed_rgb, .{ .image_index = 5 });
1706     defer third.deinit(allocator);
1707     try std.testing.expectEqual(@as(u32, 5), third.command.image_index);
1708     try std.testing.expectEqual(@as(usize, 2), state.load_count);
1709     try std.testing.expectEqual(@as(usize, 2), state.launch_count);
1710     try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1711     try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
1712     try std.testing.expectEqual(@as(usize, 2), state.write_count);
1713     try std.testing.expectEqual(@as(usize, 1), state.read_count);
1714 
1715     var changed_alpha = paint;
1716     changed_alpha.color.a = 201;
1717     var fourth = try processor.shadowAlloc(changed_alpha, .{ .image_index = 6 });
1718     defer fourth.deinit(allocator);
1719     try std.testing.expectEqual(@as(usize, 2), state.load_count);
1720     try std.testing.expectEqual(@as(usize, 4), state.launch_count);
1721     try std.testing.expectEqual(@as(usize, 2), state.sync_count);
1722     try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
1723     try std.testing.expectEqual(@as(usize, 3), state.write_count);
1724     try std.testing.expectEqual(@as(usize, 2), state.read_count);
1725 
1726     var changed_width = paint;
1727     changed_width.width = 3;
1728     var fifth = try processor.shadowAlloc(changed_width, .{ .image_index = 7 });
1729     defer fifth.deinit(allocator);
1730     try std.testing.expectEqual(@as(usize, 4), state.load_count);
1731     try std.testing.expectEqual(@as(usize, 6), state.launch_count);
1732     try std.testing.expectEqual(@as(usize, 3), state.sync_count);
1733     try std.testing.expectEqual(@as(usize, 8), state.buffer_allocate_count);
1734     try std.testing.expectEqual(@as(usize, 5), state.write_count);
1735     try std.testing.expectEqual(@as(usize, 3), state.read_count);
1736 }
1737 
1738 test "paint image processor shadow image matches reference on cpu object" {
1739     const allocator = std.testing.allocator;
1740     var state = gpu.cpu.State.init(allocator);
1741     defer state.deinit();
1742     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
1743     defer processor.deinit();
1744     const paint = command.Command{
1745         .kind = .shadow,
1746         .rect = .{ .x = 2, .y = 3, .width = 5, .height = 4 },
1747         .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1748         .color = .{ .r = 20, .g = 30, .b = 40, .a = 200 },
1749         .radius = 1,
1750         .width = 2,
1751         .order = 7,
1752     };
1753 
1754     var expected = try referenceShadowAlloc(allocator, paint, .{ .image_index = 4 });
1755     defer expected.deinit(allocator);
1756     var actual = try processor.shadowAlloc(paint, .{ .image_index = 4 });
1757     defer actual.deinit(allocator);
1758 
1759     try std.testing.expectEqual(expected.command.kind, actual.command.kind);
1760     try std.testing.expectEqual(expected.command.image_index, actual.command.image_index);
1761     try std.testing.expectEqual(expected.command.order, actual.command.order);
1762     try std.testing.expectEqual(expected.image.width, actual.image.width);
1763     try std.testing.expectEqual(expected.image.height, actual.image.height);
1764     try std.testing.expectEqualSlices(u32, expected.pixels, actual.pixels);
1765     const center = actual.pixels[@as(usize, actual.image.width) * 3 + 4];
1766     const center_color = cpu.pixel.unpackRgba(center);
1767     try std.testing.expectEqual(@as(u8, 20), center_color.r);
1768     try std.testing.expectEqual(@as(u8, 30), center_color.g);
1769     try std.testing.expectEqual(@as(u8, 40), center_color.b);
1770     try std.testing.expect(center_color.a > 0);
1771 
1772     var changed_rgb = paint;
1773     changed_rgb.color.r = 90;
1774     var expected_changed = try referenceShadowAlloc(allocator, changed_rgb, .{ .image_index = 5 });
1775     defer expected_changed.deinit(allocator);
1776     var actual_changed = try processor.shadowAlloc(changed_rgb, .{ .image_index = 5 });
1777     defer actual_changed.deinit(allocator);
1778 
1779     try std.testing.expectEqualSlices(u32, expected_changed.pixels, actual_changed.pixels);
1780     const changed_center = actual_changed.pixels[@as(usize, actual_changed.image.width) * 3 + 4];
1781     const changed_color = cpu.pixel.unpackRgba(changed_center);
1782     try std.testing.expectEqual(@as(u8, 90), changed_color.r);
1783     try std.testing.expectEqual(@as(u8, 30), changed_color.g);
1784     try std.testing.expectEqual(center_color.a, changed_color.a);
1785 }
1786 
1787 test "paint image processor expands shadow commands into appended images" {
1788     const allocator = std.testing.allocator;
1789     var state = gpu.cpu.State.init(allocator);
1790     defer state.deinit();
1791     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
1792     defer processor.deinit();
1793 
1794     const base_pixels = [_]u32{
1795         cpu.pixel.packRgba(.{ .r = 255, .a = 255 }),
1796         cpu.pixel.packRgba(.{ .g = 255, .a = 255 }),
1797         cpu.pixel.packRgba(.{ .b = 255, .a = 255 }),
1798         cpu.pixel.packRgba(.{ .r = 255, .g = 255, .a = 255 }),
1799     };
1800     const images = command.ImageSet{ .images = &.{.{
1801         .width = 2,
1802         .height = 2,
1803         .pixels = base_pixels[0..],
1804     }} };
1805     const commands = [_]command.Command{
1806         .{
1807             .kind = .fill,
1808             .rect = .{ .x = 0, .y = 0, .width = 3, .height = 3 },
1809             .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1810             .color = .{ .r = 200, .a = 255 },
1811             .order = 0,
1812         },
1813         .{
1814             .kind = .shadow,
1815             .rect = .{ .x = 3, .y = 4, .width = 5, .height = 4 },
1816             .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1817             .color = .{ .r = 20, .g = 30, .b = 40, .a = 200 },
1818             .radius = 1,
1819             .width = 2,
1820             .order = 2,
1821         },
1822         .{
1823             .kind = .image,
1824             .rect = .{ .x = 8, .y = 1, .width = 2, .height = 2 },
1825             .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1826             .color = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
1827             .image_index = 0,
1828             .order = 1,
1829         },
1830     };
1831 
1832     var expansion = try processor.expandShadowsAlloc(commands[0..], images, .{});
1833     defer expansion.deinit();
1834     const expanded_images = expansion.imageSet();
1835 
1836     try std.testing.expectEqual(commands.len, expansion.commands.len);
1837     try std.testing.expectEqual(@as(usize, 2), expanded_images.images.len);
1838     try std.testing.expectEqual(command.Kind.fill, expansion.commands[0].kind);
1839     try std.testing.expectEqual(command.Kind.image, expansion.commands[1].kind);
1840     try std.testing.expectEqual(command.Kind.image, expansion.commands[2].kind);
1841     try std.testing.expectEqual(@as(u32, 1), expansion.commands[1].image_index);
1842     try std.testing.expectEqual(@as(u32, 0), expansion.commands[2].image_index);
1843     try std.testing.expectEqual(@as(u32, 2), expansion.commands[1].order);
1844     try std.testing.expectEqualSlices(u32, base_pixels[0..], expanded_images.images[0].pixels);
1845 
1846     var expected = try referenceShadowAlloc(allocator, commands[1], .{ .image_index = 1 });
1847     defer expected.deinit(allocator);
1848     try std.testing.expectEqual(expected.command.kind, expansion.commands[1].kind);
1849     try std.testing.expectEqual(expected.command.image_index, expansion.commands[1].image_index);
1850     try std.testing.expectEqual(expected.image.width, expanded_images.images[1].width);
1851     try std.testing.expectEqual(expected.image.height, expanded_images.images[1].height);
1852     try std.testing.expectEqualSlices(u32, expected.pixels, expanded_images.images[1].pixels);
1853 
1854     var target_pixels = @as([(16 * 16)]u32, @splat(0));
1855     try cpu.renderCommandsPackedWithImages(expansion.commands, .{
1856         .width = 16,
1857         .height = 16,
1858         .pixels = target_pixels[0..],
1859     }, .{ .a = 0 }, expanded_images);
1860     try std.testing.expect(target_pixels[4 * 16 + 4] != 0);
1861 }
1862 
1863 test "paint image processor records shadow command expansion launches" {
1864     const allocator = std.testing.allocator;
1865     var state = gpu.recording.BackendState{
1866         .allocator = allocator,
1867         .kind = .vulkan,
1868         .format = .vulkan_spirv,
1869     };
1870     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
1871     defer processor.deinit();
1872     const commands = [_]command.Command{.{
1873         .kind = .shadow,
1874         .rect = .{ .x = 2, .y = 3, .width = 5, .height = 4 },
1875         .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1876         .color = .{ .r = 20, .g = 30, .b = 40, .a = 200 },
1877         .radius = 1,
1878         .width = 2,
1879         .order = 9,
1880     }};
1881 
1882     var expansion = try processor.expandShadowsAlloc(commands[0..], .{}, .{});
1883     defer expansion.deinit();
1884 
1885     try std.testing.expectEqual(@as(usize, 1), expansion.commands.len);
1886     try std.testing.expectEqual(@as(usize, 1), expansion.imageSet().images.len);
1887     try std.testing.expectEqual(command.Kind.image, expansion.commands[0].kind);
1888     try std.testing.expectEqual(@as(u32, 0), expansion.commands[0].image_index);
1889     try std.testing.expectEqual(@as(usize, 2), state.launch_count);
1890     try std.testing.expectEqual(@as(usize, 2), state.load_count);
1891     try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1892 }
1893 
1894 test "paint image processor expansion preserves command lists without shadows" {
1895     const allocator = std.testing.allocator;
1896     var state = gpu.recording.BackendState{
1897         .allocator = allocator,
1898         .kind = .vulkan,
1899         .format = .vulkan_spirv,
1900     };
1901     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
1902     defer processor.deinit();
1903     const pixels = [_]u32{cpu.pixel.packRgba(.{ .r = 255, .a = 255 })};
1904     const images = command.ImageSet{ .images = &.{.{ .width = 1, .height = 1, .pixels = pixels[0..] }} };
1905     const commands = [_]command.Command{
1906         .{
1907             .kind = .fill,
1908             .rect = .{ .x = 0, .y = 0, .width = 3, .height = 3 },
1909             .clip = .{ .x = 0, .y = 0, .width = 8, .height = 8 },
1910             .color = .{ .r = 200, .a = 255 },
1911         },
1912         .{
1913             .kind = .image,
1914             .rect = .{ .x = 3, .y = 3, .width = 1, .height = 1 },
1915             .clip = .{ .x = 0, .y = 0, .width = 8, .height = 8 },
1916             .color = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
1917             .image_index = 0,
1918         },
1919     };
1920 
1921     var expansion = try processor.expandShadowsAlloc(commands[0..], images, .{});
1922     defer expansion.deinit();
1923 
1924     try std.testing.expectEqualSlices(command.Command, commands[0..], expansion.commands);
1925     try std.testing.expectEqual(@as(usize, 1), expansion.imageSet().images.len);
1926     try std.testing.expectEqualSlices(u32, pixels[0..], expansion.imageSet().images[0].pixels);
1927     try std.testing.expectEqual(@as(usize, 0), state.launch_count);
1928 }
1929 
1930 test "paint image processor records blur catalog launch" {
1931     const allocator = std.testing.allocator;
1932     var state = gpu.recording.BackendState{
1933         .allocator = allocator,
1934         .kind = .vulkan,
1935         .format = .vulkan_spirv,
1936     };
1937     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
1938     defer processor.deinit();
1939     const width: u32 = 33;
1940     const height: u32 = 17;
1941     const radius: u32 = 1;
1942     const pixels = try pixelCount(width, height);
1943     const taps = try blurTapCount(radius);
1944 
1945     const dst = try allocateDeviceBuffer(state.handle(), u32, .u32, pixels);
1946     defer state.handle().destroyObject(dst.id);
1947     const src = try allocateDeviceBuffer(state.handle(), u32, .u32, pixels);
1948     defer state.handle().destroyObject(src.id);
1949     const weights = try allocateDeviceBuffer(state.handle(), f32, .f32, taps);
1950     defer state.handle().destroyObject(weights.id);
1951 
1952     try processor.launchBlurPass(dst, src, weights, width, height, radius, .vertical);
1953 
1954     const threads = image_library.imageThreadsForExtents(width, height);
1955     try std.testing.expectEqual(@as(usize, 1), state.load_count);
1956     try std.testing.expectEqual(@as(usize, 1), state.launch_count);
1957     try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1958     try std.testing.expectEqual(@as(usize, 3), state.last_launch_buffer_count);
1959     try std.testing.expectEqual(@as(usize, 2), state.last_launch_scalar_count);
1960     try std.testing.expectEqual(width, state.last_launch_scalar_u32_values[0]);
1961     try std.testing.expectEqual(height, state.last_launch_scalar_u32_values[1]);
1962     try std.testing.expectEqual((width + threads.x - 1) / threads.x, state.last_launch_grid[0]);
1963     try std.testing.expectEqual((height + threads.y - 1) / threads.y, state.last_launch_grid[1]);
1964     try std.testing.expectEqual(@as(u32, 1), state.last_launch_grid[2]);
1965     try std.testing.expectEqual(threads.x, state.last_launch_threadgroup[0]);
1966     try std.testing.expectEqual(threads.y, state.last_launch_threadgroup[1]);
1967     try std.testing.expectEqual(gpu.BufferAccess.read_write, state.last_buffer_access[0]);
1968     try std.testing.expectEqual(gpu.BufferAccess.read_only, state.last_buffer_access[1]);
1969     try std.testing.expectEqual(gpu.BufferAccess.read_only, state.last_buffer_access[2]);
1970 }
1971 
1972 test "paint image processor blur pass matches Accy image reference on cpu object" {
1973     const allocator = std.testing.allocator;
1974     var state = gpu.cpu.State.init(allocator);
1975     defer state.deinit();
1976 
1977     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
1978     defer processor.deinit();
1979     const width: u32 = 5;
1980     const height: u32 = 4;
1981     const pixels = try pixelCount(width, height);
1982     const radius: u32 = 1;
1983     const sigma: f32 = 1.0;
1984 
1985     const src = try allocator.alloc(u32, pixels);
1986     defer allocator.free(src);
1987     for (src, 0..) |*pixel, index| pixel.* = testPixel(index + 3);
1988 
1989     const actual = try allocator.alloc(u32, pixels);
1990     defer allocator.free(actual);
1991     @memset(actual, 0);
1992 
1993     const expected = try allocator.alloc(u32, pixels);
1994     defer allocator.free(expected);
1995     const weights = try image_library.gaussianWeightsAlloc(allocator, radius, sigma);
1996     defer allocator.free(weights);
1997     image_library.referenceBlurPass(expected, src, weights, width, height, .horizontal);
1998 
1999     try processor.blurPass(actual, src, width, height, radius, sigma, .horizontal);
2000     try std.testing.expectEqualSlices(u32, expected, actual);
2001 }
2002 
2003 test "paint image processor reuses blur pass buffers" {
2004     const allocator = std.testing.allocator;
2005     var state = gpu.recording.BackendState{
2006         .allocator = allocator,
2007         .kind = .vulkan,
2008         .format = .vulkan_spirv,
2009     };
2010     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2011     defer processor.deinit();
2012     const width: u32 = 5;
2013     const height: u32 = 4;
2014     const pixels: usize = width * height;
2015     var src = @as([pixels]u32, @splat(0));
2016     for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 101);
2017     var dst = @as([pixels]u32, @splat(0));
2018 
2019     try processor.blurPass(dst[0..], src[0..], width, height, 1, 1.0, .horizontal);
2020     try std.testing.expectEqual(@as(usize, 3), state.buffer_allocate_count);
2021     try std.testing.expectEqual(@as(usize, 1), state.launch_count);
2022     try std.testing.expectEqual(@as(usize, 2), state.write_count);
2023     try std.testing.expectEqual(@as(usize, 1), state.read_count);
2024 
2025     try processor.blurPass(dst[0..], src[0..], width, height, 1, 1.0, .horizontal);
2026     try std.testing.expectEqual(@as(usize, 3), state.buffer_allocate_count);
2027     try std.testing.expectEqual(@as(usize, 2), state.launch_count);
2028     try std.testing.expectEqual(@as(usize, 3), state.write_count);
2029     try std.testing.expectEqual(@as(usize, 2), state.read_count);
2030 
2031     const larger_width: u32 = 6;
2032     const larger_pixels: usize = larger_width * height;
2033     var larger_src = @as([larger_pixels]u32, @splat(0));
2034     for (larger_src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 131);
2035     var larger_dst = @as([larger_pixels]u32, @splat(0));
2036 
2037     try processor.blurPass(larger_dst[0..], larger_src[0..], larger_width, height, 2, 1.0, .horizontal);
2038     try std.testing.expectEqual(@as(usize, 6), state.buffer_allocate_count);
2039     try std.testing.expectEqual(@as(usize, 3), state.launch_count);
2040     try std.testing.expectEqual(@as(usize, 5), state.write_count);
2041     try std.testing.expectEqual(@as(usize, 3), state.read_count);
2042 }
2043 
2044 test "paint image processor resize bilinear matches Accy image reference on cpu object" {
2045     const allocator = std.testing.allocator;
2046     var state = gpu.cpu.State.init(allocator);
2047     defer state.deinit();
2048 
2049     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
2050     defer processor.deinit();
2051     const src_width: u32 = 3;
2052     const src_height: u32 = 2;
2053     const dst_width: u32 = 5;
2054     const dst_height: u32 = 4;
2055     const src_pixels = try pixelCount(src_width, src_height);
2056     const dst_pixels = try pixelCount(dst_width, dst_height);
2057 
2058     const src = try allocator.alloc(u32, src_pixels);
2059     defer allocator.free(src);
2060     for (src, 0..) |*pixel, index| pixel.* = testPixel(index + 11);
2061 
2062     const actual = try allocator.alloc(u32, dst_pixels);
2063     defer allocator.free(actual);
2064     @memset(actual, 0);
2065 
2066     const expected = try allocator.alloc(u32, dst_pixels);
2067     defer allocator.free(expected);
2068     image_library.referenceResizeBilinear(expected, src, dst_width, dst_height, src_width, src_height);
2069 
2070     try processor.resizeBilinear(actual, src, dst_width, dst_height, src_width, src_height);
2071     try std.testing.expectEqualSlices(u32, expected, actual);
2072 }
2073 
2074 test "paint image processor records owned resize image launch" {
2075     const allocator = std.testing.allocator;
2076     var state = gpu.recording.BackendState{
2077         .allocator = allocator,
2078         .kind = .vulkan,
2079         .format = .vulkan_spirv,
2080     };
2081     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2082     defer processor.deinit();
2083     const src_width: u32 = 3;
2084     const src_height: u32 = 2;
2085     const dst_width: u32 = 5;
2086     const dst_height: u32 = 4;
2087     var src = @as([(src_width * src_height)]u32, @splat(0));
2088     for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 31);
2089 
2090     var resized = try processor.resizeAlloc(.{
2091         .width = src_width,
2092         .height = src_height,
2093         .pixels = src[0..],
2094     }, dst_width, dst_height);
2095     defer resized.deinit(allocator);
2096 
2097     try std.testing.expectEqual(dst_width, resized.image.width);
2098     try std.testing.expectEqual(dst_height, resized.image.height);
2099     try std.testing.expectEqual(@as(usize, dst_width * dst_height), resized.image.pixels.len);
2100     try std.testing.expectEqual(resized.pixels.ptr, resized.image.pixels.ptr);
2101     try std.testing.expectEqual(@as(usize, 1), state.launch_count);
2102     try std.testing.expectEqual(@as(usize, 1), state.load_count);
2103     try std.testing.expectEqual(@as(usize, 1), state.sync_count);
2104     try std.testing.expectEqual(@as(usize, 1), state.write_count);
2105     try std.testing.expectEqual(@as(usize, 1), state.read_count);
2106     try std.testing.expectEqual(@as(usize, 2), state.last_launch_buffer_count);
2107     try std.testing.expectEqual(@as(usize, 7), state.last_launch_scalar_count);
2108     try std.testing.expectEqual(dst_width, state.last_launch_scalar_u32_values[0]);
2109     try std.testing.expectEqual(dst_height, state.last_launch_scalar_u32_values[1]);
2110     try std.testing.expectEqual(src_width, state.last_launch_scalar_u32_values[2]);
2111     try std.testing.expectApproxEqAbs(image_library.resizeScale(src_width, dst_width), state.last_launch_scalar_f32_values[3], 0.00001);
2112     try std.testing.expectApproxEqAbs(image_library.resizeScale(src_height, dst_height), state.last_launch_scalar_f32_values[4], 0.00001);
2113 }
2114 
2115 test "paint image processor reuses image-family resize kernels" {
2116     const allocator = std.testing.allocator;
2117     var state = gpu.recording.BackendState{
2118         .allocator = allocator,
2119         .kind = .vulkan,
2120         .format = .vulkan_spirv,
2121     };
2122     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2123     defer processor.deinit();
2124     const src_width: u32 = 3;
2125     const src_height: u32 = 2;
2126     const dst_width: u32 = 5;
2127     const dst_height: u32 = 4;
2128     var src = @as([(src_width * src_height)]u32, @splat(0));
2129     for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 41);
2130 
2131     var first = try processor.resizeAlloc(.{
2132         .width = src_width,
2133         .height = src_height,
2134         .pixels = src[0..],
2135     }, dst_width, dst_height);
2136     defer first.deinit(allocator);
2137     try std.testing.expectEqual(@as(usize, 1), state.load_count);
2138     try std.testing.expectEqual(@as(usize, 1), state.launch_count);
2139     try std.testing.expectEqual(@as(usize, 2), state.buffer_allocate_count);
2140 
2141     var second = try processor.resizeAlloc(.{
2142         .width = src_width,
2143         .height = src_height,
2144         .pixels = src[0..],
2145     }, dst_width, dst_height);
2146     defer second.deinit(allocator);
2147     try std.testing.expectEqual(@as(usize, 1), state.load_count);
2148     try std.testing.expectEqual(@as(usize, 2), state.launch_count);
2149     try std.testing.expectEqual(@as(usize, 2), state.buffer_allocate_count);
2150 
2151     var third = try processor.resizeAlloc(.{
2152         .width = src_width,
2153         .height = src_height,
2154         .pixels = src[0..],
2155     }, dst_width + 1, dst_height);
2156     defer third.deinit(allocator);
2157     try std.testing.expectEqual(@as(usize, 2), state.load_count);
2158     try std.testing.expectEqual(@as(usize, 3), state.launch_count);
2159     try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
2160 }
2161 
2162 test "paint image processor reads retained larger resize buffers into active result" {
2163     const allocator = std.testing.allocator;
2164     var state = gpu.recording.BackendState{
2165         .allocator = allocator,
2166         .kind = .vulkan,
2167         .format = .vulkan_spirv,
2168     };
2169     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2170     defer processor.deinit();
2171     const src_width: u32 = 3;
2172     const src_height: u32 = 2;
2173     var src = @as([(src_width * src_height)]u32, @splat(0));
2174     for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 151);
2175 
2176     var larger = try processor.resizeAlloc(.{
2177         .width = src_width,
2178         .height = src_height,
2179         .pixels = src[0..],
2180     }, 5, 4);
2181     defer larger.deinit(allocator);
2182     try std.testing.expectEqual(@as(usize, 20), larger.pixels.len);
2183     try std.testing.expectEqual(@as(usize, 80), state.last_read_byte_count);
2184 
2185     var smaller = try processor.resizeAlloc(.{
2186         .width = src_width,
2187         .height = src_height,
2188         .pixels = src[0..],
2189     }, 2, 2);
2190     defer smaller.deinit(allocator);
2191     try std.testing.expectEqual(@as(usize, 4), smaller.pixels.len);
2192     try std.testing.expectEqual(@as(usize, 2), state.buffer_allocate_count);
2193     try std.testing.expectEqual(@as(usize, 2), state.read_count);
2194     try std.testing.expectEqual(@as(usize, 80), state.last_read_byte_count);
2195 }
2196 
2197 test "paint image processor owned resize image matches Accy image reference on cpu object" {
2198     const allocator = std.testing.allocator;
2199     var state = gpu.cpu.State.init(allocator);
2200     defer state.deinit();
2201 
2202     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
2203     defer processor.deinit();
2204     const src_width: u32 = 4;
2205     const src_height: u32 = 3;
2206     const dst_width: u32 = 2;
2207     const dst_height: u32 = 5;
2208     const src_pixels = try pixelCount(src_width, src_height);
2209     const dst_pixels = try pixelCount(dst_width, dst_height);
2210     const src = try allocator.alloc(u32, src_pixels);
2211     defer allocator.free(src);
2212     for (src, 0..) |*pixel, index| pixel.* = testPixel(index + 47);
2213     const expected = try allocator.alloc(u32, dst_pixels);
2214     defer allocator.free(expected);
2215     image_library.referenceResizeBilinear(expected, src, dst_width, dst_height, src_width, src_height);
2216 
2217     var resized = try processor.resizeAlloc(.{
2218         .width = src_width,
2219         .height = src_height,
2220         .pixels = src,
2221     }, dst_width, dst_height);
2222     defer resized.deinit(allocator);
2223 
2224     try std.testing.expectEqual(dst_width, resized.image.width);
2225     try std.testing.expectEqual(dst_height, resized.image.height);
2226     try std.testing.expectEqualSlices(u32, expected, resized.pixels);
2227     try std.testing.expectEqualSlices(u32, expected, resized.image.pixels);
2228 }
2229 
2230 test "paint image thumbnail extent fits max box without upscaling" {
2231     try std.testing.expectEqual(ImageExtent{ .width = 100, .height = 50 }, try thumbnailExtent(400, 200, 100, 100));
2232     try std.testing.expectEqual(ImageExtent{ .width = 50, .height = 100 }, try thumbnailExtent(200, 400, 100, 100));
2233     try std.testing.expectEqual(ImageExtent{ .width = 100, .height = 50 }, try thumbnailExtent(100, 50, 500, 500));
2234     try std.testing.expectEqual(ImageExtent{ .width = 4, .height = 1 }, try thumbnailExtent(7, 3, 4, 4));
2235     try std.testing.expectError(error.InvalidImage, thumbnailExtent(1, 1, 0, 4));
2236 }
2237 
2238 test "paint image processor records thumbnail resize launch" {
2239     const allocator = std.testing.allocator;
2240     var state = gpu.recording.BackendState{
2241         .allocator = allocator,
2242         .kind = .vulkan,
2243         .format = .vulkan_spirv,
2244     };
2245     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2246     defer processor.deinit();
2247     const src_width: u32 = 4;
2248     const src_height: u32 = 2;
2249     const dst_width: u32 = 2;
2250     const dst_height: u32 = 1;
2251     var src = @as([(src_width * src_height)]u32, @splat(0));
2252     for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 71);
2253 
2254     var thumbnail = try processor.thumbnailAlloc(.{
2255         .width = src_width,
2256         .height = src_height,
2257         .pixels = src[0..],
2258     }, .{
2259         .max_width = 2,
2260         .max_height = 2,
2261     });
2262     defer thumbnail.deinit(allocator);
2263 
2264     try std.testing.expectEqual(dst_width, thumbnail.image.width);
2265     try std.testing.expectEqual(dst_height, thumbnail.image.height);
2266     try std.testing.expectEqual(@as(usize, dst_width * dst_height), thumbnail.pixels.len);
2267     try std.testing.expectEqual(@as(usize, 1), state.launch_count);
2268     try std.testing.expectEqual(@as(usize, 1), state.load_count);
2269     try std.testing.expectEqual(@as(usize, 1), state.sync_count);
2270     try std.testing.expectEqual(@as(usize, 1), state.write_count);
2271     try std.testing.expectEqual(@as(usize, 1), state.read_count);
2272     try std.testing.expectEqual(@as(usize, 7), state.last_launch_scalar_count);
2273     try std.testing.expectEqual(dst_width, state.last_launch_scalar_u32_values[0]);
2274     try std.testing.expectEqual(dst_height, state.last_launch_scalar_u32_values[1]);
2275     try std.testing.expectEqual(src_width, state.last_launch_scalar_u32_values[2]);
2276     try std.testing.expectApproxEqAbs(image_library.resizeScale(src_width, dst_width), state.last_launch_scalar_f32_values[3], 0.00001);
2277     try std.testing.expectApproxEqAbs(image_library.resizeScale(src_height, dst_height), state.last_launch_scalar_f32_values[4], 0.00001);
2278 }
2279 
2280 test "paint image processor thumbnail copies already fitting images without launch" {
2281     const allocator = std.testing.allocator;
2282     var state = gpu.recording.BackendState{
2283         .allocator = allocator,
2284         .kind = .vulkan,
2285         .format = .vulkan_spirv,
2286     };
2287     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2288     defer processor.deinit();
2289     const src_width: u32 = 2;
2290     const src_height: u32 = 2;
2291     var src = @as([(src_width * src_height)]u32, @splat(0));
2292     for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 83);
2293 
2294     var thumbnail = try processor.thumbnailAlloc(.{
2295         .width = src_width,
2296         .height = src_height,
2297         .pixels = src[0..],
2298     }, .{
2299         .max_width = 8,
2300         .max_height = 8,
2301     });
2302     defer thumbnail.deinit(allocator);
2303 
2304     try std.testing.expectEqual(src_width, thumbnail.image.width);
2305     try std.testing.expectEqual(src_height, thumbnail.image.height);
2306     try std.testing.expectEqualSlices(u32, src[0..], thumbnail.pixels);
2307     try std.testing.expectEqualSlices(u32, src[0..], thumbnail.image.pixels);
2308     try std.testing.expect(@intFromPtr(src[0..].ptr) != @intFromPtr(thumbnail.pixels.ptr));
2309     try std.testing.expectEqual(@as(usize, 0), state.launch_count);
2310     try std.testing.expectEqual(@as(usize, 0), state.write_count);
2311     try std.testing.expectEqual(@as(usize, 0), state.read_count);
2312 }
2313 
2314 test "paint image processor thumbnail image matches Accy image reference on cpu object" {
2315     const allocator = std.testing.allocator;
2316     var state = gpu.cpu.State.init(allocator);
2317     defer state.deinit();
2318 
2319     var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
2320     defer processor.deinit();
2321     const src_width: u32 = 6;
2322     const src_height: u32 = 4;
2323     const dst_width: u32 = 3;
2324     const dst_height: u32 = 2;
2325     const src_pixels = try pixelCount(src_width, src_height);
2326     const dst_pixels = try pixelCount(dst_width, dst_height);
2327     const src = try allocator.alloc(u32, src_pixels);
2328     defer allocator.free(src);
2329     for (src, 0..) |*pixel, index| pixel.* = testPixel(index + 97);
2330     const expected = try allocator.alloc(u32, dst_pixels);
2331     defer allocator.free(expected);
2332     image_library.referenceResizeBilinear(expected, src, dst_width, dst_height, src_width, src_height);
2333 
2334     var thumbnail = try processor.thumbnailAlloc(.{
2335         .width = src_width,
2336         .height = src_height,
2337         .pixels = src,
2338     }, .{
2339         .max_width = 3,
2340         .max_height = 3,
2341     });
2342     defer thumbnail.deinit(allocator);
2343 
2344     try std.testing.expectEqual(dst_width, thumbnail.image.width);
2345     try std.testing.expectEqual(dst_height, thumbnail.image.height);
2346     try std.testing.expectEqualSlices(u32, expected, thumbnail.pixels);
2347     try std.testing.expectEqualSlices(u32, expected, thumbnail.image.pixels);
2348 }