Commit 5f6169ae for libheif

commit 5f6169aec0e6a7d39f9e1078653a3203093c98ae
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sat Sep 19 14:54:02 2026 +0200

    Let color-conversion operators declare the sample width they read

    The operators picked uint8_t or uint16_t samples from an 8-bit SDR/HDR split
    (bits_per_pixel > 8 meant "two bytes per sample"). That dates from when 16 bits
    was the maximum and is wrong for the 32-, 64- and 128-bit components that
    'unci' can carry: HeifPixelImage stores those with 4, 8 or 16 bytes per sample.
    Nothing wider than 16 bits could reach an operator, because each of them
    guarded against it separately through has_samples_wider_than_16bit() and
    convert_colorspace() has a backstop, but the sample type never followed the
    allocation, so those guards were load-bearing.

    Make the sample width explicit instead. bytes_per_sample_for_bit_depth() in
    pixelimage.h is now the single mapping from bit depth to storage width, used
    by plane allocation and by the operators, so the two cannot drift. ColorState
    gained get_bytes_per_sample(), get_max_bytes_per_sample(),
    color_channels_have_bytes_per_sample() and all_channels_have_bytes_per_sample(),
    and each operator declares in state_after_conversion() which width it can read
    (sizeof(Pixel) for the templated ones, 1 or 2 for the others). Strides are
    divided by the sample width instead of by a conditional 2, alpha rows are
    copied with their own width, and the operators that switch on the width at
    runtime refuse anything else. has_samples_wider_than_16bit() and the
    all_channels_sdr()/all_channels_hdr() helpers have no callers left and are
    removed; the check added for GHSA-r7gr-2xm2-23wf in Op_flatten_alpha_plane
    becomes all_channels_have_bytes_per_sample(sizeof(Pixel)).

    The 16-bit backstop and the YCbCr luma/chroma check in convert_colorspace()
    now also run when no pipeline could be built, so callers keep getting the
    specific error instead of the generic "unsupported color conversion".

    A new test builds 32-, 64- and 128-bit YCbCr, RGB and monochrome states and
    checks that the operators decline them and that no pipeline is constructible.

diff --git a/libheif/color-conversion/alpha.cc b/libheif/color-conversion/alpha.cc
index 21ded126..9055b70f 100644
--- a/libheif/color-conversion/alpha.cc
+++ b/libheif/color-conversion/alpha.cc
@@ -134,18 +134,12 @@ Op_flatten_alpha_plane<Pixel>::state_after_conversion(const ColorState& input_st
                                                       const heif_color_conversion_options& options,
                                                       const heif_color_conversion_options_ext& options_ext) const
 {
-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
   // The colour planes and the alpha plane are all read through the single 'Pixel' type
-  // below, so every plane must be SDR (one byte per sample) for the uint8_t instance and
-  // HDR (two bytes per sample) for the uint16_t instance. A file may declare a different
-  // depth per plane ('unci'); reading a one-byte plane as two-byte samples would run past
-  // its end (GHSA-r7gr-2xm2-23wf). Decline a mixture instead.
-  if (hdr ? !input_state.all_channels_hdr() : !input_state.all_channels_sdr()) {
-    return {};
-  }
-
-  if (has_samples_wider_than_16bit(input_state)) {
+  // below, so every plane must be stored with sizeof(Pixel) bytes per sample. A file may
+  // declare a different depth per plane ('unci'); reading a one-byte plane as two-byte
+  // samples would run past its end (GHSA-r7gr-2xm2-23wf), and a plane wider than two
+  // bytes cannot be read through either instance. Decline anything else.
+  if (!input_state.all_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
     return {};
   }

@@ -255,11 +249,9 @@ Op_flatten_alpha_plane<Pixel>::convert_colorspace(const std::shared_ptr<const He
     size_t stride_out;
     p_out = (Pixel*)outimg->get_channel_memory(channel, &stride_out);

-    if (sizeof(Pixel) == 2) {
-      stride_alpha /= 2;
-      stride_in /= 2;
-      stride_out /= 2;
-    }
+    stride_alpha /= sizeof(Pixel);
+    stride_in /= sizeof(Pixel);
+    stride_out /= sizeof(Pixel);

     if (options_ext.alpha_composition_mode == heif_alpha_composition_mode_solid_color ||
         (options_ext.alpha_composition_mode == heif_alpha_composition_mode_checkerboard && options_ext.checkerboard_square_size == 0)) {
@@ -374,7 +366,8 @@ Op_adjust_alpha_bit_depth::state_after_conversion(const ColorState& input_state,

   // Rewrites the alpha plane from its own bit depth to the colour bit depth, so both
   // ends have to be accessible as 8- or 16-bit samples.
-  if (has_samples_wider_than_16bit(input_state)) {
+  if (input_state.get_bytes_per_sample(heif_channel_Alpha) > 2 ||
+      bytes_per_sample_for_bit_depth(input_state.get_color_bits_per_pixel()) > 2) {
     return {};
   }

@@ -425,7 +418,10 @@ Op_adjust_alpha_bit_depth::convert_colorspace(const std::shared_ptr<const HeifPi
     return err;
   }

-  if (input_alpha_bpp <= 8 && target_bpp > 8) {
+  int input_bytes = bytes_per_sample_for_bit_depth(input_alpha_bpp);
+  int target_bytes = bytes_per_sample_for_bit_depth(target_bpp);
+
+  if (input_bytes == 1 && target_bytes == 2) {
     // Upscale: 8-bit alpha -> HDR using bit replication
     const uint8_t* p_in;
     size_t stride_in;
@@ -442,7 +438,7 @@ Op_adjust_alpha_bit_depth::convert_colorspace(const std::shared_ptr<const HeifPi
         p_out[y * stride_out + x] = (uint16_t) replicate_sample_bits(in, input_alpha_bpp, target_bpp);
       }
   }
-  else if (input_alpha_bpp > 8 && target_bpp <= 8) {
+  else if (input_bytes == 2 && target_bytes == 1) {
     // Downscale: HDR alpha -> 8-bit
     const uint16_t* p_in;
     size_t stride_in;
@@ -460,7 +456,7 @@ Op_adjust_alpha_bit_depth::convert_colorspace(const std::shared_ptr<const HeifPi
         p_out[y * stride_out + x] = (uint8_t) (p_in[y * stride_in + x] >> shift);
       }
   }
-  else if (input_alpha_bpp > 8 && target_bpp > 8) {
+  else if (input_bytes == 2 && target_bytes == 2) {
     // HDR alpha -> different HDR: rescale within uint16_t
     const uint16_t* p_in;
     size_t stride_in;
@@ -487,7 +483,7 @@ Op_adjust_alpha_bit_depth::convert_colorspace(const std::shared_ptr<const HeifPi
         }
     }
   }
-  else {
+  else if (input_bytes == 1 && target_bytes == 1) {
     // SDR alpha -> different SDR (both <= 8)
     const uint8_t* p_in;
     size_t stride_in;
@@ -512,6 +508,11 @@ Op_adjust_alpha_bit_depth::convert_colorspace(const std::shared_ptr<const HeifPi
         }
     }
   }
+  else {
+    return Error{heif_error_Unsupported_feature,
+                 heif_suberror_Unsupported_bit_depth,
+                 "Alpha bit depth adjustment only supports 8- and 16-bit sample storage."};
+  }

   return outimg;
 }
diff --git a/libheif/color-conversion/bayer_bilinear.cc b/libheif/color-conversion/bayer_bilinear.cc
index 941d81f8..5d7128e1 100644
--- a/libheif/color-conversion/bayer_bilinear.cc
+++ b/libheif/color-conversion/bayer_bilinear.cc
@@ -42,12 +42,13 @@ Op_bayer_bilinear_to_RGB24_32::state_after_conversion(const ColorState& input_st
   output_state.colorspace = heif_colorspace_RGB;

   int bpp = input_state.bits_per_pixel_filter_array;
+  int bytes_per_sample = input_state.get_bytes_per_sample(heif_channel_filter_array);

   if (bpp == 8) {
     output_state.chroma = heif_chroma_interleaved_RGB;
     output_state.set_color_bits_per_pixel(8);
   }
-  else if (bpp > 8 && bpp <= 16) {
+  else if (bytes_per_sample == 2) {
     output_state.chroma = heif_chroma_interleaved_RRGGBB_LE;
     output_state.set_color_bits_per_pixel(bpp);
   }
@@ -102,9 +103,13 @@ Op_bayer_bilinear_to_RGB24_32::convert_colorspace(const std::shared_ptr<const He
   }

   int bpp = input->get_bits_per_pixel(heif_channel_filter_array);
-  bool hdr = bpp > 8;
+  int bytes_per_sample = bytes_per_sample_for_bit_depth(bpp);

-  heif_chroma out_chroma = hdr ? heif_chroma_interleaved_RRGGBB_LE : heif_chroma_interleaved_RGB;
+  if (bytes_per_sample != 1 && bytes_per_sample != 2) {
+    return Error::InternalError;
+  }
+
+  heif_chroma out_chroma = (bytes_per_sample == 2) ? heif_chroma_interleaved_RRGGBB_LE : heif_chroma_interleaved_RGB;

   auto outimg = std::make_shared<HeifPixelImage>();

@@ -201,7 +206,7 @@ Op_bayer_bilinear_to_RGB24_32::convert_colorspace(const std::shared_ptr<const He
     }
   };

-  if (hdr) {
+  if (bytes_per_sample == 2) {
     demosaic(reinterpret_cast<const uint16_t*>(in_p),
              reinterpret_cast<uint16_t*>(out_p),
              in_stride / 2, out_stride / 2);
diff --git a/libheif/color-conversion/chroma_sampling.cc b/libheif/color-conversion/chroma_sampling.cc
index 511a05d5..2a2e82f8 100644
--- a/libheif/color-conversion/chroma_sampling.cc
+++ b/libheif/color-conversion/chroma_sampling.cc
@@ -43,13 +43,9 @@ Op_YCbCr444_to_YCbCr420_average<Pixel>::state_after_conversion(const ColorState&
     return {};
   }

-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
-  if ((input_state.bits_per_pixel_Y > 8) != hdr) {
-    return {};
-  }
-
-  if (has_samples_wider_than_16bit(input_state)) {
+  // The three colour planes are read through the same 'Pixel' type, so they must be stored
+  // with sizeof(Pixel) bytes per sample. The alpha plane is copied through at its own width.
+  if (!input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
     return {};
   }

@@ -88,8 +84,6 @@ Op_YCbCr444_to_YCbCr420_average<Pixel>::convert_colorspace(const std::shared_ptr
                                                            const heif_color_conversion_options_ext& options_ext,
                                                            const heif_security_limits* limits) const
 {
-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
   int bpp_y = input->get_bits_per_pixel(heif_channel_Y);
   int bpp_cb = input->get_bits_per_pixel(heif_channel_Cb);
   int bpp_cr = input->get_bits_per_pixel(heif_channel_Cr);
@@ -101,19 +95,10 @@ Op_YCbCr444_to_YCbCr420_average<Pixel>::convert_colorspace(const std::shared_ptr
     bpp_a = input->get_bits_per_pixel(heif_channel_Alpha);
   }

-  if (!hdr) {
-    if (bpp_y > 8 ||
-        bpp_cb > 8 ||
-        bpp_cr > 8) {
-      return Error::InternalError;
-    }
-  }
-  else {
-    if (bpp_y <= 8 ||
-        bpp_cb <= 8 ||
-        bpp_cr <= 8) {
-      return Error::InternalError;
-    }
+  if (bytes_per_sample_for_bit_depth(bpp_y) != static_cast<int>(sizeof(Pixel)) ||
+      bytes_per_sample_for_bit_depth(bpp_cb) != static_cast<int>(sizeof(Pixel)) ||
+      bytes_per_sample_for_bit_depth(bpp_cr) != static_cast<int>(sizeof(Pixel))) {
+    return Error::InternalError;
   }


@@ -159,14 +144,12 @@ Op_YCbCr444_to_YCbCr420_average<Pixel>::convert_colorspace(const std::shared_ptr
   out_cb = (Pixel*) outimg->get_channel_memory(heif_channel_Cb, &out_cb_stride);
   out_cr = (Pixel*) outimg->get_channel_memory(heif_channel_Cr, &out_cr_stride);

-  if (hdr) {
-    in_y_stride /= 2;
-    in_cb_stride /= 2;
-    in_cr_stride /= 2;
-    out_y_stride /= 2;
-    out_cb_stride /= 2;
-    out_cr_stride /= 2;
-  }
+  in_y_stride /= sizeof(Pixel);
+  in_cb_stride /= sizeof(Pixel);
+  in_cr_stride /= sizeof(Pixel);
+  out_y_stride /= sizeof(Pixel);
+  out_cb_stride /= sizeof(Pixel);
+  out_cr_stride /= sizeof(Pixel);


   // We only copy the alpha, do not access it as 16 bit
@@ -231,12 +214,12 @@ Op_YCbCr444_to_YCbCr420_average<Pixel>::convert_colorspace(const std::shared_ptr
   // TODO: check whether we can use HeifPixelImage::transfer_channel_from_image_as() instead of copying Y and Alpha

   for (y = 0; y < height; y++) {
-    uint32_t copyWidth = (hdr ? width * 2 : width);
+    size_t copyWidth = static_cast<size_t>(width) * sizeof(Pixel);

     memcpy(&out_y[y * out_y_stride], &in_y[y * in_y_stride], copyWidth);

     if (has_alpha) {
-      uint32_t alphaCopyWidth = (bpp_a > 8 ? width * 2 : width);
+      size_t alphaCopyWidth = static_cast<size_t>(width) * static_cast<size_t>(bytes_per_sample_for_bit_depth(bpp_a));
       memcpy(&out_a[y * out_a_stride], &in_a[y * in_a_stride], alphaCopyWidth);
     }
   }
@@ -270,13 +253,9 @@ Op_YCbCr444_to_YCbCr422_average<Pixel>::state_after_conversion(const ColorState&
     return {};
   }

-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
-  if ((input_state.bits_per_pixel_Y > 8) != hdr) {
-    return {};
-  }
-
-  if (has_samples_wider_than_16bit(input_state)) {
+  // The three colour planes are read through the same 'Pixel' type, so they must be stored
+  // with sizeof(Pixel) bytes per sample. The alpha plane is copied through at its own width.
+  if (!input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
     return {};
   }

@@ -315,8 +294,6 @@ Op_YCbCr444_to_YCbCr422_average<Pixel>::convert_colorspace(const std::shared_ptr
                                                            const heif_color_conversion_options_ext& options_ext,
                                                            const heif_security_limits* limits) const
 {
-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
   int bpp_y = input->get_bits_per_pixel(heif_channel_Y);
   int bpp_cb = input->get_bits_per_pixel(heif_channel_Cb);
   int bpp_cr = input->get_bits_per_pixel(heif_channel_Cr);
@@ -328,19 +305,10 @@ Op_YCbCr444_to_YCbCr422_average<Pixel>::convert_colorspace(const std::shared_ptr
     bpp_a = input->get_bits_per_pixel(heif_channel_Alpha);
   }

-  if (!hdr) {
-    if (bpp_y > 8 ||
-        bpp_cb > 8 ||
-        bpp_cr > 8) {
-      return Error::InternalError;
-    }
-  }
-  else {
-    if (bpp_y <= 8 ||
-        bpp_cb <= 8 ||
-        bpp_cr <= 8) {
-      return Error::InternalError;
-    }
+  if (bytes_per_sample_for_bit_depth(bpp_y) != static_cast<int>(sizeof(Pixel)) ||
+      bytes_per_sample_for_bit_depth(bpp_cb) != static_cast<int>(sizeof(Pixel)) ||
+      bytes_per_sample_for_bit_depth(bpp_cr) != static_cast<int>(sizeof(Pixel))) {
+    return Error::InternalError;
   }


@@ -399,14 +367,12 @@ Op_YCbCr444_to_YCbCr422_average<Pixel>::convert_colorspace(const std::shared_ptr
   }


-  if (hdr) {
-    in_y_stride /= 2;
-    in_cb_stride /= 2;
-    in_cr_stride /= 2;
-    out_y_stride /= 2;
-    out_cb_stride /= 2;
-    out_cr_stride /= 2;
-  }
+  in_y_stride /= sizeof(Pixel);
+  in_cb_stride /= sizeof(Pixel);
+  in_cr_stride /= sizeof(Pixel);
+  out_y_stride /= sizeof(Pixel);
+  out_cb_stride /= sizeof(Pixel);
+  out_cr_stride /= sizeof(Pixel);

   // --- fill right border if the image size is odd

@@ -436,12 +402,12 @@ Op_YCbCr444_to_YCbCr422_average<Pixel>::convert_colorspace(const std::shared_ptr
   // TODO: check whether we can use HeifPixelImage::transfer_channel_from_image_as() instead of copying Y and Alpha

   for (y = 0; y < height; y++) {
-    uint32_t copyWidth = (hdr ? width * 2 : width);
+    size_t copyWidth = static_cast<size_t>(width) * sizeof(Pixel);

     memcpy(&out_y[y * out_y_stride], &in_y[y * in_y_stride], copyWidth);

     if (has_alpha) {
-      uint32_t alphaCopyWidth = (bpp_a>8 ? width * 2 : width);
+      size_t alphaCopyWidth = static_cast<size_t>(width) * static_cast<size_t>(bytes_per_sample_for_bit_depth(bpp_a));
       memcpy(&out_a[y * out_a_stride], &in_a[y * in_a_stride], alphaCopyWidth);
     }
   }
@@ -475,13 +441,9 @@ Op_YCbCr420_bilinear_to_YCbCr444<Pixel>::state_after_conversion(const ColorState
     return {};
   }

-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
-  if ((input_state.bits_per_pixel_Y > 8) != hdr) {
-    return {};
-  }
-
-  if (has_samples_wider_than_16bit(input_state)) {
+  // The three colour planes are read through the same 'Pixel' type, so they must be stored
+  // with sizeof(Pixel) bytes per sample. The alpha plane is copied through at its own width.
+  if (!input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
     return {};
   }

@@ -516,8 +478,6 @@ Op_YCbCr420_bilinear_to_YCbCr444<Pixel>::convert_colorspace(const std::shared_pt
                                                             const heif_color_conversion_options_ext& options_ext,
                                                             const heif_security_limits* limits) const
 {
-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
   int bpp_y = input->get_bits_per_pixel(heif_channel_Y);
   int bpp_cb = input->get_bits_per_pixel(heif_channel_Cb);
   int bpp_cr = input->get_bits_per_pixel(heif_channel_Cr);
@@ -529,19 +489,10 @@ Op_YCbCr420_bilinear_to_YCbCr444<Pixel>::convert_colorspace(const std::shared_pt
     bpp_a = input->get_bits_per_pixel(heif_channel_Alpha);
   }

-  if (!hdr) {
-    if (bpp_y > 8 ||
-        bpp_cb > 8 ||
-        bpp_cr > 8) {
-      return Error::InternalError;
-    }
-  }
-  else {
-    if (bpp_y <= 8 ||
-        bpp_cb <= 8 ||
-        bpp_cr <= 8) {
-      return Error::InternalError;
-    }
+  if (bytes_per_sample_for_bit_depth(bpp_y) != static_cast<int>(sizeof(Pixel)) ||
+      bytes_per_sample_for_bit_depth(bpp_cb) != static_cast<int>(sizeof(Pixel)) ||
+      bytes_per_sample_for_bit_depth(bpp_cr) != static_cast<int>(sizeof(Pixel))) {
+    return Error::InternalError;
   }


@@ -597,14 +548,12 @@ Op_YCbCr420_bilinear_to_YCbCr444<Pixel>::convert_colorspace(const std::shared_pt
   }


-  if (hdr) {
-    in_y_stride /= 2;
-    in_cb_stride /= 2;
-    in_cr_stride /= 2;
-    out_y_stride /= 2;
-    out_cb_stride /= 2;
-    out_cr_stride /= 2;
-  }
+  in_y_stride /= sizeof(Pixel);
+  in_cb_stride /= sizeof(Pixel);
+  in_cr_stride /= sizeof(Pixel);
+  out_y_stride /= sizeof(Pixel);
+  out_cb_stride /= sizeof(Pixel);
+  out_cr_stride /= sizeof(Pixel);

   /*
    *  We assume that chroma pixels are located in the center of 2x2 luma pixels.
@@ -719,12 +668,12 @@ Op_YCbCr420_bilinear_to_YCbCr444<Pixel>::convert_colorspace(const std::shared_pt
   // TODO: check whether we can use HeifPixelImage::transfer_channel_from_image_as() instead of copying Y and Alpha

   for (y = 0; y < height; y++) {
-    uint32_t copyWidth = (hdr ? width * 2 : width);
+    size_t copyWidth = static_cast<size_t>(width) * sizeof(Pixel);

     memcpy(&out_y[y * out_y_stride], &in_y[y * in_y_stride], copyWidth);

     if (has_alpha) {
-      uint32_t alphaCopyWidth = (bpp_a > 8 ? width * 2 : width);
+      size_t alphaCopyWidth = static_cast<size_t>(width) * static_cast<size_t>(bytes_per_sample_for_bit_depth(bpp_a));
       memcpy(&out_a[y * out_a_stride], &in_a[y * in_a_stride], alphaCopyWidth);
     }
   }
@@ -759,13 +708,9 @@ Op_YCbCr422_bilinear_to_YCbCr444<Pixel>::state_after_conversion(const ColorState
     return {};
   }

-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
-  if ((input_state.bits_per_pixel_Y > 8) != hdr) {
-    return {};
-  }
-
-  if (has_samples_wider_than_16bit(input_state)) {
+  // The three colour planes are read through the same 'Pixel' type, so they must be stored
+  // with sizeof(Pixel) bytes per sample. The alpha plane is copied through at its own width.
+  if (!input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
     return {};
   }

@@ -800,8 +745,6 @@ Op_YCbCr422_bilinear_to_YCbCr444<Pixel>::convert_colorspace(const std::shared_pt
                                                             const heif_color_conversion_options_ext& options_ext,
                                                             const heif_security_limits* limits) const
 {
-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
   int bpp_y = input->get_bits_per_pixel(heif_channel_Y);
   int bpp_cb = input->get_bits_per_pixel(heif_channel_Cb);
   int bpp_cr = input->get_bits_per_pixel(heif_channel_Cr);
@@ -813,19 +756,10 @@ Op_YCbCr422_bilinear_to_YCbCr444<Pixel>::convert_colorspace(const std::shared_pt
     bpp_a = input->get_bits_per_pixel(heif_channel_Alpha);
   }

-  if (!hdr) {
-    if (bpp_y > 8 ||
-        bpp_cb > 8 ||
-        bpp_cr > 8) {
-      return Error::InternalError;
-    }
-  }
-  else {
-    if (bpp_y <= 8 ||
-        bpp_cb <= 8 ||
-        bpp_cr <= 8) {
-      return Error::InternalError;
-    }
+  if (bytes_per_sample_for_bit_depth(bpp_y) != static_cast<int>(sizeof(Pixel)) ||
+      bytes_per_sample_for_bit_depth(bpp_cb) != static_cast<int>(sizeof(Pixel)) ||
+      bytes_per_sample_for_bit_depth(bpp_cr) != static_cast<int>(sizeof(Pixel))) {
+    return Error::InternalError;
   }


@@ -881,14 +815,12 @@ Op_YCbCr422_bilinear_to_YCbCr444<Pixel>::convert_colorspace(const std::shared_pt
   }


-  if (hdr) {
-    in_y_stride /= 2;
-    in_cb_stride /= 2;
-    in_cr_stride /= 2;
-    out_y_stride /= 2;
-    out_cb_stride /= 2;
-    out_cr_stride /= 2;
-  }
+  in_y_stride /= sizeof(Pixel);
+  in_cb_stride /= sizeof(Pixel);
+  in_cr_stride /= sizeof(Pixel);
+  out_y_stride /= sizeof(Pixel);
+  out_cb_stride /= sizeof(Pixel);
+  out_cr_stride /= sizeof(Pixel);

   /*
    *  We assume that chroma pixels are located in the center of 2x1 luma pixels.
@@ -949,12 +881,12 @@ Op_YCbCr422_bilinear_to_YCbCr444<Pixel>::convert_colorspace(const std::shared_pt
   // TODO: check whether we can use HeifPixelImage::transfer_channel_from_image_as() instead of copying Y and Alpha

   for (y = 0; y < height; y++) {
-    uint32_t copyWidth = (hdr ? width * 2 : width);
+    size_t copyWidth = static_cast<size_t>(width) * sizeof(Pixel);

     memcpy(&out_y[y * out_y_stride], &in_y[y * in_y_stride], copyWidth);

     if (has_alpha) {
-      uint32_t alphaCopyWidth = (bpp_a > 8 ? width * 2 : width);
+      size_t alphaCopyWidth = static_cast<size_t>(width) * static_cast<size_t>(bytes_per_sample_for_bit_depth(bpp_a));
       memcpy(&out_a[y * out_a_stride], &in_a[y * in_a_stride], alphaCopyWidth);
     }
   }
diff --git a/libheif/color-conversion/colorconversion.cc b/libheif/color-conversion/colorconversion.cc
index ae1e1b36..6d2a06be 100644
--- a/libheif/color-conversion/colorconversion.cc
+++ b/libheif/color-conversion/colorconversion.cc
@@ -326,15 +326,34 @@ bool ColorState::all_channels_have_same_bpp() const
 }


-bool ColorState::all_channels_sdr() const
+int ColorState::get_bytes_per_sample(heif_channel channel) const
 {
-  return all_existing_planes_satisfy(*this, true, [](int bpp) { return bpp <= 8; });
+  int bpp = get_bits_per_pixel(channel);
+  return bpp != 0 ? bytes_per_sample_for_bit_depth(bpp) : 0;
 }


-bool ColorState::all_channels_hdr() const
+int ColorState::get_max_bytes_per_sample() const
 {
-  return all_existing_planes_satisfy(*this, true, [](int bpp) { return bpp > 8; });
+  // The bit depth to sample width mapping is monotonic, so the widest plane is the deepest.
+  int max_bpp = get_max_bits_per_pixel();
+  return max_bpp != 0 ? bytes_per_sample_for_bit_depth(max_bpp) : 0;
+}
+
+
+bool ColorState::color_channels_have_bytes_per_sample(int bytes) const
+{
+  return all_existing_planes_satisfy(*this, false, [bytes](int bpp) {
+    return bytes_per_sample_for_bit_depth(bpp) == bytes;
+  });
+}
+
+
+bool ColorState::all_channels_have_bytes_per_sample(int bytes) const
+{
+  return all_existing_planes_satisfy(*this, true, [bytes](int bpp) {
+    return bytes_per_sample_for_bit_depth(bpp) == bytes;
+  });
 }


@@ -848,15 +867,16 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr

   ColorConversionPipeline pipeline;
   bool success = pipeline.construct_pipeline(input_state, output_state, options, *options_ext);
-  if (!success) {
-    return Error{heif_error_Unsupported_feature,
-                 heif_suberror_Unsupported_color_conversion};
-  }

-  if (pipeline.is_nop()) {
+  if (success && pipeline.is_nop()) {
     return input;
   }
-  else {
+
+  {
+    // The two checks below also run when no pipeline could be built, so that the caller
+    // gets the specific reason (a plane wider than 16 bits, or YCbCr planes of differing
+    // depth) instead of the generic "unsupported color conversion" error.
+    //
     // Every color-conversion operator is written for 8-bit or 16-bit integer samples.
     // They access the planes through uint8_t* / uint16_t* and derive shift amounts and
     // midpoint values from the bit depth (e.g. '128 << (bpp - 8)' in Op_mono_to_YCbCr420).
@@ -868,11 +888,12 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
     // the image through untouched, so wide components stay accessible to the caller.
     //
     // This is a backstop, not the primary defence. The constraint belongs in each
-    // operator's state_after_conversion(), and every operator now declares it there
-    // (most through has_samples_wider_than_16bit()), so construct_pipeline() above
-    // already fails for a wider input and a real conversion never reaches this loop.
-    // Keep it until an operator actually supports more than 16 bits per component,
-    // then remove it together with that operator's call to the helper.
+    // operator's state_after_conversion(), and every operator declares there which
+    // sample width it can read (ColorState::color_channels_have_bytes_per_sample() and
+    // friends, derived from the same bit depth to storage width mapping that
+    // HeifPixelImage uses), so construct_pipeline() above already fails for a wider
+    // input and a real conversion never reaches this loop. Keep it until an operator
+    // actually supports more than 16 bits per component, then remove it.

     for (heif_channel channel : channels) {
       if (input->get_bits_per_pixel(channel) > 16) {
@@ -904,9 +925,14 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
                      "Color conversion of YCbCr images with differing luma and chroma bit depths is not supported."};
       }
     }
+  }

-    return pipeline.convert_image(input, limits);
+  if (!success) {
+    return Error{heif_error_Unsupported_feature,
+                 heif_suberror_Unsupported_color_conversion};
   }
+
+  return pipeline.convert_image(input, limits);
 }


diff --git a/libheif/color-conversion/colorconversion.h b/libheif/color-conversion/colorconversion.h
index 9621e38d..169bdf8a 100644
--- a/libheif/color-conversion/colorconversion.h
+++ b/libheif/color-conversion/colorconversion.h
@@ -84,11 +84,19 @@ struct ColorState
   // True if all existing planes, including alpha, have the same bit depth.
   bool all_channels_have_same_bpp() const;

-  // True if all existing planes, including alpha, have at most 8 bits.
-  bool all_channels_sdr() const;
+  // Number of bytes HeifPixelImage stores per sample of the given plane (1, 2, 4, 8 or 16),
+  // 0 if the plane does not exist. Operators access samples through uint8_t or uint16_t
+  // pointers, so they declare the sample width they can handle, not just a bit depth range.
+  int get_bytes_per_sample(heif_channel channel) const;

-  // True if all existing planes, including alpha, have more than 8 bits.
-  bool all_channels_hdr() const;
+  // Largest sample width over all existing planes, including alpha.
+  int get_max_bytes_per_sample() const;
+
+  // True if all existing colour planes (R/G/B or Y/Cb/Cr) are stored with 'bytes' per sample.
+  bool color_channels_have_bytes_per_sample(int bytes) const;
+
+  // True if all existing planes, including alpha, are stored with 'bytes' per sample.
+  bool all_channels_have_bytes_per_sample(int bytes) const;

   bool operator==(const ColorState&) const;
 };
@@ -96,24 +104,13 @@ struct ColorState
 std::ostream& operator<<(std::ostream& ostr, const ColorState& state);


-// True if 'state' has a colour or alpha component wider than 16 bits.
-//
-// Every conversion operator reads and writes sample data through uint8_t* or uint16_t*
-// and derives shift amounts from the bit depth, so none of them can handle a wider
-// component. Images with wider components do exist: 'unci' components may be up to 256
-// bits and we store up to 128 of them (64-bit integers, 32/64-bit floats, complex
-// numbers) so that they can be read through the component API.
-//
-// An operator that cannot handle a bit depth must not offer itself to the pipeline for
-// it, so each operator states its own supported range in state_after_conversion(). This
-// helper spells out the upper bound they currently all share; operators with a tighter
-// or different range (an exact 8 bits, or an explicit list) say so themselves instead.
-// When an operator gains support for wider samples it simply stops calling this, and the
-// catch-all in convert_colorspace() can go away.
-inline bool has_samples_wider_than_16bit(const ColorState& state)
-{
-  return state.get_max_bits_per_pixel() > 16;
-}
+// Note on sample widths: HeifPixelImage stores a plane with 1, 2, 4, 8 or 16 bytes per
+// sample depending on its bit depth (bytes_per_sample_for_bit_depth() in pixelimage.h);
+// 'unci' components may be up to 128 bits wide. Every conversion operator, however, reads
+// samples through uint8_t* or uint16_t*. An operator therefore declares in
+// state_after_conversion() which sample width it accepts (ColorState::
+// color_channels_have_bytes_per_sample() and friends) instead of relying on an 8-bit
+// SDR/HDR split, which would misread any plane wider than 16 bits.

 // These are some integer constants for typical color conversion Op speed costs.
 // The integer value is the speed cost. Any other integer can be assigned to the speed cost.
diff --git a/libheif/color-conversion/hdr_sdr.cc b/libheif/color-conversion/hdr_sdr.cc
index ef16fb3b..ffffd5c5 100644
--- a/libheif/color-conversion/hdr_sdr.cc
+++ b/libheif/color-conversion/hdr_sdr.cc
@@ -155,9 +155,8 @@ Op_to_sdr_planes::state_after_conversion(const ColorState& input_state,
     return {};
   }

-  // Every channel, alpha included, is read through a uint16_t* and shifted down by
-  // (bits_per_pixel - 8).
-  if (has_samples_wider_than_16bit(input_state)) {
+  // Every channel, alpha included, is read as uint8_t or uint16_t samples.
+  if (input_state.get_max_bytes_per_sample() > 2) {
     return {};
   }

@@ -204,8 +203,9 @@ Op_to_sdr_planes::convert_colorspace(const std::shared_ptr<const HeifPixelImage>
                                heif_channel_Alpha}) {
     if (input->has_channel(channel)) {
       int input_bits = input->get_bits_per_pixel(channel);
+      int input_bytes = bytes_per_sample_for_bit_depth(input_bits);

-      if (input_bits > 8) {
+      if (input_bytes == 2) {
         uint32_t width = input->get_width(channel);
         uint32_t height = input->get_height(channel);
         if (auto err = outimg->add_channel(channel, width, height, 8, limits)) {
@@ -270,8 +270,12 @@ Op_to_sdr_planes::convert_colorspace(const std::shared_ptr<const HeifPixelImage>
             int in = p_in[y * stride_in + x];
             p_out[y * stride_out + x] = (uint8_t) ((in * mulFactor) >> 8);
           }
-      } else {
+      } else if (input_bits == 8) {
         outimg->copy_new_channel_from(input, channel, channel, limits);
+      } else {
+        return Error{heif_error_Unsupported_feature,
+                     heif_suberror_Unsupported_bit_depth,
+                     "Op_to_sdr_planes: only 8- and 16-bit sample storage is supported"};
       }
     }
   }
diff --git a/libheif/color-conversion/monochrome.cc b/libheif/color-conversion/monochrome.cc
index acce06ba..7a572fdc 100644
--- a/libheif/color-conversion/monochrome.cc
+++ b/libheif/color-conversion/monochrome.cc
@@ -33,7 +33,8 @@ Op_mono_to_YCbCr420::state_after_conversion(const ColorState& input_state,
     return {};
   }

-  if (has_samples_wider_than_16bit(input_state)) {
+  // The luma plane is read as uint8_t or uint16_t samples; alpha is copied at its own width.
+  if (input_state.get_bytes_per_sample(heif_channel_Y) > 2) {
     return {};
   }

@@ -90,7 +91,9 @@ Op_mono_to_YCbCr420::convert_colorspace(const std::shared_ptr<const HeifPixelIma
   }


-  if (input_bpp <= 8) {
+  int bytes_per_sample = bytes_per_sample_for_bit_depth(input_bpp);
+
+  if (bytes_per_sample == 1) {
     uint8_t* out_cb, * out_cr, * out_y;
     size_t out_cb_stride = 0, out_cr_stride = 0, out_y_stride = 0;

@@ -114,7 +117,7 @@ Op_mono_to_YCbCr420::convert_colorspace(const std::shared_ptr<const HeifPixelIma
              width);
     }
   }
-  else {
+  else if (bytes_per_sample == 2) {
     uint16_t* out_cb, * out_cr, * out_y;
     size_t out_cb_stride = 0, out_cr_stride = 0, out_y_stride = 0;

@@ -144,6 +147,11 @@ Op_mono_to_YCbCr420::convert_colorspace(const std::shared_ptr<const HeifPixelIma
              width * 2);
     }
   }
+  else {
+    return Error{heif_error_Unsupported_feature,
+                 heif_suberror_Unsupported_bit_depth,
+                 "Monochrome to YCbCr conversion only supports 8- and 16-bit sample storage."};
+  }

   if (has_alpha) {
     const uint8_t* in_a;
@@ -154,7 +162,7 @@ Op_mono_to_YCbCr420::convert_colorspace(const std::shared_ptr<const HeifPixelIma
     in_a = input->get_channel_memory(heif_channel_Alpha, &in_a_stride);
     out_a = outimg->get_channel_memory(heif_channel_Alpha, &out_a_stride);

-    uint32_t memory_width = (alpha_bpp > 8 ? width * 2 : width);
+    size_t memory_width = static_cast<size_t>(width) * static_cast<size_t>(bytes_per_sample_for_bit_depth(alpha_bpp));

     for (uint32_t y = 0; y < height; y++) {
       memcpy(&out_a[y * out_a_stride], &in_a[y * in_a_stride], memory_width);
diff --git a/libheif/color-conversion/rgb2rgb.cc b/libheif/color-conversion/rgb2rgb.cc
index 21a7f19d..3442dc97 100644
--- a/libheif/color-conversion/rgb2rgb.cc
+++ b/libheif/color-conversion/rgb2rgb.cc
@@ -169,7 +169,8 @@ Op_RGB_HDR_to_RRGGBBaa_BE::state_after_conversion(const ColorState& input_state,
     return {};
   }

-  if (has_samples_wider_than_16bit(input_state)) {
+  // All planes, alpha included, are read as uint16_t samples.
+  if (!input_state.all_channels_have_bytes_per_sample(2)) {
     return {};
   }

@@ -220,9 +221,9 @@ Op_RGB_HDR_to_RRGGBBaa_BE::convert_colorspace(const std::shared_ptr<const HeifPi
                                               const heif_color_conversion_options_ext& options_ext,
                                               const heif_security_limits* limits) const
 {
-  if (input->get_bits_per_pixel(heif_channel_R) <= 8 ||
-      input->get_bits_per_pixel(heif_channel_G) <= 8 ||
-      input->get_bits_per_pixel(heif_channel_B) <= 8) {
+  if (bytes_per_sample_for_bit_depth(input->get_bits_per_pixel(heif_channel_R)) != 2 ||
+      bytes_per_sample_for_bit_depth(input->get_bits_per_pixel(heif_channel_G)) != 2 ||
+      bytes_per_sample_for_bit_depth(input->get_bits_per_pixel(heif_channel_B)) != 2) {
     return Error::InternalError;
   }

@@ -230,7 +231,7 @@ Op_RGB_HDR_to_RRGGBBaa_BE::convert_colorspace(const std::shared_ptr<const HeifPi
   bool output_has_alpha = input_has_alpha || target_state.has_alpha();

   if (input_has_alpha) {
-    if (input->get_bits_per_pixel(heif_channel_Alpha) <= 8) {
+    if (bytes_per_sample_for_bit_depth(input->get_bits_per_pixel(heif_channel_Alpha)) != 2) {
       return Error::InternalError;
     }

@@ -468,7 +469,8 @@ Op_RRGGBBaa_BE_to_RGB_HDR::state_after_conversion(const ColorState& input_state,
     return {};
   }

-  if (has_samples_wider_than_16bit(input_state)) {
+  // Interleaved RRGGBB samples are two bytes each.
+  if (!input_state.color_channels_have_bytes_per_sample(2)) {
     return {};
   }

@@ -689,7 +691,7 @@ Op_RRGGBBaa_swap_endianness::state_after_conversion(const ColorState& input_stat

   // Swaps the two bytes of each component, which is only meaningful for components
   // that are stored in 16 bits.
-  if (has_samples_wider_than_16bit(input_state)) {
+  if (!input_state.color_channels_have_bytes_per_sample(2)) {
     return {};
   }

diff --git a/libheif/color-conversion/rgb2yuv.cc b/libheif/color-conversion/rgb2yuv.cc
index b392fa70..4dee7697 100644
--- a/libheif/color-conversion/rgb2yuv.cc
+++ b/libheif/color-conversion/rgb2yuv.cc
@@ -34,24 +34,16 @@ Op_RGB_to_YCbCr<Pixel>::state_after_conversion(const ColorState& input_state,
                                                const heif_color_conversion_options& options,
                                                const heif_color_conversion_options_ext& options_ext) const
 {
-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
   if (input_state.colorspace != heif_colorspace_RGB ||
       input_state.chroma != heif_chroma_444) {
     return {};
   }

-  if ((input_state.bits_per_pixel_R > 8) != hdr) {
-    return {};
-  }
-
   // All three colour planes are read through the same 'Pixel' type and converted with one
-  // set of range constants, so they must share one bit depth.
-  if (!input_state.color_channels_have_same_bpp()) {
-    return {};
-  }
-
-  if (has_samples_wider_than_16bit(input_state)) {
+  // set of range constants, so they must share one bit depth and be stored with
+  // sizeof(Pixel) bytes per sample.
+  if (!input_state.color_channels_have_same_bpp() ||
+      !input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
     return {};
   }

@@ -116,8 +108,6 @@ Op_RGB_to_YCbCr<Pixel>::convert_colorspace(const std::shared_ptr<const HeifPixel
                                            const heif_color_conversion_options_ext& options_ext,
                                            const heif_security_limits* limits) const
 {
-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
   uint32_t width = input->get_width();
   uint32_t height = input->get_height();

@@ -126,7 +116,7 @@ Op_RGB_to_YCbCr<Pixel>::convert_colorspace(const std::shared_ptr<const HeifPixel
   int subV = chroma_v_subsampling(chroma);

   int bpp = input->get_bits_per_pixel(heif_channel_R);
-  if (bpp < 8 || (bpp > 8) != hdr) {
+  if (bpp < 8 || bytes_per_sample_for_bit_depth(bpp) != static_cast<int>(sizeof(Pixel))) {
     return Error::InternalError;
   }

@@ -188,14 +178,12 @@ Op_RGB_to_YCbCr<Pixel>::convert_colorspace(const std::shared_ptr<const HeifPixel
     out_a = nullptr;
   }

-  if (hdr) {
-    in_r_stride /= 2;
-    in_g_stride /= 2;
-    in_b_stride /= 2;
-    out_y_stride /= 2;
-    out_cb_stride /= 2;
-    out_cr_stride /= 2;
-  }
+  in_r_stride /= sizeof(Pixel);
+  in_g_stride /= sizeof(Pixel);
+  in_b_stride /= sizeof(Pixel);
+  out_y_stride /= sizeof(Pixel);
+  out_cb_stride /= sizeof(Pixel);
+  out_cr_stride /= sizeof(Pixel);

   uint16_t halfRange = (uint16_t) (1 << (bpp - 1));
   int32_t fullRange = (1 << bpp) - 1;
@@ -312,7 +300,7 @@ Op_RGB_to_YCbCr<Pixel>::convert_colorspace(const std::shared_ptr<const HeifPixel

   if (has_alpha) {
     int bpp_a = input->get_bits_per_pixel(heif_channel_Alpha);
-    int alphaCopyWidth = (bpp_a > 8 ? width * 2 : width);
+    size_t alphaCopyWidth = static_cast<size_t>(width) * static_cast<size_t>(bytes_per_sample_for_bit_depth(bpp_a));

     for (y = 0; y < height; y++) {
       memcpy(&out_a[y * out_a_stride], &in_a[y * in_a_stride], alphaCopyWidth);
@@ -350,7 +338,8 @@ Op_RRGGBBxx_HDR_to_YCbCr420::state_after_conversion(const ColorState& input_stat
     return {};
   }

-  if (has_samples_wider_than_16bit(input_state)) {
+  // The interleaved samples are assembled from two bytes each.
+  if (!input_state.color_channels_have_bytes_per_sample(2)) {
     return {};
   }

diff --git a/libheif/color-conversion/rgb2yuv_sharp.cc b/libheif/color-conversion/rgb2yuv_sharp.cc
index a7b3cf56..b761a8f8 100644
--- a/libheif/color-conversion/rgb2yuv_sharp.cc
+++ b/libheif/color-conversion/rgb2yuv_sharp.cc
@@ -187,13 +187,9 @@ Op_Any_RGB_to_YCbCr_420_Sharp::convert_colorspace(
     }
   }

-  int input_bytes_per_sample =
-      (input_chroma == heif_chroma_interleaved_RGB ||
-       input_chroma == heif_chroma_interleaved_RGBA ||
-       (input_chroma == heif_chroma_444 &&
-        input->get_bits_per_pixel(heif_channel_R) <= 8))
-      ? 1
-      : 2;
+  int input_bytes_per_sample = bytes_per_sample_for_bit_depth(
+      input->get_bits_per_pixel(input_chroma == heif_chroma_444 ? heif_channel_R
+                                                                 : heif_channel_interleaved));

   const uint8_t* in_r, * in_g, * in_b, * in_a = nullptr;
   size_t in_stride = 0;
diff --git a/libheif/color-conversion/yuv2rgb.cc b/libheif/color-conversion/yuv2rgb.cc
index d5e1cf34..3772ac3f 100644
--- a/libheif/color-conversion/yuv2rgb.cc
+++ b/libheif/color-conversion/yuv2rgb.cc
@@ -58,9 +58,9 @@ Op_YCbCr_to_RGB<Pixel>::state_after_conversion(const ColorState& input_state,
   //   BT.2020 NCL. A correct CL path needs EOTF inversion on Cb/Cr, not the linear matrix below.


-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
-  if ((input_state.bits_per_pixel_Y > 8) != hdr) {
+  // The three colour planes are read through the same 'Pixel' type, so they must be stored
+  // with sizeof(Pixel) bytes per sample. The alpha plane is copied through at its own width.
+  if (!input_state.color_channels_have_bytes_per_sample(static_cast<int>(sizeof(Pixel)))) {
     return {};
   }

@@ -75,11 +75,6 @@ Op_YCbCr_to_RGB<Pixel>::state_after_conversion(const ColorState& input_state,
     return {};
   }

-  // Also covers the alpha plane, which is copied through at its own bit depth below.
-  if (has_samples_wider_than_16bit(input_state)) {
-    return {};
-  }
-
   std::vector<ColorStateWithCost> states;

   ColorState output_state;
@@ -106,8 +101,6 @@ Op_YCbCr_to_RGB<Pixel>::convert_colorspace(const std::shared_ptr<const HeifPixel
                                            const heif_color_conversion_options_ext& options_ext,
                                            const heif_security_limits* limits) const
 {
-  bool hdr = !std::is_same<Pixel, uint8_t>::value;
-
   heif_chroma chroma = input->get_chroma_format();

   int bpp_y = input->get_bits_per_pixel(heif_channel_Y);
@@ -121,19 +114,10 @@ Op_YCbCr_to_RGB<Pixel>::convert_colorspace(const std::shared_ptr<const HeifPixel
     bpp_a = input->get_bits_per_pixel(heif_channel_Alpha);
   }

-  if (!hdr) {
-    if (bpp_y != 8 ||
-        bpp_cb != 8 ||
-        bpp_cr != 8) {
-      return Error::InternalError;
-    }
-  }
-  else {
-    if (bpp_y == 8 ||
-        bpp_cb == 8 ||
-        bpp_cr == 8) {
-      return Error::InternalError;
-    }
+  if (bytes_per_sample_for_bit_depth(bpp_y) != static_cast<int>(sizeof(Pixel)) ||
+      bytes_per_sample_for_bit_depth(bpp_cb) != static_cast<int>(sizeof(Pixel)) ||
+      bytes_per_sample_for_bit_depth(bpp_cr) != static_cast<int>(sizeof(Pixel))) {
+    return Error::InternalError;
   }


@@ -201,14 +185,12 @@ Op_YCbCr_to_RGB<Pixel>::convert_colorspace(const std::shared_ptr<const HeifPixel
   int shiftH = chroma_h_subsampling(chroma) - 1;
   int shiftV = chroma_v_subsampling(chroma) - 1;

-  if (hdr) {
-    in_y_stride /= 2;
-    in_cb_stride /= 2;
-    in_cr_stride /= 2;
-    out_r_stride /= 2;
-    out_g_stride /= 2;
-    out_b_stride /= 2;
-  }
+  in_y_stride /= sizeof(Pixel);
+  in_cb_stride /= sizeof(Pixel);
+  in_cr_stride /= sizeof(Pixel);
+  out_r_stride /= sizeof(Pixel);
+  out_g_stride /= sizeof(Pixel);
+  out_b_stride /= sizeof(Pixel);

   int matrix_coeffs = 2;
   bool full_range_flag = true;
@@ -297,7 +279,7 @@ Op_YCbCr_to_RGB<Pixel>::convert_colorspace(const std::shared_ptr<const HeifPixel
     }

     if (has_alpha) {
-      int alphaCopyWidth = (bpp_a>8 ? width * 2 : width);
+      size_t alphaCopyWidth = static_cast<size_t>(width) * static_cast<size_t>(bytes_per_sample_for_bit_depth(bpp_a));
       memcpy(&out_a[y * out_a_stride], &in_a[y * in_a_stride], alphaCopyWidth);
     }
   }
@@ -597,7 +579,8 @@ Op_YCbCr420_to_RRGGBBaa::state_after_conversion(const ColorState& input_state,
     return {};
   }

-  if (has_samples_wider_than_16bit(input_state)) {
+  // All planes, alpha included, are read as uint16_t samples.
+  if (!input_state.all_channels_have_bytes_per_sample(2)) {
     return {};
   }

diff --git a/libheif/image/pixelimage.cc b/libheif/image/pixelimage.cc
index c197c290..a7c72c27 100644
--- a/libheif/image/pixelimage.cc
+++ b/libheif/image/pixelimage.cc
@@ -432,12 +432,7 @@ Error HeifPixelImage::ComponentStorage::alloc(uint32_t width, uint32_t height, h
   m_datatype = datatype;

   // Cache bytes-per-pixel for the inner-loop get_bytes_per_pixel().
-  int bytes_per_component;
-  if (bit_depth <= 8)        bytes_per_component = 1;
-  else if (bit_depth <= 16)  bytes_per_component = 2;
-  else if (bit_depth <= 32)  bytes_per_component = 4;
-  else if (bit_depth <= 64)  bytes_per_component = 8;
-  else                       bytes_per_component = 16;
+  int bytes_per_component = bytes_per_sample_for_bit_depth(bit_depth);

   // m_bytes_per_pixel is a uint8_t. bytes_per_component * num_interleaved_components can
   // exceed 255 even though num_interleaved_components itself is already bounded to <= 255
diff --git a/libheif/image/pixelimage.h b/libheif/image/pixelimage.h
index 3a5dc13b..cf7c8e69 100644
--- a/libheif/image/pixelimage.h
+++ b/libheif/image/pixelimage.h
@@ -57,6 +57,28 @@ std::vector<heif_chroma> get_valid_chroma_values_for_colorspace(heif_colorspace



+// Number of bytes HeifPixelImage uses to store one sample of the given bit depth
+// (1, 2, 4, 8 or 16). This is the single source of truth for the sample width: plane
+// allocation uses it, and so does every code path that reinterprets plane memory as
+// uint8_t/uint16_t samples (color conversion in particular), so that they cannot drift.
+inline int bytes_per_sample_for_bit_depth(int bit_depth)
+{
+  if (bit_depth <= 8) {
+    return 1;
+  }
+  if (bit_depth <= 16) {
+    return 2;
+  }
+  if (bit_depth <= 32) {
+    return 4;
+  }
+  if (bit_depth <= 64) {
+    return 8;
+  }
+  return 16;
+}
+
+
 class HeifPixelImage : public std::enable_shared_from_this<HeifPixelImage>,
                        public ImageDescription,
                        public ErrorBuffer
diff --git a/tests/conversion.cc b/tests/conversion.cc
index cdf096b8..7151f399 100644
--- a/tests/conversion.cc
+++ b/tests/conversion.cc
@@ -28,6 +28,9 @@
 #include "catch_amalgamated.hpp"
 #include "color-conversion/colorconversion.h"
 #include "color-conversion/hdr_sdr.h"
+#include "color-conversion/monochrome.h"
+#include "color-conversion/rgb2yuv.h"
+#include "color-conversion/yuv2rgb.h"
 #include "image/pixelimage.h"
 #include <cmath>

@@ -958,6 +961,48 @@ TEST_CASE("Mismatched alpha bit depth - conversion correctness") {
 }


+// An 'unci' component may be 32, 64 or 128 bits wide. HeifPixelImage stores such planes with
+// 4, 8 or 16 bytes per sample, but every conversion operator reads samples through uint8_t
+// or uint16_t pointers. An operator must therefore decline such input in
+// state_after_conversion() based on the sample width (not on an 8-bit SDR/HDR split, which
+// would treat a 64-bit plane like a 16-bit one), so that no pipeline is ever built for it.
+TEST_CASE("Conversion operators decline planes wider than 16 bits", "[heif_image]")
+{
+  heif_color_conversion_options options{};
+  std::unique_ptr<heif_color_conversion_options_ext, void(*)(heif_color_conversion_options_ext*)>
+      options_ext(heif_color_conversion_options_ext_alloc(), heif_color_conversion_options_ext_free);
+
+  ColorState rgb8(heif_colorspace_RGB, heif_chroma_444, false, 8);
+  ColorState rrggbb16(heif_colorspace_RGB, heif_chroma_interleaved_RRGGBB_LE, false, 16);
+  ColorState ycbcr8(heif_colorspace_YCbCr, heif_chroma_444, false, 8);
+  nclx_default_if_undefined(ycbcr8);
+
+  for (int bits : {32, 64, 128}) {
+    INFO("bits=" << bits);
+
+    ColorState ycbcr(heif_colorspace_YCbCr, heif_chroma_444, false, bits);
+    nclx_default_if_undefined(ycbcr);
+    ColorState rgb(heif_colorspace_RGB, heif_chroma_444, false, bits);
+    ColorState mono(heif_colorspace_monochrome, heif_chroma_monochrome, false, bits);
+
+    CHECK(ycbcr.get_bytes_per_sample(heif_channel_Y) > 2);
+    CHECK_FALSE(ycbcr.color_channels_have_bytes_per_sample(2));
+
+    // The individual operators must not offer themselves ...
+    CHECK(Op_YCbCr_to_RGB<uint16_t>().state_after_conversion(ycbcr, rgb, options, *options_ext).empty());
+    CHECK(Op_RGB_to_YCbCr<uint16_t>().state_after_conversion(rgb, ycbcr, options, *options_ext).empty());
+    CHECK(Op_mono_to_YCbCr420().state_after_conversion(mono, ycbcr, options, *options_ext).empty());
+    CHECK(Op_to_sdr_planes().state_after_conversion(rgb, rgb8, options, *options_ext).empty());
+
+    // ... and consequently no pipeline can be built from such an input.
+    ColorConversionPipeline pipeline;
+    CHECK_FALSE(pipeline.construct_pipeline(ycbcr, rgb8, options, *options_ext));
+    CHECK_FALSE(pipeline.construct_pipeline(rgb, rrggbb16, options, *options_ext));
+    CHECK_FALSE(pipeline.construct_pipeline(mono, ycbcr8, options, *options_ext));
+  }
+}
+
+
 // Regression test for GHSA-8857-r8x5-7499. Op_to_hdr_planes widens an 8-bit input to a higher
 // bit depth with out = (in << (m-8)) | (in >> (16-m)). For m > 16 the right shift exponent
 // (16-m) becomes negative, which is undefined behavior (UBSan: "shift exponent is negative"),