Commit 00c618cd for libheif

commit 00c618cd11f8062d6ed45f592705061a12b3cb7c
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Mon Apr 13 14:47:38 2026 +0200

    track alpha bpp through color conversion pipeline and adjust if needed (#1673)

diff --git a/libheif/color-conversion/alpha.cc b/libheif/color-conversion/alpha.cc
index 08651b0b..7a316a81 100644
--- a/libheif/color-conversion/alpha.cc
+++ b/libheif/color-conversion/alpha.cc
@@ -106,6 +106,10 @@ Op_flatten_alpha_plane<Pixel>::state_after_conversion(const ColorState& input_st
     return {};
   }

+  if (input_state.has_alpha && input_state.get_alpha_bits_per_pixel() != input_state.bits_per_pixel) {
+    return {};
+  }
+
   // only drop alpha plane if it is not needed in output

   if ((input_state.chroma != heif_chroma_monochrome &&
@@ -285,3 +289,169 @@ Op_flatten_alpha_plane<Pixel>::convert_colorspace(const std::shared_ptr<const He

 template class Op_flatten_alpha_plane<uint8_t>;
 template class Op_flatten_alpha_plane<uint16_t>;
+
+
+std::vector<ColorStateWithCost>
+Op_adjust_alpha_bit_depth::state_after_conversion(const ColorState& input_state,
+                                                  const ColorState& target_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
+  if (!input_state.has_alpha ||
+      input_state.get_alpha_bits_per_pixel() == input_state.bits_per_pixel) {
+    return {};
+  }
+
+  // Only for planar formats with alpha
+  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 {};
+  }
+
+  std::vector<ColorStateWithCost> states;
+
+  ColorState output_state = input_state;
+  output_state.alpha_bits_per_pixel = input_state.bits_per_pixel;
+
+  states.emplace_back(output_state, SpeedCosts_Unoptimized);
+
+  return states;
+}
+
+
+Result<std::shared_ptr<HeifPixelImage>>
+Op_adjust_alpha_bit_depth::convert_colorspace(const std::shared_ptr<const HeifPixelImage>& input,
+                                              const ColorState& input_state,
+                                              const ColorState& target_state,
+                                              const heif_color_conversion_options& options,
+                                              const heif_color_conversion_options_ext& options_ext,
+                                              const heif_security_limits* limits) const
+{
+  uint32_t width = input->get_width();
+  uint32_t height = input->get_height();
+
+  auto outimg = std::make_shared<HeifPixelImage>();
+  outimg->create(width, height, input->get_colorspace(), input->get_chroma_format());
+
+  // Copy all non-alpha channels unchanged
+  for (heif_channel channel : {heif_channel_Y, heif_channel_Cb, heif_channel_Cr,
+                                heif_channel_R, heif_channel_G, heif_channel_B}) {
+    if (input->has_channel(channel)) {
+      outimg->copy_new_plane_from(input, channel, channel, limits);
+    }
+  }
+
+  if (!input->has_channel(heif_channel_Alpha)) {
+    return outimg;
+  }
+
+  int input_alpha_bpp = input->get_bits_per_pixel(heif_channel_Alpha);
+  int target_bpp = input_state.bits_per_pixel;
+
+  uint32_t alpha_width = input->get_width(heif_channel_Alpha);
+  uint32_t alpha_height = input->get_height(heif_channel_Alpha);
+
+  if (auto err = outimg->add_plane(heif_channel_Alpha, alpha_width, alpha_height, target_bpp, limits)) {
+    return err;
+  }
+
+  if (input_alpha_bpp <= 8 && target_bpp > 8) {
+    // Upscale: 8-bit alpha -> HDR using pattern replication
+    const uint8_t* p_in;
+    size_t stride_in;
+    p_in = input->get_plane(heif_channel_Alpha, &stride_in);
+
+    uint16_t* p_out;
+    size_t stride_out;
+    p_out = (uint16_t*) outimg->get_plane(heif_channel_Alpha, &stride_out);
+    stride_out /= 2;
+
+    int shift1 = target_bpp - input_alpha_bpp;
+    int shift2 = 2 * input_alpha_bpp - target_bpp;
+
+    for (uint32_t y = 0; y < alpha_height; y++)
+      for (uint32_t x = 0; x < alpha_width; x++) {
+        int in = p_in[y * stride_in + x];
+        p_out[y * stride_out + x] = (uint16_t) ((in << shift1) | (in >> shift2));
+      }
+  }
+  else if (input_alpha_bpp > 8 && target_bpp <= 8) {
+    // Downscale: HDR alpha -> 8-bit
+    const uint16_t* p_in;
+    size_t stride_in;
+    p_in = (const uint16_t*) input->get_plane(heif_channel_Alpha, &stride_in);
+    stride_in /= 2;
+
+    uint8_t* p_out;
+    size_t stride_out;
+    p_out = outimg->get_plane(heif_channel_Alpha, &stride_out);
+
+    int shift = input_alpha_bpp - 8;
+
+    for (uint32_t y = 0; y < alpha_height; y++)
+      for (uint32_t x = 0; x < alpha_width; x++) {
+        p_out[y * stride_out + x] = (uint8_t) (p_in[y * stride_in + x] >> shift);
+      }
+  }
+  else if (input_alpha_bpp > 8 && target_bpp > 8) {
+    // HDR alpha -> different HDR: rescale within uint16_t
+    const uint16_t* p_in;
+    size_t stride_in;
+    p_in = (const uint16_t*) input->get_plane(heif_channel_Alpha, &stride_in);
+    stride_in /= 2;
+
+    uint16_t* p_out;
+    size_t stride_out;
+    p_out = (uint16_t*) outimg->get_plane(heif_channel_Alpha, &stride_out);
+    stride_out /= 2;
+
+    if (target_bpp > input_alpha_bpp) {
+      int shift1 = target_bpp - input_alpha_bpp;
+      int shift2 = 2 * input_alpha_bpp - target_bpp;
+      for (uint32_t y = 0; y < alpha_height; y++)
+        for (uint32_t x = 0; x < alpha_width; x++) {
+          int in = p_in[y * stride_in + x];
+          p_out[y * stride_out + x] = (uint16_t) ((in << shift1) | (in >> shift2));
+        }
+    }
+    else {
+      int shift = input_alpha_bpp - target_bpp;
+      for (uint32_t y = 0; y < alpha_height; y++)
+        for (uint32_t x = 0; x < alpha_width; x++) {
+          p_out[y * stride_out + x] = (uint16_t) (p_in[y * stride_in + x] >> shift);
+        }
+    }
+  }
+  else {
+    // SDR alpha -> different SDR (both <= 8)
+    const uint8_t* p_in;
+    size_t stride_in;
+    p_in = input->get_plane(heif_channel_Alpha, &stride_in);
+
+    uint8_t* p_out;
+    size_t stride_out;
+    p_out = outimg->get_plane(heif_channel_Alpha, &stride_out);
+
+    if (target_bpp > input_alpha_bpp) {
+      int shift1 = target_bpp - input_alpha_bpp;
+      int shift2 = 2 * input_alpha_bpp - target_bpp;
+      for (uint32_t y = 0; y < alpha_height; y++)
+        for (uint32_t x = 0; x < alpha_width; x++) {
+          int in = p_in[y * stride_in + x];
+          p_out[y * stride_out + x] = (uint8_t) ((in << shift1) | (in >> shift2));
+        }
+    }
+    else {
+      int shift = input_alpha_bpp - target_bpp;
+      for (uint32_t y = 0; y < alpha_height; y++)
+        for (uint32_t x = 0; x < alpha_width; x++) {
+          p_out[y * stride_out + x] = (uint8_t) (p_in[y * stride_in + x] >> shift);
+        }
+    }
+  }
+
+  return outimg;
+}
diff --git a/libheif/color-conversion/alpha.h b/libheif/color-conversion/alpha.h
index 60ad84e6..cd596630 100644
--- a/libheif/color-conversion/alpha.h
+++ b/libheif/color-conversion/alpha.h
@@ -64,4 +64,22 @@ public:
                      const heif_security_limits* limits) const override;
 };

+class Op_adjust_alpha_bit_depth : public ColorConversionOperation
+{
+public:
+  std::vector<ColorStateWithCost>
+  state_after_conversion(const ColorState& input_state,
+                         const ColorState& target_state,
+                         const heif_color_conversion_options& options,
+                         const heif_color_conversion_options_ext& options_ext) const override;
+
+  Result<std::shared_ptr<HeifPixelImage>>
+  convert_colorspace(const std::shared_ptr<const HeifPixelImage>& input,
+                     const ColorState& input_state,
+                     const ColorState& target_state,
+                     const heif_color_conversion_options& options,
+                     const heif_color_conversion_options_ext& options_ext,
+                     const heif_security_limits* limits) const override;
+};
+
 #endif //LIBHEIF_COLORCONVERSION_ALPHA_H
diff --git a/libheif/color-conversion/chroma_sampling.cc b/libheif/color-conversion/chroma_sampling.cc
index 9bafdff8..2de71686 100644
--- a/libheif/color-conversion/chroma_sampling.cc
+++ b/libheif/color-conversion/chroma_sampling.cc
@@ -67,6 +67,7 @@ Op_YCbCr444_to_YCbCr420_average<Pixel>::state_after_conversion(const ColorState&
   output_state.chroma = heif_chroma_420;
   output_state.has_alpha = input_state.has_alpha;  // we simply keep the old alpha plane
   output_state.bits_per_pixel = input_state.bits_per_pixel;
+  output_state.alpha_bits_per_pixel = input_state.alpha_bits_per_pixel;
   output_state.nclx = input_state.nclx;

   states.emplace_back(output_state, SpeedCosts_Unoptimized);
@@ -290,6 +291,7 @@ Op_YCbCr444_to_YCbCr422_average<Pixel>::state_after_conversion(const ColorState&
   output_state.chroma = heif_chroma_422;
   output_state.has_alpha = input_state.has_alpha;  // we simply keep the old alpha plane
   output_state.bits_per_pixel = input_state.bits_per_pixel;
+  output_state.alpha_bits_per_pixel = input_state.alpha_bits_per_pixel;
   output_state.nclx = input_state.nclx;

   states.emplace_back(output_state, SpeedCosts_Unoptimized);
@@ -487,6 +489,7 @@ Op_YCbCr420_bilinear_to_YCbCr444<Pixel>::state_after_conversion(const ColorState
   output_state.chroma = heif_chroma_444;
   output_state.has_alpha = input_state.has_alpha;  // we simply keep the old alpha plane
   output_state.bits_per_pixel = input_state.bits_per_pixel;
+  output_state.alpha_bits_per_pixel = input_state.alpha_bits_per_pixel;
   output_state.nclx = input_state.nclx;

   states.emplace_back(output_state, SpeedCosts_Unoptimized);
@@ -767,6 +770,7 @@ Op_YCbCr422_bilinear_to_YCbCr444<Pixel>::state_after_conversion(const ColorState
   output_state.chroma = heif_chroma_444;
   output_state.has_alpha = input_state.has_alpha;  // we simply keep the old alpha plane
   output_state.bits_per_pixel = input_state.bits_per_pixel;
+  output_state.alpha_bits_per_pixel = input_state.alpha_bits_per_pixel;
   output_state.nclx = input_state.nclx;

   states.emplace_back(output_state, SpeedCosts_Unoptimized);
diff --git a/libheif/color-conversion/colorconversion.cc b/libheif/color-conversion/colorconversion.cc
index 1c0be8a5..819fa6e4 100644
--- a/libheif/color-conversion/colorconversion.cc
+++ b/libheif/color-conversion/colorconversion.cc
@@ -158,6 +158,12 @@ bool ColorState::operator==(const ColorState& b) const
     return false;
   }

+  if (has_alpha && b.has_alpha) {
+    if (get_alpha_bits_per_pixel() != b.get_alpha_bits_per_pixel()) {
+      return false;
+    }
+  }
+
   if (colorspace == heif_colorspace_YCbCr) {
     bool ycbcr_parameters_match = nclx.equal_except_transfer_curve(b.nclx);

@@ -200,6 +206,10 @@ std::ostream& operator<<(std::ostream& ostr, const ColorState& state)
            << " bpp(R)=" << state.bits_per_pixel
               << " alpha=" << (state.has_alpha ? "yes" : "no");

+  if (state.has_alpha && state.get_alpha_bits_per_pixel() != state.bits_per_pixel) {
+    ostr << " alpha_bpp=" << state.get_alpha_bits_per_pixel();
+  }
+
   if (state.colorspace == heif_colorspace_YCbCr) {
     ostr << " matrix-coefficients=" << state.nclx.get_matrix_coefficients()
          << " colour-primaries=" << state.nclx.get_colour_primaries()
@@ -245,6 +255,7 @@ void ColorConversionPipeline::init_ops()
   ops.emplace_back(std::make_shared<Op_drop_alpha_plane>());
   ops.emplace_back(std::make_shared<Op_flatten_alpha_plane<uint8_t>>());
   ops.emplace_back(std::make_shared<Op_flatten_alpha_plane<uint16_t>>());
+  ops.emplace_back(std::make_shared<Op_adjust_alpha_bit_depth>());
   ops.emplace_back(std::make_shared<Op_to_hdr_planes>());
   ops.emplace_back(std::make_shared<Op_to_sdr_planes>());
   ops.emplace_back(std::make_shared<Op_YCbCr420_bilinear_to_YCbCr444<uint8_t>>());
@@ -531,6 +542,10 @@ Result<std::shared_ptr<HeifPixelImage>> convert_colorspace(const std::shared_ptr
   assert(!channels.empty());
   input_state.bits_per_pixel = input->get_bits_per_pixel(*(channels.begin()));

+  if (input_state.has_alpha && input->has_channel(heif_channel_Alpha)) {
+    input_state.alpha_bits_per_pixel = input->get_bits_per_pixel(heif_channel_Alpha);
+  }
+
   ColorState output_state = input_state;
   output_state.colorspace = target_colorspace;
   output_state.chroma = target_chroma;
diff --git a/libheif/color-conversion/colorconversion.h b/libheif/color-conversion/colorconversion.h
index bc000eba..f1553fcb 100644
--- a/libheif/color-conversion/colorconversion.h
+++ b/libheif/color-conversion/colorconversion.h
@@ -34,6 +34,7 @@ struct ColorState
   heif_chroma chroma = heif_chroma_undefined;
   bool has_alpha = false;
   int bits_per_pixel = 8;
+  int alpha_bits_per_pixel = 0; // 0 = not set, treated as bits_per_pixel

   // ColorConversionOperations can assume that the input and target nclx has no 'unspecified' values
   // if the colorspace is heif_colorspace_YCbCr. Otherwise, the values should preferably be 'unspecified'.
@@ -44,6 +45,9 @@ struct ColorState
   ColorState(heif_colorspace colorspace, heif_chroma chroma, bool has_alpha, int bits_per_pixel)
       : colorspace(colorspace), chroma(chroma), has_alpha(has_alpha), bits_per_pixel(bits_per_pixel) {}

+  // Returns effective alpha BPP (treats 0 as bits_per_pixel for backward compatibility)
+  int get_alpha_bits_per_pixel() const { return alpha_bits_per_pixel ? alpha_bits_per_pixel : bits_per_pixel; }
+
   bool operator==(const ColorState&) const;
 };

diff --git a/libheif/color-conversion/hdr_sdr.cc b/libheif/color-conversion/hdr_sdr.cc
index fa8de4f6..fab0cd09 100644
--- a/libheif/color-conversion/hdr_sdr.cc
+++ b/libheif/color-conversion/hdr_sdr.cc
@@ -44,6 +44,7 @@ Op_to_hdr_planes::state_after_conversion(const ColorState& input_state,

   output_state = input_state;
   output_state.bits_per_pixel = target_state.bits_per_pixel;
+  output_state.alpha_bits_per_pixel = target_state.bits_per_pixel;

   states.emplace_back(output_state, SpeedCosts_Unoptimized);

@@ -134,6 +135,7 @@ Op_to_sdr_planes::state_after_conversion(const ColorState& input_state,

   output_state = input_state;
   output_state.bits_per_pixel = 8;
+  output_state.alpha_bits_per_pixel = 8;

   states.emplace_back(output_state, SpeedCosts_Unoptimized);

diff --git a/libheif/color-conversion/monochrome.cc b/libheif/color-conversion/monochrome.cc
index 064e2716..1d22dafe 100644
--- a/libheif/color-conversion/monochrome.cc
+++ b/libheif/color-conversion/monochrome.cc
@@ -43,6 +43,7 @@ Op_mono_to_YCbCr420::state_after_conversion(const ColorState& input_state,
   output_state.chroma = heif_chroma_420;
   output_state.has_alpha = input_state.has_alpha;
   output_state.bits_per_pixel = input_state.bits_per_pixel;
+  output_state.alpha_bits_per_pixel = input_state.alpha_bits_per_pixel;

   states.emplace_back(output_state, SpeedCosts_OptimizedSoftware);

@@ -175,6 +176,10 @@ Op_mono_to_RGB24_32::state_after_conversion(const ColorState& input_state,
     return {};
   }

+  if (input_state.has_alpha && input_state.get_alpha_bits_per_pixel() != input_state.bits_per_pixel) {
+    return {};
+  }
+
   std::vector<ColorStateWithCost> states;

   ColorState output_state;
diff --git a/libheif/color-conversion/rgb2rgb.cc b/libheif/color-conversion/rgb2rgb.cc
index 4e779268..87ebfa8d 100644
--- a/libheif/color-conversion/rgb2rgb.cc
+++ b/libheif/color-conversion/rgb2rgb.cc
@@ -37,6 +37,10 @@ Op_RGB_to_RGB24_32::state_after_conversion(const ColorState& input_state,
     return {};
   }

+  if (input_state.has_alpha && input_state.get_alpha_bits_per_pixel() != input_state.bits_per_pixel) {
+    return {};
+  }
+
   std::vector<ColorStateWithCost> states;

   ColorState output_state;
@@ -160,6 +164,10 @@ Op_RGB_HDR_to_RRGGBBaa_BE::state_after_conversion(const ColorState& input_state,
     return {};
   }

+  if (input_state.has_alpha && input_state.get_alpha_bits_per_pixel() != input_state.bits_per_pixel) {
+    return {};
+  }
+
   std::vector<ColorStateWithCost> states;

   ColorState output_state;
@@ -298,6 +306,10 @@ Op_RGB_to_RRGGBBaa_BE::state_after_conversion(const ColorState& input_state,
     return {};
   }

+  if (input_state.has_alpha && input_state.get_alpha_bits_per_pixel() != input_state.bits_per_pixel) {
+    return {};
+  }
+
   std::vector<ColorStateWithCost> states;

   ColorState output_state;
@@ -440,6 +452,7 @@ Op_RRGGBBaa_BE_to_RGB_HDR::state_after_conversion(const ColorState& input_state,
   output_state.chroma = heif_chroma_444;
   output_state.has_alpha = target_state.has_alpha;
   output_state.bits_per_pixel = input_state.bits_per_pixel;
+  output_state.alpha_bits_per_pixel = input_state.bits_per_pixel;

   states.emplace_back(output_state, SpeedCosts_Unoptimized);

@@ -556,6 +569,7 @@ Op_RGB24_32_to_RGB::state_after_conversion(const ColorState& input_state,
   output_state.chroma = heif_chroma_444;
   output_state.has_alpha = target_state.has_alpha;
   output_state.bits_per_pixel = input_state.bits_per_pixel;
+  output_state.alpha_bits_per_pixel = input_state.bits_per_pixel;

   states.emplace_back(output_state, SpeedCosts_Unoptimized);

diff --git a/libheif/color-conversion/rgb2yuv.cc b/libheif/color-conversion/rgb2yuv.cc
index b570fe6b..d8132b2f 100644
--- a/libheif/color-conversion/rgb2yuv.cc
+++ b/libheif/color-conversion/rgb2yuv.cc
@@ -50,6 +50,10 @@ Op_RGB_to_YCbCr<Pixel>::state_after_conversion(const ColorState& input_state,
     return {};
   }

+  if (input_state.has_alpha && input_state.get_alpha_bits_per_pixel() != input_state.bits_per_pixel) {
+    return {};
+  }
+
   int matrix = target_state.nclx.get_matrix_coefficients();
   if (matrix == 11 || matrix == 14) {
     return {};
diff --git a/libheif/color-conversion/yuv2rgb.cc b/libheif/color-conversion/yuv2rgb.cc
index c98893be..b9d33190 100644
--- a/libheif/color-conversion/yuv2rgb.cc
+++ b/libheif/color-conversion/yuv2rgb.cc
@@ -79,6 +79,7 @@ Op_YCbCr_to_RGB<Pixel>::state_after_conversion(const ColorState& input_state,
   output_state.chroma = heif_chroma_444;
   output_state.has_alpha = input_state.has_alpha;  // we simply keep the old alpha plane
   output_state.bits_per_pixel = input_state.bits_per_pixel;
+  output_state.alpha_bits_per_pixel = input_state.alpha_bits_per_pixel;

   states.emplace_back(output_state, SpeedCosts_Unoptimized);

@@ -446,6 +447,10 @@ Op_YCbCr420_to_RGB32::state_after_conversion(const ColorState& input_state,
     return {};
   }

+  if (input_state.has_alpha && input_state.get_alpha_bits_per_pixel() != input_state.bits_per_pixel) {
+    return {};
+  }
+
   int matrix = input_state.nclx.get_matrix_coefficients();
   if (matrix == 0 || matrix == 8 || matrix == 11 || matrix == 14) {
     return {};
@@ -576,6 +581,10 @@ Op_YCbCr420_to_RRGGBBaa::state_after_conversion(const ColorState& input_state,
     return {};
   }

+  if (input_state.has_alpha && input_state.get_alpha_bits_per_pixel() != input_state.bits_per_pixel) {
+    return {};
+  }
+
   int matrix = input_state.nclx.get_matrix_coefficients();
   if (matrix == 0 || matrix == 8 || matrix == 11 || matrix == 14) {
     return {};