Skip to main content

runmat_runtime/builtins/image/
imwrite.rs

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        // GIF append is inherently read-modify-write with the current filesystem
994        // abstraction; providers do not expose a portable advisory/exclusive lock.
995        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}