Commit d34a288c for libheif

commit d34a288c469c469b479b74cc879fbe3ff28c6f4c
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Sep 20 14:51:47 2026 +0200

    Only report a colour bit depth when all colour planes agree

    ColorState::get_color_bits_per_pixel() returned the depth of the first
    colour plane (Y, else R, else the filter array). For an image whose planes
    have different depths, which 'unci' allows, every decision based on it
    depended on the order of the planes. Op_to_sdr_planes declined an RGB
    image as "already 8 bits" as soon as its R plane was 8 bits, so 8/8/16
    could not be decoded to 8-bit RGB (interleaved RGB or convert_hdr_to_8bit)
    while the same planes as 16/8/8 could, although the operator lowers every
    plane on its own.

    Replace the accessor with get_uniform_color_bits_per_pixel(), which
    returns the shared depth or 0 when the colour planes differ or are
    absent, so that a caller needing one depth has to handle the mixed case
    and cannot pick up one plane's depth by accident. Add
    get_uniform_bits_per_pixel() (alpha included) and
    get_max_color_bits_per_pixel(). color_channels_have_same_bpp() and
    all_channels_have_same_bpp() are now thin wrappers.

    Call sites:
    - Op_to_sdr_planes declines only when all colour planes are 8 bits, so it
      equalizes mixed images (item 2 of the ColorState review).
    - Op_to_hdr_planes requires a uniform 8-bit input and a uniform target.
    - Op_flatten_alpha_plane and Op_adjust_alpha_bit_depth decline mixed
      colour depths at plan time and guard against them at runtime. The
      latter previously matched the alpha plane to an arbitrary colour plane
      of a mixed image before every consumer declined it anyway.
    - The planner uses the shared depth where the caller did not request one,
      and the widest colour plane as the lossless fallback for mixed input.
      In the same-layout branch a mixed image now also keeps its alpha depth
      instead of forcing it to the first colour plane.

    Tests: ColorState accessor semantics and Op_to_sdr_planes on 8/8/16,
    16/8/8 and 8/16/8 in conversion.cc; uncompressed_mixed_rgb_depth_to_sdr
    builds mixed-depth 'unci' files through the public API and decodes them
    to interleaved RGB and with convert_hdr_to_8bit.

diff --git a/libheif/color-conversion/alpha.cc b/libheif/color-conversion/alpha.cc
index 966b37c6..e981e53f 100644
--- a/libheif/color-conversion/alpha.cc
+++ b/libheif/color-conversion/alpha.cc
@@ -143,7 +143,10 @@ Op_flatten_alpha_plane<Pixel>::state_after_conversion(const ColorState& input_st
     return {};
   }

-  if (input_state.has_alpha() && input_state.bits_per_pixel_alpha != input_state.get_color_bits_per_pixel()) {
+  // The colour planes are converted to RGB at one common depth and the alpha plane is
+  // composited at that depth, so all of them have to agree (0 = the colour planes differ).
+  int color_bpp = input_state.get_uniform_color_bits_per_pixel();
+  if (color_bpp == 0 || (input_state.has_alpha() && input_state.bits_per_pixel_alpha != color_bpp)) {
     return {};
   }

@@ -188,6 +191,16 @@ Op_flatten_alpha_plane<Pixel>::convert_colorspace(const std::shared_ptr<const He
 {
   std::shared_ptr<const HeifPixelImage> input = input_raw;

+  // The colour planes are converted at, and the alpha plane is composited at, one common depth.
+  // state_after_conversion() only offers this operation when the planes agree; a direct caller
+  // may not have checked.
+  int color_bpp = input_state.get_uniform_color_bits_per_pixel();
+  if (color_bpp == 0) {
+    return Error{heif_error_Unsupported_feature,
+                 heif_suberror_Unsupported_color_conversion,
+                 "Op_flatten_alpha_plane: colour planes with differing bit depths"};
+  }
+
   heif_color_conversion_options_ext options_ext_skip_alpha = options_ext;
   options_ext_skip_alpha.alpha_composition_mode = heif_alpha_composition_mode_none;

@@ -196,7 +209,7 @@ Op_flatten_alpha_plane<Pixel>::convert_colorspace(const std::shared_ptr<const He
                                                                                    heif_colorspace_RGB,
                                                                                    heif_chroma_444,
                                                                                    input_state.nclx,
-                                                                                   input_state.get_color_bits_per_pixel(),
+                                                                                   color_bpp,
                                                                                    options, &options_ext_skip_alpha,
                                                                                    limits);
     if (!convInput) {
@@ -332,7 +345,7 @@ Op_flatten_alpha_plane<Pixel>::convert_colorspace(const std::shared_ptr<const He
                                                                               input_raw->get_colorspace(),
                                                                               input_raw->get_chroma_format(),
                                                                               input_state.nclx,
-                                                                              input_state.get_color_bits_per_pixel(),
+                                                                              color_bpp,
                                                                               options, &options_ext_skip_alpha,
                                                                               limits);
     if (!convOutput) {
@@ -357,9 +370,13 @@ Op_adjust_alpha_bit_depth::state_after_conversion(const ColorState& input_state,
                                                   const heif_color_conversion_options& options,
                                                   const heif_color_conversion_options_ext& options_ext) const
 {
-  // Only applicable when alpha BPP differs from color BPP
+  // Only applicable when the colour planes share one depth and the alpha plane differs from
+  // it. With mixed colour depths there is no depth to bring the alpha plane to; such images
+  // are equalized, alpha included, by Op_to_sdr_planes instead.
+  int color_bpp = input_state.get_uniform_color_bits_per_pixel();
   if (!input_state.has_alpha() ||
-      input_state.bits_per_pixel_alpha == input_state.get_color_bits_per_pixel()) {
+      color_bpp == 0 ||
+      input_state.bits_per_pixel_alpha == color_bpp) {
     return {};
   }

@@ -374,14 +391,14 @@ 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 (input_state.get_bytes_per_sample(heif_channel_Alpha) > 2 ||
-      bytes_per_sample_for_bit_depth(input_state.get_color_bits_per_pixel()) > 2) {
+      bytes_per_sample_for_bit_depth(color_bpp) > 2) {
     return {};
   }

   std::vector<ColorStateWithCost> states;

   ColorState output_state = input_state;
-  output_state.bits_per_pixel_alpha = input_state.get_color_bits_per_pixel();
+  output_state.bits_per_pixel_alpha = color_bpp;

   states.emplace_back(output_state, SpeedCosts_Unoptimized);

@@ -416,7 +433,13 @@ Op_adjust_alpha_bit_depth::convert_colorspace(const std::shared_ptr<const HeifPi
   }

   int input_alpha_bpp = input->get_bits_per_pixel(heif_channel_Alpha);
-  int target_bpp = input_state.get_color_bits_per_pixel();
+  int target_bpp = input_state.get_uniform_color_bits_per_pixel();
+  if (target_bpp == 0) {
+    // Only reachable by a direct caller; state_after_conversion() declines mixed colour depths.
+    return Error{heif_error_Unsupported_feature,
+                 heif_suberror_Unsupported_color_conversion,
+                 "Op_adjust_alpha_bit_depth: colour planes with differing bit depths"};
+  }

   uint32_t alpha_width = input->get_width(heif_channel_Alpha);
   uint32_t alpha_height = input->get_height(heif_channel_Alpha);
diff --git a/libheif/color-conversion/colorconversion.cc b/libheif/color-conversion/colorconversion.cc
index 6d2a06be..82ddd947 100644
--- a/libheif/color-conversion/colorconversion.cc
+++ b/libheif/color-conversion/colorconversion.cc
@@ -268,18 +268,46 @@ void ColorState::set_color_bits_per_pixel(int bpp)
 }


-int ColorState::get_color_bits_per_pixel() const
+int ColorState::get_uniform_color_bits_per_pixel() const
 {
-  if (bits_per_pixel_Y != 0) {
-    return bits_per_pixel_Y;
-  }
-  if (bits_per_pixel_R != 0) {
-    return bits_per_pixel_R;
+  int uniform = 0;
+
+  for (int bpp : {bits_per_pixel_R, bits_per_pixel_G, bits_per_pixel_B,
+                  bits_per_pixel_Y, bits_per_pixel_Cb, bits_per_pixel_Cr,
+                  bits_per_pixel_filter_array}) {
+    if (bpp == 0) {
+      continue; // plane does not exist
+    }
+
+    if (uniform == 0) {
+      uniform = bpp;
+    }
+    else if (bpp != uniform) {
+      return 0;
+    }
   }
-  if (bits_per_pixel_filter_array != 0) {
-    return bits_per_pixel_filter_array;
+
+  return uniform;
+}
+
+
+int ColorState::get_uniform_bits_per_pixel() const
+{
+  int uniform = get_uniform_color_bits_per_pixel();
+
+  if (uniform != 0 && bits_per_pixel_alpha != 0 && bits_per_pixel_alpha != uniform) {
+    return 0;
   }
-  return 0;
+
+  return uniform;
+}
+
+
+int ColorState::get_max_color_bits_per_pixel() const
+{
+  return std::max({bits_per_pixel_R, bits_per_pixel_G, bits_per_pixel_B,
+                   bits_per_pixel_Y, bits_per_pixel_Cb, bits_per_pixel_Cr,
+                   bits_per_pixel_filter_array});
 }


@@ -312,20 +340,6 @@ static bool all_existing_planes_satisfy(const ColorState& s, bool include_alpha,
 }


-bool ColorState::color_channels_have_same_bpp() const
-{
-  int ref = get_color_bits_per_pixel();
-  return all_existing_planes_satisfy(*this, false, [ref](int bpp) { return bpp == ref; });
-}
-
-
-bool ColorState::all_channels_have_same_bpp() const
-{
-  int ref = get_color_bits_per_pixel();
-  return all_existing_planes_satisfy(*this, true, [ref](int bpp) { return bpp == ref; });
-}
-
-
 int ColorState::get_bytes_per_sample(heif_channel channel) const
 {
   int bpp = get_bits_per_pixel(channel);
@@ -835,6 +849,14 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
     output_color_bpp = 8;
   }

+  // The depth that the input's colour planes share, or 0 if they differ ('unci' declares a
+  // depth per component). Where a single input depth is needed below and the planes do not
+  // agree, the widest plane is used: that is the lossless choice, and a conversion that cannot
+  // widen the narrower planes to it is declined by the operators, instead of one plane's depth
+  // being picked silently.
+  int uniform_input_bpp = input_state.get_uniform_color_bits_per_pixel();
+  int input_color_bpp = (uniform_input_bpp != 0) ? uniform_input_bpp : input_state.get_max_color_bits_per_pixel();
+
   // interleaved RRGGBB formats have to be >8-bit.
   // If we don't know a target bit-depth, use 10 bit.

@@ -842,28 +864,34 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
        target_chroma == heif_chroma_interleaved_RRGGBB_BE ||
        target_chroma == heif_chroma_interleaved_RRGGBBAA_LE ||
        target_chroma == heif_chroma_interleaved_RRGGBBAA_BE) &&
-      (output_color_bpp != 0 ? output_color_bpp : input_state.get_color_bits_per_pixel()) <= 8) {
+      (output_color_bpp != 0 ? output_color_bpp : input_color_bpp) <= 8) {
     output_color_bpp = 10;
   }

   bool same_plane_layout = (target_colorspace == input_state.colorspace &&
                             target_chroma == input_state.chroma);

+  int output_alpha_bpp;
+
   if (output_color_bpp == 0 && same_plane_layout) {
-    // No depth change requested and the plane layout stays the same: keep the input
-    // planes exactly as they are, even when their depths differ from each other.
-    output_color_bpp = input_state.get_color_bits_per_pixel();
+    // No depth change requested and the plane layout stays the same: keep the colour planes
+    // exactly as they are, even when their depths differ from each other. The alpha plane is
+    // brought to the colour depth when there is one (the operators expect them to agree);
+    // when the colour planes differ, it is kept as it is, too.
+    output_alpha_bpp = (uniform_input_bpp != 0) ? uniform_input_bpp : input_state.bits_per_pixel_alpha;
   }
   else {
     if (output_color_bpp == 0) {
-      output_color_bpp = input_state.get_color_bits_per_pixel();
+      output_color_bpp = input_color_bpp;
     }

     output_state.set_color_bits_per_pixel(output_color_bpp);
+
+    // Output alpha should always match the output color BPP
+    output_alpha_bpp = output_color_bpp;
   }

-  // Output alpha should always match the output color BPP
-  output_state.bits_per_pixel_alpha = output_has_alpha ? output_color_bpp : 0;
+  output_state.bits_per_pixel_alpha = output_has_alpha ? output_alpha_bpp : 0;

   ColorConversionPipeline pipeline;
   bool success = pipeline.construct_pipeline(input_state, output_state, options, *options_ext);
diff --git a/libheif/color-conversion/colorconversion.h b/libheif/color-conversion/colorconversion.h
index 169bdf8a..67a38d37 100644
--- a/libheif/color-conversion/colorconversion.h
+++ b/libheif/color-conversion/colorconversion.h
@@ -70,19 +70,28 @@ struct ColorState
   // Call this after 'colorspace' and 'chroma' have been set.
   void set_color_bits_per_pixel(int bpp);

-  // Bit depth of the first colour plane (Y for YCbCr/monochrome, R for RGB, or the filter array).
-  // This only characterizes the whole image when all colour planes have the same depth,
-  // see color_channels_have_same_bpp().
-  int get_color_bits_per_pixel() const;
+  // The bit depth shared by all existing colour planes (R/G/B, Y/Cb/Cr, or the filter array).
+  // Returns 0 if the colour planes have different depths or if there is no colour plane.
+  // There is deliberately no accessor for "the" depth of an image whose planes differ: a
+  // caller that needs one depth for all planes has to handle the 0 (usually by declining
+  // the conversion) and cannot silently pick up the depth of just one plane.
+  int get_uniform_color_bits_per_pixel() const;
+
+  // Like get_uniform_color_bits_per_pixel(), but an alpha plane, if present, must have that
+  // same depth too.
+  int get_uniform_bits_per_pixel() const;
+
+  // Maximum bit depth over all existing colour planes (alpha excluded), 0 if there is none.
+  int get_max_color_bits_per_pixel() const;

   // Maximum bit depth over all existing planes, including alpha.
   int get_max_bits_per_pixel() const;

   // True if all existing colour planes (R/G/B or Y/Cb/Cr) have the same bit depth.
-  bool color_channels_have_same_bpp() const;
+  bool color_channels_have_same_bpp() const { return get_uniform_color_bits_per_pixel() != 0; }

   // True if all existing planes, including alpha, have the same bit depth.
-  bool all_channels_have_same_bpp() const;
+  bool all_channels_have_same_bpp() const { return get_uniform_bits_per_pixel() != 0; }

   // 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
diff --git a/libheif/color-conversion/hdr_sdr.cc b/libheif/color-conversion/hdr_sdr.cc
index ffffd5c5..0738c7f4 100644
--- a/libheif/color-conversion/hdr_sdr.cc
+++ b/libheif/color-conversion/hdr_sdr.cc
@@ -28,16 +28,16 @@ Op_to_hdr_planes::state_after_conversion(const ColorState& input_state,
                                          const heif_color_conversion_options& options,
                                          const heif_color_conversion_options_ext& options_ext) const
 {
-  if ((input_state.chroma != heif_chroma_monochrome &&
-       input_state.chroma != heif_chroma_420 &&
-       input_state.chroma != heif_chroma_422 &&
-       input_state.chroma != heif_chroma_444) ||
-      input_state.get_color_bits_per_pixel() != 8) { // TODO: support for <8 bpp
+  if (input_state.chroma != heif_chroma_monochrome &&
+      input_state.chroma != heif_chroma_420 &&
+      input_state.chroma != heif_chroma_422 &&
+      input_state.chroma != heif_chroma_444) {
     return {};
   }

-  // Every plane, alpha included, is widened from 8 bits, so all of them must be 8 bits.
-  if (!input_state.all_channels_have_same_bpp()) {
+  // Every plane, alpha included, is widened from 8 bits, so all of them must be 8 bits
+  // (get_uniform_bits_per_pixel() is 0 when the planes differ).
+  if (input_state.get_uniform_bits_per_pixel() != 8) { // TODO: support for <8 bpp
     return {};
   }

@@ -46,8 +46,8 @@ Op_to_hdr_planes::state_after_conversion(const ColorState& input_state,
   // only holds for target bit depths m in (8, 16]; a larger m would both make
   // the right shift exponent negative (undefined behavior) and exceed the range
   // of the uint16_t output plane. Only offer the conversion within that range.
-  if (target_state.get_color_bits_per_pixel() <= 8 ||
-      target_state.get_color_bits_per_pixel() > 16) {
+  int target_bpp = target_state.get_uniform_color_bits_per_pixel(); // 0 if the target planes differ
+  if (target_bpp <= 8 || target_bpp > 16) {
     return {};
   }

@@ -58,9 +58,9 @@ Op_to_hdr_planes::state_after_conversion(const ColorState& input_state,
   // --- increase bit depth

   output_state = input_state;
-  output_state.set_color_bits_per_pixel(target_state.get_color_bits_per_pixel());
+  output_state.set_color_bits_per_pixel(target_bpp);
   if (output_state.has_alpha()) {
-    output_state.bits_per_pixel_alpha = target_state.get_color_bits_per_pixel();
+    output_state.bits_per_pixel_alpha = target_bpp;
   }

   states.emplace_back(output_state, SpeedCosts_Unoptimized);
@@ -94,12 +94,12 @@ Op_to_hdr_planes::convert_colorspace(const std::shared_ptr<const HeifPixelImage>
     if (input->has_channel(channel)) {
       uint32_t width = input->get_width(channel);
       uint32_t height = input->get_height(channel);
-      if (auto err = outimg->add_channel(channel, width, height, target_state.get_color_bits_per_pixel(), limits)) {
+      if (auto err = outimg->add_channel(channel, width, height, target_state.get_uniform_color_bits_per_pixel(), limits)) {
         return err;
       }

       int input_bits = input->get_bits_per_pixel(channel);
-      int output_bits = target_state.get_color_bits_per_pixel();
+      int output_bits = target_state.get_uniform_color_bits_per_pixel();

       // Guard against unsupported bit-depth combinations. state_after_conversion()
       // only offers this operation for 8-bit input and 8 < output <= 16, but a
@@ -143,15 +143,22 @@ Op_to_sdr_planes::state_after_conversion(const ColorState& input_state,
                                          const heif_color_conversion_options& options,
                                          const heif_color_conversion_options_ext& options_ext) const
 {
-  if ((input_state.chroma != heif_chroma_monochrome &&
-       input_state.chroma != heif_chroma_420 &&
-       input_state.chroma != heif_chroma_422 &&
-       input_state.chroma != heif_chroma_444) ||
-      input_state.get_color_bits_per_pixel() == 8) {
+  if (input_state.chroma != heif_chroma_monochrome &&
+      input_state.chroma != heif_chroma_420 &&
+      input_state.chroma != heif_chroma_422 &&
+      input_state.chroma != heif_chroma_444) {
     return {};
   }

-  if (target_state.get_color_bits_per_pixel() != 8) {
+  // Nothing to do when every colour plane is already 8 bits. The planes may differ from each
+  // other ('unci' declares a depth per component); the loop below handles each plane on its
+  // own, so a mixed image such as 8/8/16 is equalized to 8/8/8 here. For such an image
+  // get_uniform_color_bits_per_pixel() is 0, which does not match the 8, so it is offered.
+  if (input_state.get_uniform_color_bits_per_pixel() == 8) {
+    return {};
+  }
+
+  if (target_state.get_uniform_color_bits_per_pixel() != 8) {
     return {};
   }

diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 32eaed50..4d499f85 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -155,6 +155,7 @@ if (WITH_UNCOMPRESSED_CODEC)
     add_libheif_test(uncompressed_mixed_chroma_depth_overflow)
     add_libheif_test(uncompressed_mixed_chroma_depth_colorconv)
     add_libheif_test(uncompressed_alpha_composite_depth_mismatch)
+    add_libheif_test(uncompressed_mixed_rgb_depth_to_sdr)
     add_libheif_test(uncompressed_mixed_chroma_depth_encode)
     add_libheif_test(uncompressed_wide_component_colorconv)
     add_libheif_test(uncompressed_block_pixel_overpacked)
diff --git a/tests/conversion.cc b/tests/conversion.cc
index e2d15417..c9628742 100644
--- a/tests/conversion.cc
+++ b/tests/conversion.cc
@@ -353,7 +353,7 @@ void TestConversion(const std::string& test_name, ColorState input_state,
     bool expect_alpha_max = !target_state.has_alpha();
     bool expect_lossless =
         input_state.colorspace == target_state.colorspace &&
-        input_state.get_color_bits_per_pixel() == target_state.get_color_bits_per_pixel() &&
+        input_state.get_uniform_color_bits_per_pixel() == target_state.get_uniform_color_bits_per_pixel() &&
         (input_state.chroma == target_state.chroma ||
          (input_state.chroma != heif_chroma_420 &&
           input_state.chroma != heif_chroma_422 &&
@@ -1325,3 +1325,159 @@ TEST_CASE("Alpha compositing into a YCbCr or monochrome target", "[heif_image]")
     }
   }
 }
+
+
+// ColorState only reports one colour depth when all colour planes agree. The former accessor
+// returned the depth of the first colour plane, which made planning decisions depend on the
+// order of the planes in an image with mixed depths ('unci' declares a depth per component).
+
+static const int kMixedRgbDepths[3][3] = {{8, 8, 16}, {16, 8, 8}, {8, 16, 8}};
+
+TEST_CASE("ColorState uniform bit depth accessors", "[heif_image]")
+{
+  SECTION("uniform planes report their depth") {
+    ColorState rgb(heif_colorspace_RGB, heif_chroma_444, true, 10);
+    CHECK(rgb.get_uniform_color_bits_per_pixel() == 10);
+    CHECK(rgb.get_uniform_bits_per_pixel() == 10);
+    CHECK(rgb.get_max_color_bits_per_pixel() == 10);
+    CHECK(rgb.color_channels_have_same_bpp());
+    CHECK(rgb.all_channels_have_same_bpp());
+
+    ColorState ycc(heif_colorspace_YCbCr, heif_chroma_420, false, 12);
+    CHECK(ycc.get_uniform_color_bits_per_pixel() == 12);
+    CHECK(ycc.get_uniform_bits_per_pixel() == 12);
+
+    ColorState mono(heif_colorspace_monochrome, heif_chroma_monochrome, false, 8);
+    CHECK(mono.get_uniform_color_bits_per_pixel() == 8);
+  }
+
+  SECTION("mixed colour planes report 0 in every plane order") {
+    for (const int* d : kMixedRgbDepths) {
+      INFO("R/G/B = " << d[0] << "/" << d[1] << "/" << d[2]);
+      ColorState s;
+      s.colorspace = heif_colorspace_RGB;
+      s.chroma = heif_chroma_444;
+      s.bits_per_pixel_R = d[0];
+      s.bits_per_pixel_G = d[1];
+      s.bits_per_pixel_B = d[2];
+      CHECK(s.get_uniform_color_bits_per_pixel() == 0);
+      CHECK(s.get_uniform_bits_per_pixel() == 0);
+      CHECK(s.get_max_color_bits_per_pixel() == 16);
+      CHECK_FALSE(s.color_channels_have_same_bpp());
+      CHECK_FALSE(s.all_channels_have_same_bpp());
+    }
+
+    ColorState ycc(heif_colorspace_YCbCr, heif_chroma_444, false, 12);
+    ycc.bits_per_pixel_Cr = 8;
+    CHECK(ycc.get_uniform_color_bits_per_pixel() == 0);
+    CHECK(ycc.get_max_color_bits_per_pixel() == 12);
+  }
+
+  SECTION("a differing alpha plane only affects the all-planes variant") {
+    ColorState s(heif_colorspace_RGB, heif_chroma_444, true, 8);
+    s.bits_per_pixel_alpha = 16;
+    CHECK(s.get_uniform_color_bits_per_pixel() == 8);
+    CHECK(s.get_uniform_bits_per_pixel() == 0);
+    CHECK(s.get_max_color_bits_per_pixel() == 8);
+    CHECK(s.get_max_bits_per_pixel() == 16);
+    CHECK(s.color_channels_have_same_bpp());
+    CHECK_FALSE(s.all_channels_have_same_bpp());
+  }
+
+  SECTION("no colour plane") {
+    ColorState s;
+    CHECK(s.get_uniform_color_bits_per_pixel() == 0);
+    CHECK(s.get_uniform_bits_per_pixel() == 0);
+    CHECK(s.get_max_color_bits_per_pixel() == 0);
+  }
+}
+
+
+// Op_to_sdr_planes lowers every plane to 8 bits on its own, so it can equalize an image whose
+// colour planes have different depths. It used to be offered only when the first colour plane
+// was wider than 8 bits, so 8/8/16 failed with "unsupported color conversion" while the same
+// planes in the order 16/8/8 converted fine.
+
+// 100 at 8 bits; at wider depths the same value with a few extra low bits, so that the shift
+// down to 8 bits has something to drop.
+static uint16_t sample_at_depth(int bits)
+{
+  return static_cast<uint16_t>((100u << (bits - 8)) | (bits > 8 ? 0x5u : 0u));
+}
+
+static std::shared_ptr<HeifPixelImage> make_rgb_planar(uint32_t width, uint32_t height,
+                                                       int r_bits, int g_bits, int b_bits)
+{
+  auto img = std::make_shared<HeifPixelImage>();
+  img->create(width, height, heif_colorspace_RGB, heif_chroma_444);
+  img->fill_new_channel(heif_channel_R, sample_at_depth(r_bits), width, height, r_bits, nullptr);
+  img->fill_new_channel(heif_channel_G, sample_at_depth(g_bits), width, height, g_bits, nullptr);
+  img->fill_new_channel(heif_channel_B, sample_at_depth(b_bits), width, height, b_bits, nullptr);
+  return img;
+}
+
+TEST_CASE("Op_to_sdr_planes equalizes mixed colour depths", "[heif_image]")
+{
+  heif_color_conversion_options options{};
+  const uint32_t width = 8;
+  const uint32_t height = 4;
+
+  SECTION("mixed planes -> 8-bit interleaved RGB, in every plane order") {
+    for (const int* d : kMixedRgbDepths) {
+      INFO("R/G/B = " << d[0] << "/" << d[1] << "/" << d[2]);
+      auto img = make_rgb_planar(width, height, d[0], d[1], d[2]);
+
+      auto result = convert_colorspace(img, heif_colorspace_RGB, heif_chroma_interleaved_RGB,
+                                       nclx_profile::defaults(), 0, options, nullptr,
+                                       heif_get_disabled_security_limits());
+      REQUIRE(result);
+
+      size_t stride;
+      const uint8_t* p = (*result)->get_channel_memory(heif_channel_interleaved, &stride);
+      REQUIRE(p != nullptr);
+      CHECK(p[0] == 100);
+      CHECK(p[1] == 100);
+      CHECK(p[2] == 100);
+      CHECK(p[(height - 1) * stride + (width - 1) * 3 + 2] == 100);
+    }
+  }
+
+  SECTION("mixed planes -> 8-bit planar RGB (requested depth 8)") {
+    auto img = make_rgb_planar(width, height, 8, 8, 16);
+
+    auto result = convert_colorspace(img, heif_colorspace_RGB, heif_chroma_444,
+                                     nclx_profile::defaults(), 8, options, nullptr,
+                                     heif_get_disabled_security_limits());
+    REQUIRE(result);
+
+    for (heif_channel ch : {heif_channel_R, heif_channel_G, heif_channel_B}) {
+      CHECK((*result)->get_bits_per_pixel(ch) == 8);
+      size_t stride;
+      const uint8_t* p = (*result)->get_channel_memory(ch, &stride);
+      REQUIRE(p != nullptr);
+      CHECK(p[0] == 100);
+    }
+  }
+
+  SECTION("offered for mixed input, declined for uniform 8-bit input") {
+    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);
+
+    Op_to_sdr_planes op;
+    ColorState target(heif_colorspace_RGB, heif_chroma_444, false, 8);
+
+    ColorState mixed;
+    mixed.colorspace = heif_colorspace_RGB;
+    mixed.chroma = heif_chroma_444;
+    mixed.bits_per_pixel_R = 8;
+    mixed.bits_per_pixel_G = 8;
+    mixed.bits_per_pixel_B = 16;
+
+    auto states = op.state_after_conversion(mixed, target, options, *options_ext);
+    REQUIRE(states.size() == 1);
+    CHECK(states[0].color_state.get_uniform_color_bits_per_pixel() == 8);
+
+    ColorState uniform8(heif_colorspace_RGB, heif_chroma_444, false, 8);
+    CHECK(op.state_after_conversion(uniform8, target, options, *options_ext).empty());
+  }
+}
diff --git a/tests/uncompressed_mixed_rgb_depth_to_sdr.cc b/tests/uncompressed_mixed_rgb_depth_to_sdr.cc
new file mode 100644
index 00000000..c5cb4cbf
--- /dev/null
+++ b/tests/uncompressed_mixed_rgb_depth_to_sdr.cc
@@ -0,0 +1,183 @@
+/*
+  libheif unit tests
+
+  MIT License
+
+  Copyright (c) 2026 Dirk Farin <dirk.farin@gmail.com>
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in all
+  copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+  SOFTWARE.
+*/
+
+// Regression test: an 'unci' image whose colour planes have different bit depths, for
+// example R=8, G=8, B=12, could not be decoded to 8-bit RGB (interleaved RGB, or planar
+// with convert_hdr_to_8bit), while the very same planes in the order 12/8/8 could.
+// Op_to_sdr_planes lowers every plane to 8 bits on its own, but it was only offered when
+// the first colour plane was wider than 8 bits, because the planner asked for "the" colour
+// depth of an image whose planes do not share one. ColorState now reports a colour depth
+// only when all colour planes agree, and Op_to_sdr_planes declines only when all of them
+// are already 8 bits.
+//
+// The files are built through the public API and the uncompressed encoder, so the test
+// needs both halves of the uncompressed codec.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <cstdint>
+#include <string>
+
+namespace {
+
+constexpr uint32_t W = 64;
+constexpr uint32_t H = 64;
+
+const heif_channel CHANNELS[3] = {heif_channel_R, heif_channel_G, heif_channel_B};
+
+// Every plane holds one constant. At 8 bits these are the values below; a 12-bit plane
+// holds the same value shifted up by four bits plus a few low bits that the reduction to
+// 8 bits must drop again.
+constexpr uint8_t VALUES[3] = {100, 50, 25};
+
+std::string write_unci_rgb(const int bits[3], const char* filename)
+{
+  heif_image* image = nullptr;
+  heif_error err = heif_image_create(W, H, heif_colorspace_RGB, heif_chroma_444, &image);
+  REQUIRE(err.code == heif_error_Ok);
+
+  for (int i = 0; i < 3; i++) {
+    err = heif_image_add_plane(image, CHANNELS[i], W, H, bits[i]);
+    REQUIRE(err.code == heif_error_Ok);
+
+    size_t stride = 0;
+    uint8_t* p = heif_image_get_plane2(image, CHANNELS[i], &stride);
+    REQUIRE(p != nullptr);
+
+    for (uint32_t y = 0; y < H; y++) {
+      for (uint32_t x = 0; x < W; x++) {
+        if (bits[i] == 8) {
+          p[y * stride + x] = VALUES[i];
+        }
+        else {
+          reinterpret_cast<uint16_t*>(p + y * stride)[x] = static_cast<uint16_t>((VALUES[i] << (bits[i] - 8)) | 0xB);
+        }
+      }
+    }
+  }
+
+  heif_context* ctx = heif_context_alloc();
+  heif_encoder* encoder = nullptr;
+  err = heif_context_get_encoder_for_format(ctx, heif_compression_uncompressed, &encoder);
+  REQUIRE(err.code == heif_error_Ok);
+
+  err = heif_context_encode_image(ctx, image, encoder, nullptr, nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  std::string path = get_tests_output_file_path(filename);
+  err = heif_context_write_to_file(ctx, path.c_str());
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_encoder_release(encoder);
+  heif_image_release(image);
+  heif_context_free(ctx);
+  return path;
+}
+
+heif_image* decode(const std::string& path, heif_colorspace colorspace, heif_chroma chroma, bool hdr_to_8bit)
+{
+  heif_context* ctx = heif_context_alloc();
+  heif_error err = heif_context_read_from_file(ctx, path.c_str(), nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_image_handle* handle = get_primary_image_handle(ctx);
+
+  heif_decoding_options* options = heif_decoding_options_alloc();
+  REQUIRE(options != nullptr);
+  options->convert_hdr_to_8bit = hdr_to_8bit;
+
+  heif_image* img = nullptr;
+  err = heif_decode_image(handle, &img, colorspace, chroma, options);
+  INFO("decode error (" << err.code << "/" << err.subcode << "): " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(img != nullptr);
+
+  heif_decoding_options_free(options);
+  heif_image_handle_release(handle);
+  heif_context_free(ctx);
+  return img;
+}
+
+} // namespace
+
+TEST_CASE("unci RGB with mixed plane depths decodes to 8 bits in any plane order")
+{
+  if (!heif_have_decoder_for_format(heif_compression_uncompressed)) {
+    SKIP("uncompressed decoder not available");
+  }
+  if (!heif_have_encoder_for_format(heif_compression_uncompressed)) {
+    SKIP("uncompressed encoder not available");
+  }
+
+  const int layouts[3][3] = {{8, 8, 12}, {12, 8, 8}, {8, 12, 8}};
+  const char* filenames[3] = {"mixed_rgb_8_8_12.heif", "mixed_rgb_12_8_8.heif", "mixed_rgb_8_12_8.heif"};
+
+  for (int l = 0; l < 3; l++) {
+    const int* bits = layouts[l];
+    INFO("R/G/B = " << bits[0] << "/" << bits[1] << "/" << bits[2]);
+
+    std::string path = write_unci_rgb(bits, filenames[l]);
+
+    // A native decode keeps the declared depths, so the file really is mixed.
+    {
+      heif_image* img = decode(path, heif_colorspace_undefined, heif_chroma_undefined, false);
+      for (int i = 0; i < 3; i++) {
+        CHECK(heif_image_get_bits_per_pixel_range(img, CHANNELS[i]) == bits[i]);
+      }
+      heif_image_release(img);
+    }
+
+    // 8-bit interleaved RGB: the wide plane has to be lowered first.
+    {
+      heif_image* img = decode(path, heif_colorspace_RGB, heif_chroma_interleaved_RGB, false);
+      size_t stride = 0;
+      const uint8_t* p = heif_image_get_plane_readonly2(img, heif_channel_interleaved, &stride);
+      REQUIRE(p != nullptr);
+      const uint8_t* last = p + (H - 1) * stride + (W - 1) * 3;
+      for (int i = 0; i < 3; i++) {
+        CHECK(p[i] == VALUES[i]);
+        CHECK(last[i] == VALUES[i]);
+      }
+      heif_image_release(img);
+    }
+
+    // Planar output with convert_hdr_to_8bit: every plane ends up at 8 bits.
+    {
+      heif_image* img = decode(path, heif_colorspace_undefined, heif_chroma_undefined, true);
+      for (int i = 0; i < 3; i++) {
+        CHECK(heif_image_get_bits_per_pixel_range(img, CHANNELS[i]) == 8);
+        size_t stride = 0;
+        const uint8_t* p = heif_image_get_plane_readonly2(img, CHANNELS[i], &stride);
+        REQUIRE(p != nullptr);
+        CHECK(p[0] == VALUES[i]);
+        CHECK(p[(H - 1) * stride + (W - 1)] == VALUES[i]);
+      }
+      heif_image_release(img);
+    }
+  }
+}