1use std::io::Cursor;
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use image::codecs::gif::{GifDecoder, GifEncoder, Repeat};
6use image::{AnimationDecoder, Delay, DynamicImage, Frame, ImageFormat, ImageOutputFormat};
7use image::{ImageBuffer, Luma, Rgb, Rgba, RgbaImage};
8use runmat_builtins::{
9 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
10 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
11 LogicalArray, NumericDType, Tensor, Value,
12};
13use runmat_macros::runtime_builtin;
14
15use crate::builtins::common::spec::{
16 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
17 ReductionNaN, ResidencyPolicy, ShapeRequirements,
18};
19use crate::builtins::common::tensor;
20use crate::builtins::image::type_resolvers::imwrite_type;
21use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
22
23const BUILTIN_NAME: &str = "imwrite";
24
25const IMWRITE_INPUTS_IMAGE_FILENAME: [BuiltinParamDescriptor; 2] = [
26 BuiltinParamDescriptor {
27 name: "A",
28 ty: BuiltinParamType::NumericArray,
29 arity: BuiltinParamArity::Required,
30 default: None,
31 description: "Grayscale, truecolor, or RGBA image data.",
32 },
33 BuiltinParamDescriptor {
34 name: "filename",
35 ty: BuiltinParamType::StringScalar,
36 arity: BuiltinParamArity::Required,
37 default: None,
38 description: "Output image path.",
39 },
40];
41
42const IMWRITE_INPUTS_INDEXED: [BuiltinParamDescriptor; 3] = [
43 BuiltinParamDescriptor {
44 name: "X",
45 ty: BuiltinParamType::NumericArray,
46 arity: BuiltinParamArity::Required,
47 default: None,
48 description: "Indexed image data.",
49 },
50 BuiltinParamDescriptor {
51 name: "map",
52 ty: BuiltinParamType::NumericArray,
53 arity: BuiltinParamArity::Required,
54 default: None,
55 description: "Nx3 colormap.",
56 },
57 BuiltinParamDescriptor {
58 name: "filename",
59 ty: BuiltinParamType::StringScalar,
60 arity: BuiltinParamArity::Required,
61 default: None,
62 description: "Output image path.",
63 },
64];
65
66const IMWRITE_INPUTS_OPTIONS: [BuiltinParamDescriptor; 4] = [
67 BuiltinParamDescriptor {
68 name: "A",
69 ty: BuiltinParamType::NumericArray,
70 arity: BuiltinParamArity::Required,
71 default: None,
72 description: "Image data.",
73 },
74 BuiltinParamDescriptor {
75 name: "filename",
76 ty: BuiltinParamType::StringScalar,
77 arity: BuiltinParamArity::Required,
78 default: None,
79 description: "Output image path.",
80 },
81 BuiltinParamDescriptor {
82 name: "name",
83 ty: BuiltinParamType::StringScalar,
84 arity: BuiltinParamArity::Variadic,
85 default: None,
86 description: "Name-value option.",
87 },
88 BuiltinParamDescriptor {
89 name: "value",
90 ty: BuiltinParamType::Any,
91 arity: BuiltinParamArity::Variadic,
92 default: None,
93 description: "Name-value option value.",
94 },
95];
96
97const IMWRITE_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
98 BuiltinSignatureDescriptor {
99 label: "imwrite(A, filename)",
100 inputs: &IMWRITE_INPUTS_IMAGE_FILENAME,
101 outputs: &[],
102 },
103 BuiltinSignatureDescriptor {
104 label: "imwrite(A, filename, fmt)",
105 inputs: &IMWRITE_INPUTS_OPTIONS,
106 outputs: &[],
107 },
108 BuiltinSignatureDescriptor {
109 label: "imwrite(A, filename, name, value, ...)",
110 inputs: &IMWRITE_INPUTS_OPTIONS,
111 outputs: &[],
112 },
113 BuiltinSignatureDescriptor {
114 label: "imwrite(X, map, filename, ...)",
115 inputs: &IMWRITE_INPUTS_INDEXED,
116 outputs: &[],
117 },
118];
119
120const IMWRITE_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
121 code: "RM.IMWRITE.INVALID_ARGUMENT",
122 identifier: Some("RunMat:imwrite:InvalidArgument"),
123 when: "Arguments do not match a supported imwrite form.",
124 message: "imwrite: invalid argument",
125};
126const IMWRITE_ERROR_INVALID_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
127 code: "RM.IMWRITE.INVALID_FILENAME",
128 identifier: Some("RunMat:imwrite:InvalidFilename"),
129 when: "Filename is missing or empty.",
130 message: "imwrite: invalid filename",
131};
132const IMWRITE_ERROR_INVALID_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
133 code: "RM.IMWRITE.INVALID_FORMAT",
134 identifier: Some("RunMat:imwrite:InvalidFormat"),
135 when: "Image format cannot be inferred or is unsupported.",
136 message: "imwrite: invalid image format",
137};
138const IMWRITE_ERROR_INVALID_IMAGE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
139 code: "RM.IMWRITE.INVALID_IMAGE",
140 identifier: Some("RunMat:imwrite:InvalidImage"),
141 when: "Image data has unsupported type, shape, or values.",
142 message: "imwrite: invalid image data",
143};
144const IMWRITE_ERROR_INVALID_COLORMAP: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
145 code: "RM.IMWRITE.INVALID_COLORMAP",
146 identifier: Some("RunMat:imwrite:InvalidColormap"),
147 when: "Indexed-image colormap is not an Nx3 numeric array.",
148 message: "imwrite: invalid colormap",
149};
150const IMWRITE_ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
151 code: "RM.IMWRITE.INVALID_OPTION",
152 identifier: Some("RunMat:imwrite:InvalidOption"),
153 when: "Name-value option is malformed or unsupported for the requested format.",
154 message: "imwrite: invalid option",
155};
156const IMWRITE_ERROR_ENCODE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
157 code: "RM.IMWRITE.ENCODE",
158 identifier: Some("RunMat:imwrite:EncodeError"),
159 when: "Image data cannot be encoded.",
160 message: "imwrite: encode error",
161};
162const IMWRITE_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
163 code: "RM.IMWRITE.IO",
164 identifier: Some("RunMat:imwrite:Io"),
165 when: "Image file cannot be read for append or written.",
166 message: "imwrite: file I/O error",
167};
168
169const IMWRITE_ERRORS: [BuiltinErrorDescriptor; 8] = [
170 IMWRITE_ERROR_INVALID_ARGUMENT,
171 IMWRITE_ERROR_INVALID_FILENAME,
172 IMWRITE_ERROR_INVALID_FORMAT,
173 IMWRITE_ERROR_INVALID_IMAGE,
174 IMWRITE_ERROR_INVALID_COLORMAP,
175 IMWRITE_ERROR_INVALID_OPTION,
176 IMWRITE_ERROR_ENCODE,
177 IMWRITE_ERROR_IO,
178];
179
180pub const IMWRITE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
181 signatures: &IMWRITE_SIGNATURES,
182 output_mode: BuiltinOutputMode::Fixed,
183 completion_policy: BuiltinCompletionPolicy::Public,
184 errors: &IMWRITE_ERRORS,
185};
186
187#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::image::imwrite")]
188pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
189 name: "imwrite",
190 op_kind: GpuOpKind::Custom("image-imwrite"),
191 supported_precisions: &[],
192 broadcast: BroadcastSemantics::None,
193 provider_hooks: &[],
194 constant_strategy: ConstantStrategy::InlineLiteral,
195 residency: ResidencyPolicy::GatherImmediately,
196 nan_mode: ReductionNaN::Include,
197 two_pass_threshold: None,
198 workgroup_size: None,
199 accepts_nan_mode: false,
200 notes: "Host image encoder sink; gpuArray inputs are gathered before writing.",
201};
202
203#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::image::imwrite")]
204pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
205 name: "imwrite",
206 shape: ShapeRequirements::Any,
207 constant_strategy: ConstantStrategy::InlineLiteral,
208 elementwise: None,
209 reduction: None,
210 emits_nan: false,
211 notes: "File I/O is not eligible for fusion.",
212};
213
214#[runtime_builtin(
215 name = "imwrite",
216 category = "image/io",
217 summary = "Write image data to a file.",
218 keywords = "image,write,imwrite,png,jpeg,gif,bmp,tiff",
219 sink = true,
220 suppress_auto_output = true,
221 type_resolver(imwrite_type),
222 descriptor(crate::builtins::image::imwrite::IMWRITE_DESCRIPTOR),
223 builtin_path = "crate::builtins::image::imwrite"
224)]
225async fn imwrite_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
226 if let Some(n) = crate::output_count::current_output_count() {
227 if n > 0 {
228 return Err(imwrite_error_with_detail(
229 &IMWRITE_ERROR_INVALID_ARGUMENT,
230 "imwrite does not return output arguments",
231 ));
232 }
233 }
234
235 let mut host_args = Vec::with_capacity(args.len());
236 for arg in &args {
237 host_args.push(gather_if_needed_async(arg).await?);
238 }
239
240 let invocation = parse_invocation(&host_args)?;
241 let image = materialize_image(
242 &invocation.image,
243 invocation.map.as_ref(),
244 invocation.alpha.as_ref(),
245 )?;
246 let bytes = encode_image(&image, &invocation).await?;
247 runmat_filesystem::write_async(&invocation.path, &bytes)
248 .await
249 .map_err(|err| {
250 imwrite_error_with_detail(
251 &IMWRITE_ERROR_IO,
252 format!("failed to write '{}': {err}", invocation.path.display()),
253 )
254 })?;
255
256 Ok(Value::OutputList(Vec::new()))
257}
258
259#[derive(Clone, Copy, Debug, PartialEq, Eq)]
260enum WriteMode {
261 Overwrite,
262 Append,
263}
264
265#[derive(Debug)]
266struct ImwriteOptions {
267 quality: u8,
268 delay_time: Option<f64>,
269 loop_count: Option<f64>,
270 write_mode: WriteMode,
271}
272
273impl Default for ImwriteOptions {
274 fn default() -> Self {
275 Self {
276 quality: 75,
277 delay_time: None,
278 loop_count: None,
279 write_mode: WriteMode::Overwrite,
280 }
281 }
282}
283
284#[derive(Debug)]
285struct Invocation {
286 image: Value,
287 map: Option<Value>,
288 alpha: Option<Tensor>,
289 path: PathBuf,
290 format: ImageFormat,
291 options: ImwriteOptions,
292}
293
294#[derive(Clone)]
295struct MaterializedImage {
296 rows: usize,
297 cols: usize,
298 channels: usize,
299 data: PixelData,
300}
301
302#[derive(Clone)]
303enum PixelData {
304 U8(Vec<u8>),
305 U16(Vec<u16>),
306}
307
308fn parse_invocation(args: &[Value]) -> BuiltinResult<Invocation> {
309 if args.len() < 2 {
310 return Err(imwrite_error_with_detail(
311 &IMWRITE_ERROR_INVALID_ARGUMENT,
312 "expected image data and filename",
313 ));
314 }
315
316 let (image, map, filename_index) = if is_string_like(&args[1]) {
317 (args[0].clone(), None, 1usize)
318 } else {
319 if args.len() < 3 {
320 return Err(imwrite_error_with_detail(
321 &IMWRITE_ERROR_INVALID_ARGUMENT,
322 "indexed images require X, map, and filename",
323 ));
324 }
325 (args[0].clone(), Some(args[1].clone()), 2usize)
326 };
327
328 let filename = string_arg(
329 "filename",
330 &args[filename_index],
331 &IMWRITE_ERROR_INVALID_FILENAME,
332 )?;
333 if filename.trim().is_empty() {
334 return Err(imwrite_error_with_detail(
335 &IMWRITE_ERROR_INVALID_FILENAME,
336 "filename must not be empty",
337 ));
338 }
339 let path = PathBuf::from(filename);
340 let mut idx = filename_index + 1;
341
342 let mut explicit_format = None;
343 if idx < args.len() {
344 if let Some(text) = tensor::value_to_string(&args[idx]) {
345 if !is_option_name(&text) {
346 explicit_format = Some(parse_format_hint(&text)?);
347 idx += 1;
348 }
349 }
350 }
351
352 let mut options = ImwriteOptions::default();
353 let mut alpha = None;
354 while idx < args.len() {
355 let name = string_arg("option name", &args[idx], &IMWRITE_ERROR_INVALID_OPTION)?;
356 idx += 1;
357 if idx >= args.len() {
358 return Err(imwrite_error_with_detail(
359 &IMWRITE_ERROR_INVALID_OPTION,
360 format!("option '{name}' requires a value"),
361 ));
362 }
363 let value = &args[idx];
364 idx += 1;
365
366 match canonical_option_name(&name).as_str() {
367 "alpha" => alpha = Some(tensor_from_numeric_like(value, "Alpha")?),
368 "quality" => {
369 let q = numeric_scalar(value, "Quality")?;
370 if !q.is_finite() || !(0.0..=100.0).contains(&q) {
371 return Err(imwrite_error_with_detail(
372 &IMWRITE_ERROR_INVALID_OPTION,
373 "Quality must be a scalar from 0 to 100",
374 ));
375 }
376 options.quality = q.round() as u8;
377 }
378 "writemode" => {
379 let mode = string_arg("WriteMode", value, &IMWRITE_ERROR_INVALID_OPTION)?;
380 options.write_mode = match mode.trim().to_ascii_lowercase().as_str() {
381 "overwrite" => WriteMode::Overwrite,
382 "append" => WriteMode::Append,
383 _ => {
384 return Err(imwrite_error_with_detail(
385 &IMWRITE_ERROR_INVALID_OPTION,
386 "WriteMode must be 'overwrite' or 'append'",
387 ))
388 }
389 };
390 }
391 "delaytime" => {
392 let delay = numeric_scalar(value, "DelayTime")?;
393 if !delay.is_finite() || delay < 0.0 {
394 return Err(imwrite_error_with_detail(
395 &IMWRITE_ERROR_INVALID_OPTION,
396 "DelayTime must be a finite non-negative scalar in seconds",
397 ));
398 }
399 options.delay_time = Some(delay);
400 }
401 "loopcount" => {
402 let count = numeric_scalar(value, "LoopCount")?;
403 if count.is_nan() || count < 0.0 {
404 return Err(imwrite_error_with_detail(
405 &IMWRITE_ERROR_INVALID_OPTION,
406 "LoopCount must be non-negative or Inf",
407 ));
408 }
409 options.loop_count = Some(count);
410 }
411 "compression" | "bitdepth" | "mode" | "disposalmethod" | "backgroundcolor"
412 | "comment" | "transparentcolor" => {
413 return Err(imwrite_error_with_detail(
414 &IMWRITE_ERROR_INVALID_OPTION,
415 format!("option '{name}' is not supported yet"),
416 ));
417 }
418 _ => {
419 return Err(imwrite_error_with_detail(
420 &IMWRITE_ERROR_INVALID_OPTION,
421 format!("unsupported option '{name}'"),
422 ))
423 }
424 }
425 }
426
427 let format = match explicit_format {
428 Some(format) => format,
429 None => infer_format_from_path(&path)?,
430 };
431
432 Ok(Invocation {
433 image,
434 map,
435 alpha,
436 path,
437 format,
438 options,
439 })
440}
441
442fn is_string_like(value: &Value) -> bool {
443 tensor::value_to_string(value).is_some()
444}
445
446fn string_arg(
447 label: &str,
448 value: &Value,
449 error: &'static BuiltinErrorDescriptor,
450) -> BuiltinResult<String> {
451 tensor::value_to_string(value).ok_or_else(|| {
452 imwrite_error_with_detail(
453 error,
454 format!("{label} must be a string scalar or char vector"),
455 )
456 })
457}
458
459fn numeric_scalar(value: &Value, label: &str) -> BuiltinResult<f64> {
460 match value {
461 Value::Num(n) => Ok(*n),
462 Value::Int(i) => Ok(i.to_f64()),
463 Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
464 Value::Tensor(t) if t.data.len() == 1 => Ok(t.data[0]),
465 Value::LogicalArray(a) if a.data.len() == 1 => Ok(if a.data[0] != 0 { 1.0 } else { 0.0 }),
466 _ => Err(imwrite_error_with_detail(
467 &IMWRITE_ERROR_INVALID_OPTION,
468 format!("{label} must be a numeric scalar"),
469 )),
470 }
471}
472
473fn canonical_option_name(name: &str) -> String {
474 name.chars()
475 .filter(|ch| !ch.is_whitespace() && *ch != '_' && *ch != '-')
476 .flat_map(char::to_lowercase)
477 .collect()
478}
479
480fn is_option_name(name: &str) -> bool {
481 matches!(
482 canonical_option_name(name).as_str(),
483 "alpha"
484 | "quality"
485 | "writemode"
486 | "delaytime"
487 | "loopcount"
488 | "compression"
489 | "bitdepth"
490 | "mode"
491 | "disposalmethod"
492 | "backgroundcolor"
493 | "comment"
494 | "transparentcolor"
495 )
496}
497
498fn parse_format_hint(value: &str) -> BuiltinResult<ImageFormat> {
499 let label = value.trim().trim_start_matches('.').to_ascii_lowercase();
500 if label.is_empty() {
501 return Err(imwrite_error_with_detail(
502 &IMWRITE_ERROR_INVALID_FORMAT,
503 "format hint must not be empty",
504 ));
505 }
506 match label.as_str() {
507 "jpg" | "jpeg" | "jpe" => Ok(ImageFormat::Jpeg),
508 "png" => Ok(ImageFormat::Png),
509 "bmp" => Ok(ImageFormat::Bmp),
510 "gif" => Ok(ImageFormat::Gif),
511 "tif" | "tiff" => Ok(ImageFormat::Tiff),
512 other => ImageFormat::from_extension(other)
513 .filter(is_supported_format)
514 .ok_or_else(|| {
515 imwrite_error_with_detail(
516 &IMWRITE_ERROR_INVALID_FORMAT,
517 format!("unsupported image format '{other}'"),
518 )
519 }),
520 }
521}
522
523fn infer_format_from_path(path: &Path) -> BuiltinResult<ImageFormat> {
524 ImageFormat::from_path(path)
525 .ok()
526 .filter(is_supported_format)
527 .ok_or_else(|| {
528 imwrite_error_with_detail(
529 &IMWRITE_ERROR_INVALID_FORMAT,
530 format!(
531 "could not infer supported image format from '{}'",
532 path.display()
533 ),
534 )
535 })
536}
537
538fn is_supported_format(format: &ImageFormat) -> bool {
539 matches!(
540 format,
541 ImageFormat::Png
542 | ImageFormat::Jpeg
543 | ImageFormat::Bmp
544 | ImageFormat::Gif
545 | ImageFormat::Tiff
546 )
547}
548
549fn tensor_from_numeric_like(value: &Value, label: &str) -> BuiltinResult<Tensor> {
550 match value {
551 Value::Tensor(t) => Ok(t.clone()),
552 Value::LogicalArray(a) => logical_to_tensor(a),
553 Value::Num(n) => Tensor::new(vec![*n], vec![1, 1]).map_err(|err| {
554 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, format!("{label}: {err}"))
555 }),
556 Value::Int(i) => Tensor::new(vec![i.to_f64()], vec![1, 1]).map_err(|err| {
557 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, format!("{label}: {err}"))
558 }),
559 Value::Bool(b) => {
560 Tensor::new(vec![if *b { 1.0 } else { 0.0 }], vec![1, 1]).map_err(|err| {
561 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, format!("{label}: {err}"))
562 })
563 }
564 _ => Err(imwrite_error_with_detail(
565 &IMWRITE_ERROR_INVALID_IMAGE,
566 format!("{label} must be numeric or logical"),
567 )),
568 }
569}
570
571fn logical_to_tensor(value: &LogicalArray) -> BuiltinResult<Tensor> {
572 let data = value
573 .data
574 .iter()
575 .map(|&b| if b != 0 { 1.0 } else { 0.0 })
576 .collect::<Vec<_>>();
577 Tensor::new(data, value.shape.clone())
578 .map_err(|err| imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, err))
579}
580
581fn materialize_image(
582 image: &Value,
583 map: Option<&Value>,
584 alpha: Option<&Tensor>,
585) -> BuiltinResult<MaterializedImage> {
586 let tensor = tensor_from_numeric_like(image, "image")?;
587 let mut out = if let Some(map_value) = map {
588 materialize_indexed_image(&tensor, &tensor_from_numeric_like(map_value, "map")?)?
589 } else {
590 materialize_direct_image(&tensor)?
591 };
592
593 if let Some(alpha) = alpha {
594 apply_alpha(&mut out, alpha)?;
595 }
596 Ok(out)
597}
598
599fn image_dimensions(tensor: &Tensor) -> BuiltinResult<(usize, usize, usize)> {
600 match tensor.shape.len() {
601 0 => Ok((1, 1, 1)),
602 1 => Ok((1, tensor.shape[0], 1)),
603 2 => Ok((tensor.shape[0], tensor.shape[1], 1)),
604 3 if matches!(tensor.shape[2], 1 | 3 | 4) => {
605 Ok((tensor.shape[0], tensor.shape[1], tensor.shape[2]))
606 }
607 _ => Err(imwrite_error_with_detail(
608 &IMWRITE_ERROR_INVALID_IMAGE,
609 "image must be MxN, MxNx3, or MxNx4",
610 )),
611 }
612}
613
614fn materialize_direct_image(tensor: &Tensor) -> BuiltinResult<MaterializedImage> {
615 let (rows, cols, channels) = image_dimensions(tensor)?;
616 let pixels = rows.checked_mul(cols).ok_or_else(|| {
617 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image dimensions overflow")
618 })?;
619 if tensor.data.len() != pixels * channels {
620 return Err(imwrite_error_with_detail(
621 &IMWRITE_ERROR_INVALID_IMAGE,
622 "image data length does not match shape",
623 ));
624 }
625
626 let mut data = if tensor.dtype == NumericDType::U16 {
627 PixelData::U16(vec![0u16; pixels * channels])
628 } else {
629 PixelData::U8(vec![0u8; pixels * channels])
630 };
631 for row in 0..rows {
632 for col in 0..cols {
633 for channel in 0..channels {
634 let src = row + rows * col + pixels * channel;
635 let dst = (row * cols + col) * channels + channel;
636 match &mut data {
637 PixelData::U8(data) => data[dst] = value_to_u8(tensor.data[src], tensor.dtype),
638 PixelData::U16(data) => {
639 data[dst] = value_to_u16(tensor.data[src], tensor.dtype)
640 }
641 }
642 }
643 }
644 }
645 Ok(MaterializedImage {
646 rows,
647 cols,
648 channels,
649 data,
650 })
651}
652
653fn materialize_indexed_image(indexed: &Tensor, map: &Tensor) -> BuiltinResult<MaterializedImage> {
654 let (rows, cols, channels) = image_dimensions(indexed)?;
655 if channels != 1 {
656 return Err(imwrite_error_with_detail(
657 &IMWRITE_ERROR_INVALID_IMAGE,
658 "indexed image X must be a 2-D array",
659 ));
660 }
661 if map.shape.len() != 2 || map.shape[1] != 3 || map.shape[0] == 0 {
662 return Err(imwrite_error_with_detail(
663 &IMWRITE_ERROR_INVALID_COLORMAP,
664 "map must be an Nx3 colormap",
665 ));
666 }
667
668 let pixels = rows.checked_mul(cols).ok_or_else(|| {
669 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image dimensions overflow")
670 })?;
671 let byte_len = pixels.checked_mul(3).ok_or_else(|| {
672 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image dimensions overflow")
673 })?;
674 let mut data = vec![0u8; byte_len];
675 for row in 0..rows {
676 for col in 0..cols {
677 let pixel = row + rows * col;
678 let map_idx = map_index(indexed.data[pixel], indexed.dtype, map.shape[0])?;
679 let dst = (row * cols + col) * 3;
680 for channel in 0..3 {
681 let src = map_idx + map.shape[0] * channel;
682 data[dst + channel] = value_to_u8(map.data[src], map.dtype);
683 }
684 }
685 }
686 Ok(MaterializedImage {
687 rows,
688 cols,
689 channels: 3,
690 data: PixelData::U8(data),
691 })
692}
693
694fn map_index(value: f64, dtype: NumericDType, map_rows: usize) -> BuiltinResult<usize> {
695 if !value.is_finite() {
696 return Err(imwrite_error_with_detail(
697 &IMWRITE_ERROR_INVALID_IMAGE,
698 "indexed image values must be finite",
699 ));
700 }
701 let index = if matches!(dtype, NumericDType::U8 | NumericDType::U16) {
702 value.round() as isize
703 } else {
704 value.round() as isize - 1
705 };
706 if index < 0 || index as usize >= map_rows {
707 return Err(imwrite_error_with_detail(
708 &IMWRITE_ERROR_INVALID_IMAGE,
709 format!("indexed image value {value} is outside the colormap"),
710 ));
711 }
712 Ok(index as usize)
713}
714
715fn value_to_u8(value: f64, dtype: NumericDType) -> u8 {
716 let scaled = match dtype {
717 NumericDType::U8 => value,
718 NumericDType::U16 => value / 257.0,
719 NumericDType::U32 => value / (u32::MAX as f64) * 255.0,
720 NumericDType::F64 | NumericDType::F32 => value.clamp(0.0, 1.0) * 255.0,
721 };
722 if scaled.is_nan() {
723 0
724 } else {
725 scaled.round().clamp(0.0, 255.0) as u8
726 }
727}
728
729fn value_to_u16(value: f64, dtype: NumericDType) -> u16 {
730 let scaled = match dtype {
731 NumericDType::U16 => value,
732 NumericDType::U8 => value * 257.0,
733 NumericDType::U32 => value / (u32::MAX as f64) * 65535.0,
734 NumericDType::F64 | NumericDType::F32 => value.clamp(0.0, 1.0) * 65535.0,
735 };
736 if scaled.is_nan() {
737 0
738 } else {
739 scaled.round().clamp(0.0, 65535.0) as u16
740 }
741}
742
743fn apply_alpha(image: &mut MaterializedImage, alpha: &Tensor) -> BuiltinResult<()> {
744 if alpha.shape.len() != 2 || alpha.shape[0] != image.rows || alpha.shape[1] != image.cols {
745 return Err(imwrite_error_with_detail(
746 &IMWRITE_ERROR_INVALID_OPTION,
747 "Alpha must be an MxN array matching the image dimensions",
748 ));
749 }
750 if alpha.data.len() != image.rows * image.cols {
751 return Err(imwrite_error_with_detail(
752 &IMWRITE_ERROR_INVALID_OPTION,
753 "Alpha data length does not match shape",
754 ));
755 }
756
757 let pixels = image.rows * image.cols;
758 image.data = match &image.data {
759 PixelData::U8(data) => {
760 let mut rgba = vec![0u8; pixels * 4];
761 for row in 0..image.rows {
762 for col in 0..image.cols {
763 let pixel = row * image.cols + col;
764 let alpha_idx = row + image.rows * col;
765 let dst = pixel * 4;
766 match image.channels {
767 1 => {
768 let gray = data[pixel];
769 rgba[dst] = gray;
770 rgba[dst + 1] = gray;
771 rgba[dst + 2] = gray;
772 }
773 3 | 4 => {
774 let src = pixel * image.channels;
775 rgba[dst] = data[src];
776 rgba[dst + 1] = data[src + 1];
777 rgba[dst + 2] = data[src + 2];
778 }
779 _ => unreachable!(),
780 }
781 rgba[dst + 3] = value_to_u8(alpha.data[alpha_idx], alpha.dtype);
782 }
783 }
784 PixelData::U8(rgba)
785 }
786 PixelData::U16(data) => {
787 let mut rgba = vec![0u16; pixels * 4];
788 for row in 0..image.rows {
789 for col in 0..image.cols {
790 let pixel = row * image.cols + col;
791 let alpha_idx = row + image.rows * col;
792 let dst = pixel * 4;
793 match image.channels {
794 1 => {
795 let gray = data[pixel];
796 rgba[dst] = gray;
797 rgba[dst + 1] = gray;
798 rgba[dst + 2] = gray;
799 }
800 3 | 4 => {
801 let src = pixel * image.channels;
802 rgba[dst] = data[src];
803 rgba[dst + 1] = data[src + 1];
804 rgba[dst + 2] = data[src + 2];
805 }
806 _ => unreachable!(),
807 }
808 rgba[dst + 3] = value_to_u16(alpha.data[alpha_idx], alpha.dtype);
809 }
810 }
811 PixelData::U16(rgba)
812 }
813 };
814 image.channels = 4;
815 Ok(())
816}
817
818async fn encode_image(
819 image: &MaterializedImage,
820 invocation: &Invocation,
821) -> BuiltinResult<Vec<u8>> {
822 if invocation.options.write_mode == WriteMode::Append && invocation.format != ImageFormat::Gif {
823 return Err(imwrite_error_with_detail(
824 &IMWRITE_ERROR_INVALID_OPTION,
825 "WriteMode 'append' is supported for GIF files only",
826 ));
827 }
828
829 match invocation.format {
830 ImageFormat::Gif => encode_gif(image, invocation).await,
831 ImageFormat::Jpeg => {
832 if image.channels == 4 {
833 return Err(imwrite_error_with_detail(
834 &IMWRITE_ERROR_INVALID_OPTION,
835 "JPEG does not support alpha channels",
836 ));
837 }
838 write_dynamic_image(
839 image_to_dynamic(&image_as_8bit(image), false)?,
840 ImageOutputFormat::Jpeg(invocation.options.quality),
841 )
842 }
843 ImageFormat::Bmp => {
844 if image.channels == 4 {
845 return Err(imwrite_error_with_detail(
846 &IMWRITE_ERROR_INVALID_OPTION,
847 "BMP alpha output is not supported",
848 ));
849 }
850 write_dynamic_image(
851 image_to_dynamic(&image_as_8bit(image), false)?,
852 ImageOutputFormat::Bmp,
853 )
854 }
855 ImageFormat::Png => {
856 write_dynamic_image(image_to_dynamic(image, true)?, ImageOutputFormat::Png)
857 }
858 ImageFormat::Tiff => {
859 write_dynamic_image(image_to_dynamic(image, true)?, ImageOutputFormat::Tiff)
860 }
861 _ => Err(imwrite_error_with_detail(
862 &IMWRITE_ERROR_INVALID_FORMAT,
863 "unsupported image format",
864 )),
865 }
866}
867
868fn image_to_dynamic(image: &MaterializedImage, keep_alpha: bool) -> BuiltinResult<DynamicImage> {
869 let width = u32::try_from(image.cols).map_err(|_| {
870 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image width is too large")
871 })?;
872 let height = u32::try_from(image.rows).map_err(|_| {
873 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image height is too large")
874 })?;
875
876 match image.channels {
877 1 => match &image.data {
878 PixelData::U8(data) => {
879 ImageBuffer::<Luma<u8>, _>::from_raw(width, height, data.clone())
880 .map(DynamicImage::ImageLuma8)
881 .ok_or_else(|| {
882 imwrite_error_with_detail(
883 &IMWRITE_ERROR_INVALID_IMAGE,
884 "invalid grayscale image buffer",
885 )
886 })
887 }
888 PixelData::U16(data) => {
889 ImageBuffer::<Luma<u16>, _>::from_raw(width, height, data.clone())
890 .map(DynamicImage::ImageLuma16)
891 .ok_or_else(|| {
892 imwrite_error_with_detail(
893 &IMWRITE_ERROR_INVALID_IMAGE,
894 "invalid grayscale image buffer",
895 )
896 })
897 }
898 },
899 3 => match &image.data {
900 PixelData::U8(data) => ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, data.clone())
901 .map(DynamicImage::ImageRgb8)
902 .ok_or_else(|| {
903 imwrite_error_with_detail(
904 &IMWRITE_ERROR_INVALID_IMAGE,
905 "invalid RGB image buffer",
906 )
907 }),
908 PixelData::U16(data) => {
909 ImageBuffer::<Rgb<u16>, _>::from_raw(width, height, data.clone())
910 .map(DynamicImage::ImageRgb16)
911 .ok_or_else(|| {
912 imwrite_error_with_detail(
913 &IMWRITE_ERROR_INVALID_IMAGE,
914 "invalid RGB image buffer",
915 )
916 })
917 }
918 },
919 4 if keep_alpha => match &image.data {
920 PixelData::U8(data) => {
921 ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, data.clone())
922 .map(DynamicImage::ImageRgba8)
923 .ok_or_else(|| {
924 imwrite_error_with_detail(
925 &IMWRITE_ERROR_INVALID_IMAGE,
926 "invalid RGBA image buffer",
927 )
928 })
929 }
930 PixelData::U16(data) => {
931 ImageBuffer::<Rgba<u16>, _>::from_raw(width, height, data.clone())
932 .map(DynamicImage::ImageRgba16)
933 .ok_or_else(|| {
934 imwrite_error_with_detail(
935 &IMWRITE_ERROR_INVALID_IMAGE,
936 "invalid RGBA image buffer",
937 )
938 })
939 }
940 },
941 4 => match &image.data {
942 PixelData::U8(data) => {
943 let mut rgb = Vec::with_capacity(image.rows * image.cols * 3);
944 for chunk in data.chunks_exact(4) {
945 rgb.extend_from_slice(&chunk[..3]);
946 }
947 ImageBuffer::<Rgb<u8>, _>::from_raw(width, height, rgb)
948 .map(DynamicImage::ImageRgb8)
949 .ok_or_else(|| {
950 imwrite_error_with_detail(
951 &IMWRITE_ERROR_INVALID_IMAGE,
952 "invalid RGB image buffer",
953 )
954 })
955 }
956 PixelData::U16(data) => {
957 let mut rgb = Vec::with_capacity(image.rows * image.cols * 3);
958 for chunk in data.chunks_exact(4) {
959 rgb.extend_from_slice(&chunk[..3]);
960 }
961 ImageBuffer::<Rgb<u16>, _>::from_raw(width, height, rgb)
962 .map(DynamicImage::ImageRgb16)
963 .ok_or_else(|| {
964 imwrite_error_with_detail(
965 &IMWRITE_ERROR_INVALID_IMAGE,
966 "invalid RGB image buffer",
967 )
968 })
969 }
970 },
971 _ => Err(imwrite_error_with_detail(
972 &IMWRITE_ERROR_INVALID_IMAGE,
973 "image must have 1, 3, or 4 channels",
974 )),
975 }
976}
977
978fn write_dynamic_image(image: DynamicImage, format: ImageOutputFormat) -> BuiltinResult<Vec<u8>> {
979 let mut cursor = Cursor::new(Vec::new());
980 image.write_to(&mut cursor, format).map_err(|err| {
981 imwrite_error_with_detail(
982 &IMWRITE_ERROR_ENCODE,
983 format!("unable to encode image: {err}"),
984 )
985 })?;
986 Ok(cursor.into_inner())
987}
988
989async fn encode_gif(image: &MaterializedImage, invocation: &Invocation) -> BuiltinResult<Vec<u8>> {
990 let mut frames = Vec::new();
991 let mut existing_repeat = None;
992 if invocation.options.write_mode == WriteMode::Append {
993 let existing = runmat_filesystem::read_async(&invocation.path)
996 .await
997 .map_err(|err| {
998 imwrite_error_with_detail(
999 &IMWRITE_ERROR_IO,
1000 format!(
1001 "failed to read GIF for append '{}': {err}",
1002 invocation.path.display()
1003 ),
1004 )
1005 })?;
1006 existing_repeat = gif_repeat_from_bytes(&existing);
1007 let decoder = GifDecoder::new(Cursor::new(existing)).map_err(|err| {
1008 imwrite_error_with_detail(
1009 &IMWRITE_ERROR_ENCODE,
1010 format!("failed to decode GIF: {err}"),
1011 )
1012 })?;
1013 for frame in decoder.into_frames() {
1014 frames.push(frame.map_err(|err| {
1015 imwrite_error_with_detail(
1016 &IMWRITE_ERROR_ENCODE,
1017 format!("failed to decode GIF frame: {err}"),
1018 )
1019 })?);
1020 }
1021 }
1022 frames.push(gif_frame_from_image(image, invocation.options.delay_time)?);
1023
1024 let mut bytes = Vec::new();
1025 {
1026 let mut encoder = GifEncoder::new(&mut bytes);
1027 let repeat = if let Some(loop_count) = invocation.options.loop_count {
1028 Some(loop_count_to_repeat(loop_count)?)
1029 } else {
1030 existing_repeat
1031 };
1032 if let Some(repeat) = repeat {
1033 encoder.set_repeat(repeat).map_err(|err| {
1034 imwrite_error_with_detail(
1035 &IMWRITE_ERROR_ENCODE,
1036 format!("failed to set GIF repeat: {err}"),
1037 )
1038 })?;
1039 }
1040 for frame in frames {
1041 encoder.encode_frame(frame).map_err(|err| {
1042 imwrite_error_with_detail(
1043 &IMWRITE_ERROR_ENCODE,
1044 format!("failed to encode GIF frame: {err}"),
1045 )
1046 })?;
1047 }
1048 }
1049 Ok(bytes)
1050}
1051
1052fn gif_repeat_from_bytes(bytes: &[u8]) -> Option<Repeat> {
1053 const APP_EXT_PREFIX: &[u8] = b"\x21\xFF\x0BNETSCAPE2.0\x03\x01";
1054 bytes.windows(APP_EXT_PREFIX.len() + 3).find_map(|window| {
1055 if !window.starts_with(APP_EXT_PREFIX) || window[APP_EXT_PREFIX.len() + 2] != 0 {
1056 return None;
1057 }
1058 let lo = window[APP_EXT_PREFIX.len()];
1059 let hi = window[APP_EXT_PREFIX.len() + 1];
1060 let count = u16::from_le_bytes([lo, hi]);
1061 if count == 0 {
1062 Some(Repeat::Infinite)
1063 } else {
1064 Some(Repeat::Finite(count))
1065 }
1066 })
1067}
1068
1069fn loop_count_to_repeat(loop_count: f64) -> BuiltinResult<Repeat> {
1070 if loop_count.is_infinite() {
1071 return Ok(Repeat::Infinite);
1072 }
1073 let rounded = loop_count.round();
1074 if (rounded - loop_count).abs() > 1e-6 || rounded > u16::MAX as f64 {
1075 return Err(imwrite_error_with_detail(
1076 &IMWRITE_ERROR_INVALID_OPTION,
1077 "LoopCount must be an integer between 0 and 65535, or Inf",
1078 ));
1079 }
1080 Ok(Repeat::Finite(rounded as u16))
1081}
1082
1083fn gif_frame_from_image(
1084 image: &MaterializedImage,
1085 delay_time: Option<f64>,
1086) -> BuiltinResult<Frame> {
1087 let width = u32::try_from(image.cols).map_err(|_| {
1088 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image width is too large")
1089 })?;
1090 let height = u32::try_from(image.rows).map_err(|_| {
1091 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "image height is too large")
1092 })?;
1093
1094 let mut rgba = vec![0u8; image.rows * image.cols * 4];
1095 let data = image_data_as_u8(image);
1096 for pixel in 0..image.rows * image.cols {
1097 let dst = pixel * 4;
1098 match image.channels {
1099 1 => {
1100 let gray = data[pixel];
1101 rgba[dst] = gray;
1102 rgba[dst + 1] = gray;
1103 rgba[dst + 2] = gray;
1104 rgba[dst + 3] = 255;
1105 }
1106 3 => {
1107 let src = pixel * 3;
1108 rgba[dst..dst + 3].copy_from_slice(&data[src..src + 3]);
1109 rgba[dst + 3] = 255;
1110 }
1111 4 => {
1112 let src = pixel * 4;
1113 rgba[dst..dst + 4].copy_from_slice(&data[src..src + 4]);
1114 }
1115 _ => unreachable!(),
1116 }
1117 }
1118 let image = RgbaImage::from_raw(width, height, rgba).ok_or_else(|| {
1119 imwrite_error_with_detail(&IMWRITE_ERROR_INVALID_IMAGE, "invalid GIF frame buffer")
1120 })?;
1121 let delay = delay_time
1122 .map(|seconds| Delay::from_saturating_duration(Duration::from_secs_f64(seconds)))
1123 .unwrap_or_else(|| Delay::from_numer_denom_ms(0, 1));
1124 Ok(Frame::from_parts(image, 0, 0, delay))
1125}
1126
1127fn image_data_as_u8(image: &MaterializedImage) -> Vec<u8> {
1128 match &image.data {
1129 PixelData::U8(data) => data.clone(),
1130 PixelData::U16(data) => data
1131 .iter()
1132 .map(|value| ((*value as f64) / 257.0).round().clamp(0.0, 255.0) as u8)
1133 .collect(),
1134 }
1135}
1136
1137fn image_as_8bit(image: &MaterializedImage) -> MaterializedImage {
1138 MaterializedImage {
1139 rows: image.rows,
1140 cols: image.cols,
1141 channels: image.channels,
1142 data: PixelData::U8(image_data_as_u8(image)),
1143 }
1144}
1145
1146fn imwrite_error_with_detail(
1147 error: &'static BuiltinErrorDescriptor,
1148 message: impl Into<String>,
1149) -> RuntimeError {
1150 let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
1151 if let Some(identifier) = error.identifier {
1152 builder = builder.with_identifier(identifier);
1153 }
1154 builder.build()
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159 use super::*;
1160 use futures::executor::block_on;
1161 use image::io::Reader as ImageReader;
1162 use std::fs;
1163 use tempfile::tempdir;
1164
1165 fn tensor(data: Vec<f64>, shape: Vec<usize>, dtype: NumericDType) -> Tensor {
1166 Tensor::new_with_dtype(data, shape, dtype).expect("tensor")
1167 }
1168
1169 fn call(args: Vec<Value>) -> BuiltinResult<Value> {
1170 block_on(imwrite_builtin(args))
1171 }
1172
1173 #[test]
1174 fn writes_png_rgb_and_round_trips_layout() {
1175 let dir = tempdir().unwrap();
1176 let path = dir.path().join("rgb.png");
1177 let rgb = tensor(
1178 vec![255.0, 0.0, 0.0, 0.0, 0.0, 255.0],
1179 vec![1, 2, 3],
1180 NumericDType::U8,
1181 );
1182
1183 call(vec![
1184 Value::Tensor(rgb),
1185 Value::from(path.to_string_lossy().as_ref()),
1186 ])
1187 .unwrap();
1188
1189 let decoded = ImageReader::open(&path)
1190 .unwrap()
1191 .decode()
1192 .unwrap()
1193 .to_rgb8();
1194 assert_eq!(decoded.dimensions(), (2, 1));
1195 assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0]);
1196 assert_eq!(decoded.get_pixel(1, 0).0, [0, 0, 255]);
1197 }
1198
1199 #[test]
1200 fn writes_png_alpha_option() {
1201 let dir = tempdir().unwrap();
1202 let path = dir.path().join("alpha.png");
1203 let image = tensor(vec![1.0, 0.0, 0.0], vec![1, 1, 3], NumericDType::F64);
1204 let alpha = tensor(vec![0.5], vec![1, 1], NumericDType::F64);
1205
1206 call(vec![
1207 Value::Tensor(image),
1208 Value::from(path.to_string_lossy().as_ref()),
1209 Value::from("Alpha"),
1210 Value::Tensor(alpha),
1211 ])
1212 .unwrap();
1213
1214 let decoded = ImageReader::open(&path)
1215 .unwrap()
1216 .decode()
1217 .unwrap()
1218 .to_rgba8();
1219 assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128]);
1220 }
1221
1222 #[test]
1223 fn writes_uint16_png_without_downcasting() {
1224 let dir = tempdir().unwrap();
1225 let path = dir.path().join("gray16.png");
1226 let image = tensor(
1227 vec![0.0, 65535.0, 12345.0, 40000.0],
1228 vec![2, 2],
1229 NumericDType::U16,
1230 );
1231
1232 call(vec![
1233 Value::Tensor(image),
1234 Value::from(path.to_string_lossy().as_ref()),
1235 ])
1236 .unwrap();
1237
1238 let decoded = ImageReader::open(&path).unwrap().decode().unwrap();
1239 let gray = decoded.as_luma16().expect("expected 16-bit grayscale PNG");
1240 assert_eq!(gray.dimensions(), (2, 2));
1241 assert_eq!(gray.get_pixel(0, 0).0, [0]);
1242 assert_eq!(gray.get_pixel(0, 1).0, [65535]);
1243 assert_eq!(gray.get_pixel(1, 0).0, [12345]);
1244 assert_eq!(gray.get_pixel(1, 1).0, [40000]);
1245 }
1246
1247 #[test]
1248 fn writes_indexed_gif_with_zero_based_uint8_indices() {
1249 let dir = tempdir().unwrap();
1250 let path = dir.path().join("indexed.gif");
1251 let x = tensor(vec![0.0, 1.0], vec![1, 2], NumericDType::U8);
1252 let map = tensor(
1253 vec![255.0, 0.0, 0.0, 0.0, 0.0, 255.0],
1254 vec![2, 3],
1255 NumericDType::U8,
1256 );
1257
1258 call(vec![
1259 Value::Tensor(x),
1260 Value::Tensor(map),
1261 Value::from(path.to_string_lossy().as_ref()),
1262 ])
1263 .unwrap();
1264
1265 let decoded = ImageReader::open(&path)
1266 .unwrap()
1267 .decode()
1268 .unwrap()
1269 .to_rgb8();
1270 assert_eq!(decoded.dimensions(), (2, 1));
1271 assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0]);
1272 assert_eq!(decoded.get_pixel(1, 0).0, [0, 0, 255]);
1273 }
1274
1275 #[test]
1276 fn appends_gif_frame() {
1277 let dir = tempdir().unwrap();
1278 let path = dir.path().join("animated.gif");
1279 let first = tensor(vec![1.0, 0.0, 0.0], vec![1, 1, 3], NumericDType::F64);
1280 let second = tensor(vec![0.0, 1.0, 0.0], vec![1, 1, 3], NumericDType::F64);
1281
1282 call(vec![
1283 Value::Tensor(first),
1284 Value::from(path.to_string_lossy().as_ref()),
1285 Value::from("LoopCount"),
1286 Value::Num(f64::INFINITY),
1287 Value::from("DelayTime"),
1288 Value::Num(0.25),
1289 ])
1290 .unwrap();
1291 call(vec![
1292 Value::Tensor(second),
1293 Value::from(path.to_string_lossy().as_ref()),
1294 Value::from("WriteMode"),
1295 Value::from("append"),
1296 Value::from("DelayTime"),
1297 Value::Num(0.25),
1298 ])
1299 .unwrap();
1300
1301 let bytes = fs::read(&path).unwrap();
1302 assert!(matches!(
1303 gif_repeat_from_bytes(&bytes),
1304 Some(Repeat::Infinite)
1305 ));
1306 let decoder = GifDecoder::new(Cursor::new(bytes)).unwrap();
1307 let frames = decoder.into_frames().collect_frames().unwrap();
1308 assert_eq!(frames.len(), 2);
1309 }
1310
1311 #[test]
1312 fn rejects_alpha_for_jpeg() {
1313 let dir = tempdir().unwrap();
1314 let path = dir.path().join("bad.jpg");
1315 let image = tensor(vec![1.0, 0.0, 0.0], vec![1, 1, 3], NumericDType::F64);
1316 let alpha = tensor(vec![1.0], vec![1, 1], NumericDType::F64);
1317
1318 let err = call(vec![
1319 Value::Tensor(image),
1320 Value::from(path.to_string_lossy().as_ref()),
1321 Value::from("Alpha"),
1322 Value::Tensor(alpha),
1323 ])
1324 .unwrap_err();
1325 assert_eq!(err.identifier(), Some("RunMat:imwrite:InvalidOption"));
1326 }
1327
1328 #[test]
1329 fn descriptor_has_stable_errors() {
1330 let codes: Vec<&str> = IMWRITE_DESCRIPTOR
1331 .errors
1332 .iter()
1333 .map(|error| error.code)
1334 .collect();
1335 assert!(codes.contains(&"RM.IMWRITE.INVALID_IMAGE"));
1336 assert!(codes.contains(&"RM.IMWRITE.ENCODE"));
1337 }
1338}