///|
/// Append only complete bounded chunks. A failed conversion returns no image.
fn gif_append(
  out : Array[Byte],
  bytes : Bytes,
  start : Int32,
  end : Int32,
  limits : RasterLimits,
) -> Unit raise RasterFailure {
  guard start >= 0 &&
    end >= start &&
    end <= bytes.length() &&
    end - start <= limits.max_output_bytes - out.length() else {
    raise raster_fail(
      "raster/output-limit", "GIF exceeds the encoded byte budget",
    )
  }
  for i in start.. (Int32, Int32) raise RasterFailure {
  let signature = [b'G', b'I', b'F', b'8', b'9', b'a']
  guard encoded.length() > 801 && encoded[encoded.length() - 1] == b'\x3b' else {
    raise raster_fail(
      "raster/gif-encoding", "GIF encoder returned an incomplete frame",
    )
  }
  for i in 0.. RasterPixels raise RasterFailure,
) -> Bytes raise RasterFailure {
  guard delays.length() >= 2 && delays.length() <= limits.max_frames else {
    raise raster_fail(
      "raster/frame-limit", "GIF requires at least two bounded frames",
    )
  }
  for delay in delays {
    guard delay >= 1 && delay <= 65535 else {
      raise raster_fail(
        "raster/invalid-delay", "GIF delay is outside the centisecond range",
      )
    }
  }
  if loop_behavior is Repeat(count) && (count < 1 || count > 65535) {
    raise raster_fail(
      "raster/invalid-loop", "GIF repeat count must be between 1 and 65535",
    )
  }
  guard limits.max_output_bytes.to_int64() >=
    782L + delays.length().to_int64() * 20L else {
    raise raster_fail(
      "raster/output-limit", "GIF byte budget cannot hold its headers and frames",
    )
  }
  let pixels = checked_pixels(width, height, limits).to_int64()
  guard delays.length().to_int64() <= limits.max_cumulative_pixels / pixels else {
    raise raster_fail(
      "raster/cumulative-pixel-limit", "GIF exceeds the cumulative pixel budget",
    )
  }
  let out : Array[Byte] = []
  for index in 0..= 1 && delays[index] <= 65535 else {
      raise raster_fail(
        "raster/invalid-delay", "GIF delay is outside the centisecond range",
      )
    }
    let rendered = frame(index)
    guard rendered.width == width &&
      rendered.height == height &&
      rendered.data.length().to_int64() == pixels * 4L else {
      raise raster_fail(
        "raster/gif-encoding", "GIF frame pixels do not match the prepared dimensions",
      )
    }
    let encoded = @gif_codec.encode_gif_animation_pure({
      frames: [{ width, height, channels: 4, data: rendered.data, }],
      delays: [delays[index] * 10],
    }) catch {
      _ => raise raster_fail("raster/gif-encoding", "GIF frame encoding failed")
    }
    let (start, end) = gif_frame_block(encoded, width, height)
    if index == 0 {
      gif_append(out, encoded, 0, 781, limits)
      let count = match loop_behavior {
        Forever => Some(0)
        Repeat(count) => Some(count)
        Once => None
      }
      if count is Some(count) {
        guard count >= 0 && count <= 65535 else {
          raise raster_fail(
            "raster/invalid-loop", "GIF loop count exceeds its encoded range",
          )
        }
        let extension = Bytes::from_array([
          b'\x21',
          b'\xff',
          b'\x0b',
          b'N',
          b'E',
          b'T',
          b'S',
          b'C',
          b'A',
          b'P',
          b'E',
          b'2',
          b'.',
          b'0',
          b'\x03',
          b'\x01',
          (count & 255).to_byte(),
          ((count >> 8) & 255).to_byte(),
          b'\x00',
        ])
        gif_append(out, extension, 0, extension.length(), limits)
      }
    } else {
      for i in 0..<781 {
        guard out[i] == encoded[i] else {
          raise raster_fail("raster/gif-encoding", "GIF frame palettes differ")
        }
      }
    }
    gif_append(out, encoded, start, end, limits)
  }
  guard out.length() < limits.max_output_bytes else {
    raise raster_fail(
      "raster/output-limit", "GIF trailer exceeds the byte budget",
    )
  }
  out.push(b'\x3b')
  Bytes::from_array(out)
}

///|
/// Export a source-bound plan through the same generated-SVG conversion path
/// as PNG and JPEG. The selected interval includes an exact final logical view.
pub fn export_gif(
  scene : @svg.Scene,
  plan : @animation.PlaybackPlan,
  options~ : GifOptions = GifOptions::new(),
  fonts? : FontSet,
  limits~ : RasterLimits = RasterLimits::default(),
) -> RasterResult {
  let prepared = GifSampler::new(scene, plan, options~, limits~)
  guard prepared.sampler is Some(sampler) else {
    return { image: None, diagnostics: prepared.diagnostics, }
  }
  try {
    let fonts = fonts.unwrap_or_else(FontSet::default)
    let delays = sampler.times.iter().map(pair => pair.1).to_array()
    let bytes = encode_gif_frames(
      sampler.width,
      sampler.height,
      delays,
      options.loop_behavior,
      limits,
      index => {
        let sampled = sampler.frame(index)
        guard sampled.frame is Some(frame) else {
          raise raster_fail(
            "raster/frame-sampling", "GIF frame sampling failed",
          )
        }
        let source = gif_frame_svg(scene, frame, limits)
        render_rgba(
          source,
          sampler.width,
          sampler.height,
          options.theme,
          fonts,
          options.background,
          limits,
        )
      },
    )
    {
      image: Some({
        bytes,
        media_type: "image/gif",
        width: sampler.width,
        height: sampler.height,
        format: Gif,
      }),
      diagnostics: [],
    }
  } catch {
    error => { image: None, diagnostics: [raster_diagnostic(error)], }
  }
}