Commit 5ff01779 for libheif

commit 5ff0177944aad5f3dc72176aacd8db313a4dae2f
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Aug 30 13:44:39 2026 +0200

    FFmpeg decoder: return high bit depth JPEG / JPEG 2000 at the coded bit depth

    FFmpeg's MJPEG and JPEG 2000 decoders have no exact-depth pixel format for
    every coded bit depth: 12-bit grayscale JPEG is returned as GRAY16, 10/12-bit
    grayscale JPEG 2000 as GRAY16, >8-bit YCbCr JPEG as YUV4xxP16 and >8-bit
    4:4:4 JPEG 2000 as packed RGB48, each with the samples shifted left to fill
    the 16-bit container. The plugin passed these through as 16-bit planes, so
    the decoded image reported 16 bits while the handle (SOF / SIZ / pixi)
    reported 10 or 12, and the samples were scaled by the difference.

    Use bits_per_raw_sample, which both decoders set to the coded precision, to
    shift the samples back and create the planes at the coded bit depth. Also
    handle rgb48le in the JPEG 2000 packed-RGB path (previously >8-bit 4:4:4
    JPEG 2000 was rejected) and name the pixel format in the "not implemented"
    error. HEVC/AVC/VVC/AV1 always use exact-depth formats and are unaffected.

    Add a test that encodes 8/10/12/16-bit monochrome and 4:4:4 JPEG 2000 with
    the OpenJPEG encoder and checks that decoding with decoder_id "ffmpeg"
    yields the coded bit depth and the original sample values.

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QAo5hPipqTKvaQDDpSEtxT

diff --git a/libheif/plugins/decoder_ffmpeg.cc b/libheif/plugins/decoder_ffmpeg.cc
index 53d521f4..78d8ea58 100644
--- a/libheif/plugins/decoder_ffmpeg.cc
+++ b/libheif/plugins/decoder_ffmpeg.cc
@@ -38,6 +38,7 @@
 extern "C"
 {
     #include <libavcodec/avcodec.h>
+    #include <libavutil/pixdesc.h>
 }


@@ -464,12 +465,22 @@ static heif_error ffmpeg_av_decode(ffmpeg_decoder* decoder, AVCodecContext* av_d
     *out_user_data = av_frame->pts;
   }

-  // FFmpeg's JPEG2000 decoder hands back the codestream's 3 components as packed
-  // "rgb24", but libheif stores them as sYCC (the codestream is tagged sYCC).
-  // De-interleave into YCbCr-444 planes so the container's nclx color conversion
-  // is applied correctly.
-  if (av_dec_ctx->pix_fmt == AV_PIX_FMT_RGB24 &&
+  // FFmpeg's JPEG2000 decoder hands back a 3-component 4:4:4 codestream as packed
+  // "rgb24" (8 bit) or "rgb48le" (9-16 bit), but libheif stores them as sYCC (the
+  // codestream is tagged sYCC). De-interleave into YCbCr-444 planes so the container's
+  // nclx color conversion is applied correctly. For rgb48le, FFmpeg scales the samples
+  // up to 16 bit; undo this so that the image has the coded bit depth (see below).
+  if ((av_dec_ctx->pix_fmt == AV_PIX_FMT_RGB24 || av_dec_ctx->pix_fmt == AV_PIX_FMT_RGB48LE) &&
       decoder->av_codec->id == AV_CODEC_ID_JPEG2000) {
+    const bool packed16 = (av_dec_ctx->pix_fmt == AV_PIX_FMT_RGB48LE);
+    int bpp = packed16 ? 16 : 8;
+    int shift = 0;
+    int coded_bpp = av_dec_ctx->bits_per_raw_sample;
+    if (coded_bpp > 0 && coded_bpp < bpp) {
+      shift = bpp - coded_bpp;
+      bpp = coded_bpp;
+    }
+
     heif_error err = heif_image_create(av_frame->width, av_frame->height,
                                        heif_colorspace_YCbCr, heif_chroma_444, image);
     if (err.code) {
@@ -478,7 +489,7 @@ static heif_error ffmpeg_av_decode(ffmpeg_decoder* decoder, AVCodecContext* av_d
     heif_channel planes[3] = {heif_channel_Y, heif_channel_Cb, heif_channel_Cr};
     uint8_t* dst[3]; size_t dst_stride[3];
     for (int c = 0; c < 3; c++) {
-      err = heif_image_add_plane_safe(*image, planes[c], av_frame->width, av_frame->height, 8, limits);
+      err = heif_image_add_plane_safe(*image, planes[c], av_frame->width, av_frame->height, bpp, limits);
       if (err.code) {
         decoder->error_message = err.message;
         err.message = decoder->error_message.c_str();
@@ -492,9 +503,17 @@ static heif_error ffmpeg_av_decode(ffmpeg_decoder* decoder, AVCodecContext* av_d
     for (int y = 0; y < av_frame->height; y++) {
       const uint8_t* row = src + static_cast<size_t>(y) * src_stride;
       for (int x = 0; x < av_frame->width; x++) {
-        dst[0][y * dst_stride[0] + x] = row[x * 3 + 0];  // Y  <- "R"
-        dst[1][y * dst_stride[1] + x] = row[x * 3 + 1];  // Cb <- "G"
-        dst[2][y * dst_stride[2] + x] = row[x * 3 + 2];  // Cr <- "B"
+        // Y <- "R", Cb <- "G", Cr <- "B"
+        for (int c = 0; c < 3; c++) {
+          uint16_t v = packed16 ? reinterpret_cast<const uint16_t*>(row)[x * 3 + c] : row[x * 3 + c];
+          v = static_cast<uint16_t>(v >> shift);
+          if (bpp > 8) {
+            reinterpret_cast<uint16_t*>(dst[c] + y * dst_stride[c])[x] = v;
+          }
+          else {
+            dst[c][y * dst_stride[c] + x] = static_cast<uint8_t>(v);
+          }
+        }
       }
     }
     return heif_error_success;
@@ -522,18 +541,34 @@ static heif_error ffmpeg_av_decode(ffmpeg_decoder* decoder, AVCodecContext* av_d

     int nPlanes = is_mono ? 1 : 3;

-    for (int channel = 0; channel < nPlanes; channel++) {
-      int bpp = get_ffmpeg_format_bpp(av_dec_ctx->pix_fmt);
-      if (bpp == 0) {
-        heif_image_release(*image);
-        err = {
-          heif_error_Decoder_plugin_error,
-          heif_suberror_Unsupported_color_conversion,
-          "Pixel format not implemented"
-        };
-        return err;
-      }
+    int format_bpp = get_ffmpeg_format_bpp(av_dec_ctx->pix_fmt);
+    if (format_bpp == 0) {
+      heif_image_release(*image);
+      err = {
+        heif_error_Decoder_plugin_error,
+        heif_suberror_Unsupported_color_conversion,
+        "Pixel format not implemented"
+      };
+      return err;
+    }
+
+    // FFmpeg's MJPEG and JPEG 2000 decoders do not have an exact-depth pixel format for
+    // every coded bit depth. A 12-bit grayscale JPEG is returned as GRAY16, a 10- or 12-bit
+    // grayscale JPEG 2000 as GRAY16LE, a 12-bit YCbCr JPEG as YUV4xxP16, etc. In these cases
+    // FFmpeg shifts the samples left so that they fill the container (the low bits are zero)
+    // and reports the coded precision in bits_per_raw_sample. Undo that scaling so that the
+    // decoded image has the bit depth declared in the file (SOF / SIZ / pixi) and the
+    // original sample values. For HEVC/AVC/VVC/AV1 the pixel format depth always equals the
+    // coded depth, so nothing is shifted there.
+    int bpp = format_bpp;
+    int shift = 0;
+    int coded_bpp = av_dec_ctx->bits_per_raw_sample;
+    if (coded_bpp > 0 && coded_bpp < format_bpp) {
+      shift = format_bpp - coded_bpp;
+      bpp = coded_bpp;
+    }

+    for (int channel = 0; channel < nPlanes; channel++) {
       int stride = av_frame->linesize[channel];
       const uint8_t* data = av_frame->data[channel];

@@ -562,20 +597,47 @@ static heif_error ffmpeg_av_decode(ffmpeg_decoder* decoder, AVCodecContext* av_d
       size_t dst_stride;
       uint8_t* dst_mem = heif_image_get_plane2(*image, channel2plane[channel], &dst_stride);

-      int bytes_per_pixel = (bpp + 7) / 8;
+      int src_bytes_per_pixel = (format_bpp + 7) / 8;
+      int dst_bytes_per_pixel = (bpp + 7) / 8;

       for (int y = 0; y < h; y++) {
-        memcpy(dst_mem + y * dst_stride, data + static_cast<size_t>(y) * stride, static_cast<size_t>(w) * bytes_per_pixel);
+        const uint8_t* src_row = data + static_cast<size_t>(y) * stride;
+        uint8_t* dst_row = dst_mem + y * dst_stride;
+
+        if (shift == 0) {
+          memcpy(dst_row, src_row, static_cast<size_t>(w) * src_bytes_per_pixel);
+        }
+        else if (src_bytes_per_pixel == 2) {
+          const uint16_t* src16 = reinterpret_cast<const uint16_t*>(src_row);
+          if (dst_bytes_per_pixel == 2) {
+            uint16_t* dst16 = reinterpret_cast<uint16_t*>(dst_row);
+            for (int x = 0; x < w; x++) {
+              dst16[x] = static_cast<uint16_t>(src16[x] >> shift);
+            }
+          }
+          else {
+            for (int x = 0; x < w; x++) {
+              dst_row[x] = static_cast<uint8_t>(src16[x] >> shift);
+            }
+          }
+        }
+        else {
+          for (int x = 0; x < w; x++) {
+            dst_row[x] = static_cast<uint8_t>(src_row[x] >> shift);
+          }
+        }
       }
     }

     return heif_error_success;
   }
   else {
+    const char* fmt_name = av_get_pix_fmt_name(av_dec_ctx->pix_fmt);
+    decoder->error_message = std::string("Pixel format not implemented: ") + (fmt_name ? fmt_name : "unknown");
     return {
       heif_error_Unsupported_feature,
       heif_suberror_Unsupported_color_conversion,
-      "Pixel format not implemented"
+      decoder->error_message.c_str()
     };
   }
 }
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 5dfdb722..8056f1b4 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -92,6 +92,7 @@ add_libheif_test(tai)
 add_libheif_test(text)
 add_libheif_test(cxx_wrapper)
 add_libheif_test(component_descriptions)
+add_libheif_test(ffmpeg_decode_bitdepth)

 if (WITH_OPENJPH_ENCODER AND SUPPORTS_J2K_HT_ENCODING)
     add_libheif_test(encode_htj2k)
diff --git a/tests/ffmpeg_decode_bitdepth.cc b/tests/ffmpeg_decode_bitdepth.cc
new file mode 100644
index 00000000..9dd6957e
--- /dev/null
+++ b/tests/ffmpeg_decode_bitdepth.cc
@@ -0,0 +1,236 @@
+/*
+  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.
+*/
+
+// FFmpeg's JPEG 2000 (and MJPEG) decoders have no exact-depth pixel format for every
+// coded bit depth. A 10- or 12-bit grayscale codestream is returned as GRAY16 with the
+// samples shifted left to fill the 16-bit container. The FFmpeg decoder plugin has to
+// undo this so that the decoded image has the bit depth declared in the file and the
+// original sample values. These tests encode high bit depth JPEG 2000 images with the
+// OpenJPEG encoder and decode them again explicitly with the FFmpeg decoder.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "test_utils.h"
+
+#include <cstring>
+#include <string>
+#include <vector>
+
+static const int W = 64;
+static const int H = 48;
+
+static bool have_decoder(heif_compression_format format, const char* id)
+{
+  int n = heif_get_decoder_descriptors(format, nullptr, 0);
+  std::vector<const heif_decoder_descriptor*> descs(n);
+  n = heif_get_decoder_descriptors(format, descs.data(), n);
+  for (int i = 0; i < n; i++) {
+    const char* name = heif_decoder_descriptor_get_id_name(descs[i]);
+    if (name && strcmp(name, id) == 0) {
+      return true;
+    }
+  }
+  return false;
+}
+
+
+// Deterministic test pattern that covers the full value range of the bit depth.
+static uint16_t pattern(int x, int y, int bpp)
+{
+  uint32_t maxval = (1u << bpp) - 1;
+  if (x == 0 && y == 0) return static_cast<uint16_t>(maxval);
+  if (x == 1 && y == 0) return 0;
+  return static_cast<uint16_t>((static_cast<uint32_t>(x) * 37 + static_cast<uint32_t>(y) * 101 + x * y) & maxval);
+}
+
+
+static void fill_plane(heif_image* img, heif_channel channel, int w, int h, int bpp)
+{
+  heif_error err = heif_image_add_plane(img, channel, w, h, bpp);
+  REQUIRE(err.code == heif_error_Ok);
+
+  size_t stride;
+  uint8_t* p = heif_image_get_plane2(img, channel, &stride);
+  REQUIRE(p != nullptr);
+
+  for (int y = 0; y < h; y++) {
+    for (int x = 0; x < w; x++) {
+      uint16_t v = pattern(x, y, bpp);
+      if (bpp > 8) {
+        reinterpret_cast<uint16_t*>(p + y * stride)[x] = v;
+      }
+      else {
+        p[y * stride + x] = static_cast<uint8_t>(v);
+      }
+    }
+  }
+}
+
+
+static void check_plane(const heif_image* img, heif_channel channel, int w, int h, int bpp)
+{
+  REQUIRE(heif_image_has_channel(img, channel));
+  CHECK(heif_image_get_bits_per_pixel_range(img, channel) == bpp);
+  CHECK(heif_image_get_width(img, channel) == w);
+  CHECK(heif_image_get_height(img, channel) == h);
+
+  size_t stride;
+  const uint8_t* p = heif_image_get_plane_readonly2(img, channel, &stride);
+  REQUIRE(p != nullptr);
+
+  int mismatches = 0;
+  for (int y = 0; y < h; y++) {
+    for (int x = 0; x < w; x++) {
+      uint16_t expected = pattern(x, y, bpp);
+      uint16_t actual;
+      if (bpp > 8) {
+        actual = reinterpret_cast<const uint16_t*>(p + y * stride)[x];
+      }
+      else {
+        actual = p[y * stride + x];
+      }
+      if (actual != expected) {
+        if (mismatches < 3) {
+          INFO("channel " << channel << " at (" << x << "," << y << "): expected " << expected << ", got " << actual);
+          CHECK(actual == expected);
+        }
+        mismatches++;
+      }
+    }
+  }
+  CHECK(mismatches == 0);
+}
+
+
+static std::string encode_j2k_lossless(heif_image* img, const char* filename)
+{
+  heif_encoder* encoder = get_encoder_or_skip_test(heif_compression_JPEG2000);
+
+  heif_context* ctx = heif_context_alloc();
+
+  heif_error err = heif_encoder_set_lossless(encoder, 1);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_encoding_options* options = heif_encoding_options_alloc();
+  options->macOS_compatibility_workaround = false;
+  options->macOS_compatibility_workaround_no_nclx_profile = true;
+
+  heif_image_handle* handle;
+  err = heif_context_encode_image(ctx, img, encoder, options, &handle);
+  REQUIRE(err.code == heif_error_Ok);
+  heif_image_handle_release(handle);
+
+  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_encoding_options_free(options);
+  heif_encoder_release(encoder);
+  heif_context_free(ctx);
+
+  return path;
+}
+
+
+static heif_image* decode_with_ffmpeg(const std::string& path, heif_colorspace colorspace, heif_chroma chroma, int expected_bpp)
+{
+  heif_context* ctx = get_context_for_local_file(path);
+  heif_image_handle* handle = get_primary_image_handle(ctx);
+
+  // The declared bit depth comes from the codestream header (SIZ), independent of the decoder.
+  CHECK(heif_image_handle_get_luma_bits_per_pixel(handle) == expected_bpp);
+
+  heif_decoding_options* options = heif_decoding_options_alloc();
+  options->decoder_id = "ffmpeg";
+
+  heif_image* img;
+  heif_error err = heif_decode_image(handle, &img, colorspace, chroma, options);
+  INFO("decode error: " << err.message);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_decoding_options_free(options);
+  heif_image_handle_release(handle);
+  heif_context_free(ctx);
+
+  return img;
+}
+
+
+TEST_CASE("ffmpeg decodes high bit depth monochrome JPEG 2000 at the coded bit depth")
+{
+  if (!have_decoder(heif_compression_JPEG2000, "ffmpeg")) {
+    SKIP("FFmpeg decoder not available, skipping test");
+  }
+
+  // FFmpeg returns 10- and 12-bit grayscale as GRAY16 with left-shifted samples; 8- and 16-bit
+  // have exact formats (GRAY8 / GRAY16) and must be passed through unchanged.
+  int bpp = GENERATE(8, 10, 12, 16);
+  INFO("bit depth " << bpp);
+
+  heif_image* input;
+  heif_error err = heif_image_create(W, H, heif_colorspace_monochrome, heif_chroma_monochrome, &input);
+  REQUIRE(err.code == heif_error_Ok);
+  fill_plane(input, heif_channel_Y, W, H, bpp);
+
+  std::string filename = "ffmpeg_j2k_mono" + std::to_string(bpp) + ".heif";
+  std::string path = encode_j2k_lossless(input, filename.c_str());
+  heif_image_release(input);
+
+  heif_image* decoded = decode_with_ffmpeg(path, heif_colorspace_monochrome, heif_chroma_monochrome, bpp);
+  check_plane(decoded, heif_channel_Y, W, H, bpp);
+  heif_image_release(decoded);
+}
+
+
+TEST_CASE("ffmpeg decodes high bit depth YCbCr 4:4:4 JPEG 2000 at the coded bit depth")
+{
+  if (!have_decoder(heif_compression_JPEG2000, "ffmpeg")) {
+    SKIP("FFmpeg decoder not available, skipping test");
+  }
+
+  // FFmpeg returns a 3-component 4:4:4 codestream with more than 8 bits as packed RGB48
+  // with left-shifted samples (8 bit as packed RGB24). The libheif OpenJPEG encoder always
+  // writes 4:4:4, so subsampled codestreams cannot be generated here.
+  int bpp = GENERATE(8, 10, 12, 16);
+  INFO("bit depth " << bpp);
+
+  heif_image* input;
+  heif_error err = heif_image_create(W, H, heif_colorspace_YCbCr, heif_chroma_444, &input);
+  REQUIRE(err.code == heif_error_Ok);
+  fill_plane(input, heif_channel_Y, W, H, bpp);
+  fill_plane(input, heif_channel_Cb, W, H, bpp);
+  fill_plane(input, heif_channel_Cr, W, H, bpp);
+
+  std::string filename = "ffmpeg_j2k_yuv444_" + std::to_string(bpp) + ".heif";
+  std::string path = encode_j2k_lossless(input, filename.c_str());
+  heif_image_release(input);
+
+  heif_image* decoded = decode_with_ffmpeg(path, heif_colorspace_YCbCr, heif_chroma_444, bpp);
+  check_plane(decoded, heif_channel_Y, W, H, bpp);
+  check_plane(decoded, heif_channel_Cb, W, H, bpp);
+  check_plane(decoded, heif_channel_Cr, W, H, bpp);
+  heif_image_release(decoded);
+}