texel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ module ImageTransformations
5
+ FILTERS = %i[box triangle cubic mitchell lanczos].freeze
6
+ EDGES = %i[clamp reflect wrap zero].freeze
7
+
8
+ def flip_y
9
+ flipped = String.new(capacity: data.bytesize, encoding: Encoding::BINARY)
10
+ (height - 1).downto(0) { |row| flipped << data.byteslice(row * row_bytes, row_bytes) }
11
+ dup_with(data: flipped)
12
+ end
13
+
14
+ def convert(channels: self.channels, dtype: self.dtype)
15
+ validate_conversion!(channels, dtype)
16
+ return self if channels == self.channels && dtype == self.dtype
17
+
18
+ values = PixelData.unpack(data, self.dtype)
19
+ values = PixelData.convert_channels(values, width * height, self.channels, channels, self.dtype)
20
+ values = PixelData.convert_dtype(values, self.dtype, dtype)
21
+ dup_with(data: PixelData.pack(values, dtype), channels: channels, dtype: dtype)
22
+ end
23
+
24
+ def resize(new_width, new_height, filter: :mitchell, edge: :clamp)
25
+ validate_resize!(new_width, new_height, filter, edge)
26
+ return self if new_width == width && new_height == height
27
+
28
+ Resampler.resize(self, new_width, new_height, filter: filter, edge: edge)
29
+ end
30
+
31
+ def resize_to_fit(maximum)
32
+ raise FormatError, "maximum dimension must be a positive integer" unless maximum.is_a?(Integer) && maximum.positive?
33
+
34
+ scale = [maximum.to_f / width, maximum.to_f / height, 1.0].min
35
+ resize([(width * scale).round, 1].max, [(height * scale).round, 1].max)
36
+ end
37
+
38
+ def to_linear
39
+ ColorTransform.linearize(self)
40
+ end
41
+
42
+ def to_srgb
43
+ ColorTransform.delinearize(self)
44
+ end
45
+
46
+ def premultiply
47
+ transform_alpha(:premultiply)
48
+ end
49
+
50
+ def unpremultiply
51
+ transform_alpha(:unpremultiply)
52
+ end
53
+
54
+ def mipmaps(fast: false)
55
+ levels = [self]
56
+ levels << next_mipmap(levels.last, fast: fast) until levels.last.width == 1 && levels.last.height == 1
57
+ levels
58
+ end
59
+
60
+ private
61
+
62
+ def next_mipmap(source, fast:)
63
+ target_width = [source.width / 2, 1].max
64
+ target_height = [source.height / 2, 1].max
65
+ if fast && source.color_space == :srgb
66
+ encoded = source.dup_with(color_space: :unknown).resize(target_width, target_height, filter: :box)
67
+ return encoded.dup_with(color_space: :srgb)
68
+ end
69
+ return source.resize(target_width, target_height, filter: :box) if source.color_space != :srgb
70
+
71
+ linear = source.to_linear.resize(target_width, target_height, filter: :box)
72
+ ColorTransform.delinearize(linear, dtype: source.dtype)
73
+ end
74
+
75
+ def transform_alpha(operation)
76
+ unless [2, 4].include?(channels)
77
+ raise FormatError, "#{operation} requires an image with 2 or 4 channels"
78
+ end
79
+ return self if (operation == :premultiply) == premultiplied?
80
+
81
+ values = PixelData.unpack(data, dtype)
82
+ maximum = PixelData::MAX_VALUES.fetch(dtype).to_f
83
+ (width * height).times do |pixel|
84
+ offset = pixel * channels
85
+ alpha = values[offset + channels - 1].to_f / maximum
86
+ (channels - 1).times do |channel|
87
+ value = operation == :premultiply ? values[offset + channel] * alpha : divide_alpha(values[offset + channel], alpha)
88
+ values[offset + channel] = PixelData.cast(value, dtype)
89
+ end
90
+ end
91
+ dup_with(data: PixelData.pack(values, dtype), premultiplied: operation == :premultiply)
92
+ end
93
+
94
+ def divide_alpha(value, alpha)
95
+ alpha.zero? ? 0.0 : value / alpha
96
+ end
97
+
98
+ def validate_conversion!(target_channels, target_dtype)
99
+ raise FormatError, "channels must be between 1 and 4" unless target_channels.is_a?(Integer) && target_channels.between?(1, 4)
100
+ return if Image::DTYPE_SIZES.key?(target_dtype)
101
+
102
+ raise FormatError, "unsupported dtype #{target_dtype.inspect}"
103
+ end
104
+
105
+ def validate_resize!(target_width, target_height, filter, edge)
106
+ unless target_width.is_a?(Integer) && target_width.positive? && target_height.is_a?(Integer) && target_height.positive?
107
+ raise FormatError, "resize dimensions must be positive integers"
108
+ end
109
+ raise FormatError, "unsupported resize filter #{filter.inspect}" unless FILTERS.include?(filter)
110
+ raise FormatError, "unsupported edge mode #{edge.inspect}" unless EDGES.include?(edge)
111
+ end
112
+ end
113
+
114
+ Image.include(ImageTransformations)
115
+ end
data/lib/texel/info.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ Info = Data.define(:width, :height, :channels, :format) do
5
+ def initialize(width:, height:, channels:, format:)
6
+ super
7
+ freeze
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ module Native
5
+ class OperationError < StandardError; end
6
+
7
+ @available = false
8
+ @load_error = nil
9
+
10
+ class << self
11
+ attr_reader :load_error
12
+
13
+ def available?
14
+ @available
15
+ end
16
+
17
+ def metadata(bytes)
18
+ call_bridge(:metadata, bytes)
19
+ end
20
+
21
+ def decode(bytes, channels, dtype)
22
+ call_bridge(:decode, bytes, channels || 0, dtype_code(dtype))
23
+ end
24
+
25
+ def encode(data, format, width, height, channels, dtype, quality)
26
+ call_bridge(
27
+ :encode, data, format_code(format), width, height, channels, dtype_code(dtype), Integer(quality)
28
+ )
29
+ end
30
+
31
+ def resize(data, source_width, source_height, width, height, channels, dtype, srgb, filter, edge, premultiplied)
32
+ call_bridge(
33
+ :resize, data, source_width, source_height, width, height, channels, dtype_code(dtype),
34
+ srgb, filter, edge, premultiplied
35
+ )
36
+ end
37
+
38
+ def pack_f16(data)
39
+ call_bridge(:pack_f16, data)
40
+ end
41
+
42
+ private
43
+
44
+ def call_bridge(method, *arguments)
45
+ raise OperationError, native_unavailable_message unless available?
46
+
47
+ NativeBridge.public_send(method, *arguments)
48
+ rescue RuntimeError => e
49
+ raise OperationError, e.message
50
+ end
51
+
52
+ def native_unavailable_message
53
+ detail = load_error&.message
54
+ ["native extension is unavailable", detail].compact.join(": ")
55
+ end
56
+
57
+ def dtype_code(dtype)
58
+ { u8: 0, u16: 1, f32: 2 }.fetch(dtype)
59
+ end
60
+
61
+ def format_code(format)
62
+ { png: 0, jpg: 1, jpeg: 1, bmp: 2, tga: 3, hdr: 4 }.fetch(format)
63
+ end
64
+ end
65
+
66
+ unless ENV["TEXEL_DISABLE_NATIVE"] == "1"
67
+ load_errors = []
68
+ ruby_abi = RUBY_VERSION.split(".").first(2).join(".")
69
+
70
+ ["texel/#{ruby_abi}/texel_native", "texel/texel_native"].each do |extension|
71
+ begin
72
+ require extension
73
+ @available = true
74
+ break
75
+ rescue LoadError => error
76
+ load_errors << error
77
+ end
78
+ end
79
+
80
+ unless @available
81
+ begin
82
+ require_relative "../../ext/texel/texel_native"
83
+ @available = true
84
+ rescue LoadError => development_error
85
+ @load_error = development_error
86
+ @load_error.set_backtrace(load_errors.first&.backtrace || development_error.backtrace)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ module PixelData
5
+ MAX_VALUES = { u8: 255, u16: 65_535, f32: 1.0 }.freeze
6
+ UNPACK_DIRECTIVES = { u8: "C*", u16: "S<*", f32: "e*" }.freeze
7
+ PACK_DIRECTIVES = { u8: "C*", u16: "S<*", f32: "e*" }.freeze
8
+
9
+ module_function
10
+
11
+ def unpack(data, dtype)
12
+ data.unpack(UNPACK_DIRECTIVES.fetch(dtype))
13
+ end
14
+
15
+ def pack(values, dtype)
16
+ values.pack(PACK_DIRECTIVES.fetch(dtype)).b
17
+ end
18
+
19
+ def normalize(value, dtype)
20
+ value.to_f / MAX_VALUES.fetch(dtype)
21
+ end
22
+
23
+ def denormalize(value, dtype)
24
+ maximum = MAX_VALUES.fetch(dtype)
25
+ clamped = value.clamp(0.0, 1.0)
26
+ return clamped if dtype == :f32
27
+
28
+ (clamped * maximum).round
29
+ end
30
+
31
+ def cast(value, dtype)
32
+ return value.to_f if dtype == :f32
33
+
34
+ value.round.clamp(0, MAX_VALUES.fetch(dtype))
35
+ end
36
+
37
+ def convert_dtype(values, source, target)
38
+ return values if source == target
39
+
40
+ values.map { |value| denormalize(normalize(value, source), target) }
41
+ end
42
+
43
+ def convert_channels(values, pixel_count, source_channels, target_channels, dtype)
44
+ return values if source_channels == target_channels
45
+
46
+ maximum = MAX_VALUES.fetch(dtype)
47
+ result = Array.new(pixel_count * target_channels)
48
+ pixel_count.times do |pixel_index|
49
+ source = values.slice(pixel_index * source_channels, source_channels)
50
+ target = channel_values(source, source_channels, target_channels, maximum, dtype)
51
+ result[pixel_index * target_channels, target_channels] = target
52
+ end
53
+ result
54
+ end
55
+
56
+ def channel_values(source, source_channels, target_channels, maximum, dtype)
57
+ red, green, blue, alpha = rgba_values(source, source_channels, maximum)
58
+ gray = cast((red * 0.299) + (green * 0.587) + (blue * 0.114), dtype)
59
+
60
+ case target_channels
61
+ when 1 then [gray]
62
+ when 2 then [gray, alpha]
63
+ when 3 then [red, green, blue]
64
+ when 4 then [red, green, blue, alpha]
65
+ end
66
+ end
67
+ private_class_method :channel_values
68
+
69
+ def rgba_values(source, source_channels, maximum)
70
+ case source_channels
71
+ when 1 then [source[0], source[0], source[0], maximum]
72
+ when 2 then [source[0], source[0], source[0], source[1]]
73
+ when 3 then [source[0], source[1], source[2], maximum]
74
+ when 4 then source
75
+ end
76
+ end
77
+ private_class_method :rgba_values
78
+ end
79
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ module Registry
5
+ Entry = Data.define(:format, :magic, :codec, :extensions, :required_gem)
6
+
7
+ @entries = {}
8
+
9
+ class << self
10
+ def register(format, magic:, codec:, extensions: [format.to_s], required_gem: nil)
11
+ normalized = format.to_sym
12
+ @entries[normalized] = Entry.new(
13
+ format: normalized,
14
+ magic: magic,
15
+ codec: codec,
16
+ extensions: Array(extensions).map { |extension| extension.to_s.downcase }.freeze,
17
+ required_gem: required_gem
18
+ ).freeze
19
+ end
20
+
21
+ def fetch(format)
22
+ @entries[format.to_sym]
23
+ end
24
+
25
+ def formats
26
+ @entries.keys.freeze
27
+ end
28
+
29
+ def detect(bytes, extension: nil)
30
+ detected = @entries.values.find { |entry| magic_matches?(entry.magic, bytes) }
31
+ return detected.format if detected
32
+
33
+ by_extension(extension)&.format
34
+ end
35
+
36
+ def codec_for!(format, operation:)
37
+ entry = fetch(format)
38
+ raise FormatError, "unsupported image format #{format.inspect}" unless entry
39
+ if entry.required_gem && !entry.codec
40
+ raise MissingCodecError, "#{entry.format.to_s.upcase} requires the #{entry.required_gem} gem"
41
+ end
42
+ unless entry.codec&.public_send("can_#{operation}?", entry.format)
43
+ raise MissingCodecError, "#{entry.format.to_s.upcase} #{operation} support is unavailable"
44
+ end
45
+
46
+ entry.codec
47
+ end
48
+
49
+ private
50
+
51
+ def magic_matches?(magic, bytes)
52
+ return false unless magic
53
+ return magic.call(bytes) if magic.respond_to?(:call)
54
+
55
+ Array(magic).any? { |signature| bytes.start_with?(signature.b) }
56
+ end
57
+
58
+ def by_extension(extension)
59
+ return unless extension
60
+
61
+ normalized = extension.to_s.delete_prefix(".").downcase
62
+ @entries.values.find { |entry| entry.extensions.include?(normalized) }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ module Resampler
5
+ FILTER_CODES = { box: 1, triangle: 2, cubic: 3, mitchell: 5, lanczos: 7 }.freeze
6
+ EDGE_CODES = { clamp: 0, reflect: 1, wrap: 2, zero: 3 }.freeze
7
+
8
+ module_function
9
+
10
+ def resize(image, width, height, filter:, edge:)
11
+ if encoded_srgb_requires_linear_path?(image)
12
+ linear = ColorTransform.linearize(image)
13
+ resized = resize(linear, width, height, filter: filter, edge: edge)
14
+ return ColorTransform.delinearize(resized, dtype: image.dtype)
15
+ end
16
+
17
+ if Native.available?
18
+ data = Native.resize(
19
+ image.data, image.width, image.height, width, height, image.channels, image.dtype,
20
+ image.color_space == :srgb, FILTER_CODES.fetch(filter), EDGE_CODES.fetch(edge), image.premultiplied?
21
+ )
22
+ return image.dup_with(data: data, width: width, height: height)
23
+ end
24
+
25
+ resize_in_ruby(image, width, height)
26
+ rescue Native::OperationError => e
27
+ raise FormatError, "resize failed: #{e.message}"
28
+ end
29
+
30
+ def encoded_srgb_requires_linear_path?(image)
31
+ image.color_space == :srgb && (image.dtype != :u8 || !Native.available?)
32
+ end
33
+ private_class_method :encoded_srgb_requires_linear_path?
34
+
35
+ def resize_in_ruby(image, target_width, target_height)
36
+ source = PixelData.unpack(image.data, image.dtype)
37
+ output = Array.new(target_width * target_height * image.channels)
38
+ target_height.times do |target_y|
39
+ source_y = (((target_y + 0.5) * image.height / target_height) - 0.5).clamp(0, image.height - 1)
40
+ y0, y1, vertical = interpolation_points(source_y, image.height)
41
+ target_width.times do |target_x|
42
+ source_x = (((target_x + 0.5) * image.width / target_width) - 0.5).clamp(0, image.width - 1)
43
+ x0, x1, horizontal = interpolation_points(source_x, image.width)
44
+ image.channels.times do |channel|
45
+ output[((target_y * target_width) + target_x) * image.channels + channel] = interpolate(
46
+ source, image, x0, x1, y0, y1, channel, horizontal, vertical
47
+ )
48
+ end
49
+ end
50
+ end
51
+ image.dup_with(data: PixelData.pack(output, image.dtype), width: target_width, height: target_height)
52
+ end
53
+ private_class_method :resize_in_ruby
54
+
55
+ def interpolation_points(position, size)
56
+ lower = position.floor
57
+ upper = [lower + 1, size - 1].min
58
+ [lower, upper, position - lower]
59
+ end
60
+ private_class_method :interpolation_points
61
+
62
+ def interpolate(source, image, x0, x1, y0, y1, channel, horizontal, vertical)
63
+ top = mix(sample(source, image, x0, y0, channel), sample(source, image, x1, y0, channel), horizontal)
64
+ bottom = mix(sample(source, image, x0, y1, channel), sample(source, image, x1, y1, channel), horizontal)
65
+ PixelData.cast(mix(top, bottom, vertical), image.dtype)
66
+ end
67
+ private_class_method :interpolate
68
+
69
+ def sample(source, image, x, y, channel)
70
+ source[((y * image.width) + x) * image.channels + channel]
71
+ end
72
+ private_class_method :sample
73
+
74
+ def mix(left, right, amount)
75
+ left + ((right - left) * amount)
76
+ end
77
+ private_class_method :mix
78
+ end
79
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ module SourceReader
5
+ Source = Data.define(:bytes, :extension)
6
+
7
+ module_function
8
+
9
+ def read(source)
10
+ return read_path(source) if path?(source)
11
+ return read_io(source) if source.respond_to?(:read)
12
+ return Source.new(bytes: immutable_binary(source), extension: nil) if source.is_a?(String)
13
+
14
+ raise FormatError, "source must be a file path, binary String, or readable IO"
15
+ end
16
+
17
+ def extension_for_path(path)
18
+ value = path.respond_to?(:to_path) ? path.to_path : path
19
+ File.extname(value).delete_prefix(".").downcase.then { |extension| extension unless extension.empty? }
20
+ end
21
+
22
+ def path?(source)
23
+ source.respond_to?(:to_path) || (source.is_a?(String) && File.file?(source))
24
+ rescue ArgumentError
25
+ false
26
+ end
27
+ private_class_method :path?
28
+
29
+ def read_path(source)
30
+ path = source.respond_to?(:to_path) ? source.to_path : source
31
+ Source.new(bytes: File.binread(path).freeze, extension: extension_for_path(path))
32
+ rescue SystemCallError => e
33
+ raise FormatError, "could not read #{path}: #{e.message}"
34
+ end
35
+ private_class_method :read_path
36
+
37
+ def read_io(io)
38
+ bytes = io.read
39
+ raise FormatError, "IO#read must return a String" unless bytes.is_a?(String)
40
+
41
+ Source.new(bytes: immutable_binary(bytes), extension: io.respond_to?(:path) ? extension_for_path(io.path) : nil)
42
+ end
43
+ private_class_method :read_io
44
+
45
+ def immutable_binary(bytes)
46
+ return bytes if bytes.encoding == Encoding::BINARY && bytes.frozen?
47
+
48
+ bytes.dup.force_encoding(Encoding::BINARY).freeze
49
+ end
50
+ private_class_method :immutable_binary
51
+ end
52
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Texel
4
+ VERSION = "0.1.0"
5
+ end
data/lib/texel/wgpu.rb ADDED
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../texel"
4
+ require_relative "half_float"
5
+
6
+ module Texel
7
+ module WGPU
8
+ ALIGNMENT = 256
9
+ FORMAT_ALIASES = {
10
+ r8unorm: :r8_unorm,
11
+ rg8unorm: :rg8_unorm,
12
+ rgba8unorm: :rgba8_unorm,
13
+ rgba8unorm_srgb: :rgba8_unorm_srgb,
14
+ r16float: :r16_float,
15
+ rg16float: :rg16_float,
16
+ rgba16float: :rgba16_float,
17
+ r32float: :r32_float,
18
+ rg32float: :rg32_float,
19
+ rgba32float: :rgba32_float
20
+ }.freeze
21
+ FORMAT_INFO = {
22
+ r8_unorm: { channels: 1, dtype: :u8, transfer: :u8 },
23
+ rg8_unorm: { channels: 2, dtype: :u8, transfer: :u8 },
24
+ rgba8_unorm: { channels: 4, dtype: :u8, transfer: :u8 },
25
+ rgba8_unorm_srgb: { channels: 4, dtype: :u8, transfer: :u8 },
26
+ r16_float: { channels: 1, dtype: :f32, transfer: :f16 },
27
+ rg16_float: { channels: 2, dtype: :f32, transfer: :f16 },
28
+ rgba16_float: { channels: 4, dtype: :f32, transfer: :f16 },
29
+ r32_float: { channels: 1, dtype: :f32, transfer: :f32 },
30
+ rg32_float: { channels: 2, dtype: :f32, transfer: :f32 },
31
+ rgba32_float: { channels: 4, dtype: :f32, transfer: :f32 }
32
+ }.freeze
33
+
34
+ module_function
35
+
36
+ def upload(image, device:, queue:, usage: %i[texture_binding copy_dst], mips: true, format: nil, label: nil)
37
+ validate_image!(image)
38
+ texture_format = normalize_format(format || infer_format(image))
39
+ levels = mips ? image.mipmaps : [image]
40
+ texture = device.create_texture(
41
+ label: label,
42
+ size: extent(image),
43
+ format: texture_format,
44
+ usage: normalize_usage(usage),
45
+ mip_level_count: levels.length
46
+ )
47
+
48
+ levels.each_with_index do |level, mip_level|
49
+ data, bytes_per_row = pad_rows(level, format: texture_format)
50
+ queue.write_texture(
51
+ destination: { texture: texture, mip_level: mip_level, origin: { x: 0, y: 0, z: 0 } },
52
+ data: data,
53
+ data_layout: { offset: 0, bytes_per_row: bytes_per_row, rows_per_image: level.height },
54
+ size: extent(level)
55
+ )
56
+ end
57
+ texture
58
+ end
59
+
60
+ def infer_format(image)
61
+ validate_image!(image)
62
+ if image.channels == 3
63
+ raise FormatError, "3-channel images are unsupported by WebGPU; convert to 4 channels first"
64
+ end
65
+
66
+ case [image.dtype, image.channels, image.color_space]
67
+ in [:u8, 4, :srgb] then :rgba8_unorm_srgb
68
+ in [:u8, 4, _] then :rgba8_unorm
69
+ in [:u8, 1, _] then :r8_unorm
70
+ in [:u8, 2, _] then :rg8_unorm
71
+ in [:f32, 4, _] then :rgba16_float
72
+ in [:f32, 1, _] then :r16_float
73
+ in [:f32, 2, _] then :rg16_float
74
+ else
75
+ raise FormatError, "WebGPU upload does not support #{image.dtype} images with #{image.channels} channels"
76
+ end
77
+ end
78
+
79
+ def pad_rows(image, format: nil)
80
+ texture_format = normalize_format(format || infer_format(image))
81
+ info = validate_format!(image, texture_format)
82
+ transfer_data = transfer_data(image, info.fetch(:transfer))
83
+ component_size = { u8: 1, f16: 2, f32: 4 }.fetch(info.fetch(:transfer))
84
+ row_bytes = image.width * image.channels * component_size
85
+ bytes_per_row = align(row_bytes)
86
+ return [transfer_data.freeze, bytes_per_row] if row_bytes == bytes_per_row
87
+
88
+ padding = "\0".b * (bytes_per_row - row_bytes)
89
+ padded = String.new(capacity: bytes_per_row * image.height, encoding: Encoding::BINARY)
90
+ image.height.times do |row|
91
+ padded << transfer_data.byteslice(row * row_bytes, row_bytes) << padding
92
+ end
93
+ [padded.freeze, bytes_per_row]
94
+ end
95
+
96
+ def align(byte_count)
97
+ ((byte_count + ALIGNMENT - 1) / ALIGNMENT) * ALIGNMENT
98
+ end
99
+
100
+ def extent(image)
101
+ { width: image.width, height: image.height, depth_or_array_layers: 1 }
102
+ end
103
+ private_class_method :extent
104
+
105
+ def transfer_data(image, transfer)
106
+ transfer == :f16 ? HalfFloat.pack(image.data) : image.data
107
+ end
108
+ private_class_method :transfer_data
109
+
110
+ def validate_format!(image, format)
111
+ info = FORMAT_INFO[format]
112
+ raise FormatError, "unsupported WebGPU texture format #{format.inspect}" unless info
113
+ return info if image.channels == info.fetch(:channels) && image.dtype == info.fetch(:dtype)
114
+
115
+ expected = "#{info.fetch(:dtype)}/#{info.fetch(:channels)}ch"
116
+ raise FormatError, "#{format} requires #{expected} pixels, got #{image.dtype}/#{image.channels}ch"
117
+ end
118
+ private_class_method :validate_format!
119
+
120
+ def normalize_format(format)
121
+ symbol = format.to_sym
122
+ FORMAT_ALIASES.fetch(symbol, symbol)
123
+ end
124
+ private_class_method :normalize_format
125
+
126
+ def normalize_usage(usage)
127
+ values = usage.is_a?(Array) ? usage.dup : [usage]
128
+ values << :copy_dst unless values.include?(:copy_dst)
129
+ values.freeze
130
+ end
131
+ private_class_method :normalize_usage
132
+
133
+ def validate_image!(image)
134
+ raise FormatError, "expected a Texel::Image" unless image.is_a?(Image)
135
+ end
136
+ private_class_method :validate_image!
137
+ end
138
+ end