Commit 676db6cd for libheif
commit 676db6cd926df47b849fdc4c7b7b881d63f0f95a
Author: Dirk Farin <dirk.farin@gmail.com>
Date: Tue Sep 29 18:53:18 2026 +0200
Refuse to encode HEVC images with 16 bits per sample
The 'hvcC' box stores bitDepthLumaMinus8 and bitDepthChromaMinus8 in 3 bit
fields (ISO/IEC 14496-15), so it can signal 8 to 15 bits per sample. HEVC
itself allows 16 bits. Such an image would be written with a bit depth of
8 in its 'hvcC' box, and readers that rely on the box, libheif among them,
report the wrong bit depth.
So far, only the encoder plugins stood in the way, because neither x265
nor kvazaar encodes 16 bits. Check it in libheif: the bit depth of the
image before it is passed to the plugin, and the bit depth in the SPS
that the plugin returns, for images and for image sequences.
diff --git a/libheif/codecs/hevc_enc.cc b/libheif/codecs/hevc_enc.cc
index 75b6d932..194ae562 100644
--- a/libheif/codecs/hevc_enc.cc
+++ b/libheif/codecs/hevc_enc.cc
@@ -24,12 +24,37 @@
#include "context.h"
#include "api_structs.h"
+#include <algorithm>
#include <string>
#include <utility>
#include "plugins/nalu_utils.h"
+// The HEVCDecoderConfigurationRecord ('hvcC', ISO/IEC 14496-15) stores bitDepthLumaMinus8 and
+// bitDepthChromaMinus8 in 3 bits each. It can signal 8 to 15 bits per sample, but not the
+// 16 bits that HEVC itself allows. Writing a 16-bit image would put a bit depth of 8 into
+// its 'hvcC' box, so we refuse to encode it.
+static const int MAX_BIT_DEPTH_IN_HVCC = 15;
+
+static Error check_bit_depth_fits_hvcC(int bit_depth)
+{
+ if (bit_depth > MAX_BIT_DEPTH_IN_HVCC) {
+ return Error{heif_error_Unsupported_feature,
+ heif_suberror_Unsupported_bit_depth,
+ "HEVC images with more than 15 bits per sample cannot be written, "
+ "because the 'hvcC' box cannot signal their bit depth."};
+ }
+
+ return Error::Ok;
+}
+
+static Error check_bit_depth_fits_hvcC(const HEVCDecoderConfigurationRecord& config)
+{
+ return check_bit_depth_fits_hvcC(std::max(config.bit_depth_luma, config.bit_depth_chroma));
+}
+
+
// TODO: can we use the new sequences interface for this to avoid duplicate code.
Result<Encoder::CodedImageData> Encoder_HEVC::encode(const std::shared_ptr<HeifPixelImage>& image,
heif_encoder* encoder,
@@ -38,6 +63,10 @@ Result<Encoder::CodedImageData> Encoder_HEVC::encode(const std::shared_ptr<HeifP
{
CodedImageData codedImage;
+ if (Error bitDepthErr = check_bit_depth_fits_hvcC(image->get_visual_image_bits_per_pixel())) {
+ return bitDepthErr;
+ }
+
auto hvcC = std::make_shared<Box_hvcC>();
heif_image c_api_image;
@@ -70,6 +99,11 @@ Result<Encoder::CodedImageData> Encoder_HEVC::encode(const std::shared_ptr<HeifP
if ((data[0] >> 1) == HEVC_NAL_UNIT_SPS_NUT) {
parse_sps_for_hvcC_configuration(data, size, &hvcC->get_configuration(), &encoded_width, &encoded_height);
+ // The encoder decides about the coded bit depth, it may differ from the one of the input image.
+ if (Error bitDepthErr = check_bit_depth_fits_hvcC(hvcC->get_configuration())) {
+ return bitDepthErr;
+ }
+
codedImage.encoded_image_width = encoded_width;
codedImage.encoded_image_height = encoded_height;
}
@@ -123,6 +157,10 @@ Error Encoder_HEVC::encode_sequence_frame(const std::shared_ptr<HeifPixelImage>&
uint32_t framerate_num, uint32_t framerate_denom,
uintptr_t frame_number)
{
+ if (Error bitDepthErr = check_bit_depth_fits_hvcC(image->get_visual_image_bits_per_pixel())) {
+ return bitDepthErr;
+ }
+
heif_image c_api_image;
c_api_image.image = image;
@@ -205,6 +243,10 @@ Error Encoder_HEVC::get_data(heif_encoder* encoder)
parse_sps_for_hvcC_configuration(data, size,
&m_hvcC->get_configuration(),
&m_encoded_image_width, &m_encoded_image_height);
+
+ if (Error bitDepthErr = check_bit_depth_fits_hvcC(m_hvcC->get_configuration())) {
+ return bitDepthErr;
+ }
}
switch (nal_type) {
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index fdfa748f..6b1db08a 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -93,6 +93,7 @@ endif()
add_libheif_test(clap_decode)
add_libheif_test(encode)
add_libheif_test(encode_grid)
+add_libheif_test(encode_hevc_bitdepth)
add_libheif_test(entity_groups)
add_libheif_test(extended_type)
add_libheif_test(zero_length_memcpy)
diff --git a/tests/encode_hevc_bitdepth.cc b/tests/encode_hevc_bitdepth.cc
new file mode 100644
index 00000000..4255b9d6
--- /dev/null
+++ b/tests/encode_hevc_bitdepth.cc
@@ -0,0 +1,185 @@
+/*
+ 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.
+*/
+
+// The 'hvcC' box stores the bit depths in 3 bit fields and can signal at most 15 bits per
+// sample. An HEVC image with 16 bits would be written with a bit depth of 8 in its 'hvcC'
+// box. libheif refuses to encode such an image, and it does so itself, before the image
+// reaches the encoder plugin: the error has to come with heif_error_Unsupported_feature,
+// not with the heif_error_Encoder_plugin_error of a plugin that happens to reject the
+// bit depth as well.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "libheif/heif_sequences.h"
+#include "test_utils.h"
+
+#include <cstdint>
+#include <cstring>
+#include <string>
+
+namespace {
+
+constexpr uint32_t W = 64;
+constexpr uint32_t H = 64;
+
+void add_plane(heif_image* img, heif_channel channel, uint32_t w, uint32_t h, int bit_depth)
+{
+ heif_error err = heif_image_add_plane(img, channel, w, h, bit_depth);
+ REQUIRE(err.code == heif_error_Ok);
+
+ size_t stride = 0;
+ uint8_t* p = heif_image_get_plane2(img, channel, &stride);
+ REQUIRE(p != nullptr);
+
+ const uint16_t value = static_cast<uint16_t>(1u << (bit_depth - 1));
+ for (uint32_t y = 0; y < h; y++) {
+ for (uint32_t x = 0; x < w; x++) {
+ if (bit_depth > 8) {
+ memcpy(p + y * stride + 2 * x, &value, 2);
+ }
+ else {
+ p[y * stride + x] = static_cast<uint8_t>(value);
+ }
+ }
+ }
+}
+
+heif_image* create_image(heif_colorspace colorspace, heif_chroma chroma, int bit_depth)
+{
+ heif_image* img = nullptr;
+ heif_error err = heif_image_create(W, H, colorspace, chroma, &img);
+ REQUIRE(err.code == heif_error_Ok);
+
+ if (colorspace == heif_colorspace_RGB) {
+ for (heif_channel channel : {heif_channel_R, heif_channel_G, heif_channel_B}) {
+ add_plane(img, channel, W, H, bit_depth);
+ }
+ }
+ else {
+ add_plane(img, heif_channel_Y, W, H, bit_depth);
+ if (chroma == heif_chroma_420) {
+ add_plane(img, heif_channel_Cb, W / 2, H / 2, bit_depth);
+ add_plane(img, heif_channel_Cr, W / 2, H / 2, bit_depth);
+ }
+ }
+ return img;
+}
+
+// heif_error::message may point into the context that reported the error, so it has to be
+// copied before the context is freed.
+struct EncodeResult
+{
+ heif_error_code code;
+ heif_suberror_code subcode;
+ std::string message;
+};
+
+EncodeResult encode_image(heif_image* img)
+{
+ heif_context* ctx = heif_context_alloc();
+ heif_encoder* encoder = nullptr;
+ heif_error err = heif_context_get_encoder_for_format(ctx, heif_compression_HEVC, &encoder);
+ REQUIRE(err.code == heif_error_Ok);
+
+ err = heif_context_encode_image(ctx, img, encoder, nullptr, nullptr);
+ EncodeResult result{err.code, err.subcode, err.message};
+
+ heif_encoder_release(encoder);
+ heif_context_free(ctx);
+ return result;
+}
+
+EncodeResult encode_sequence_frame(heif_image* img)
+{
+ heif_context* ctx = heif_context_alloc();
+ heif_encoder* encoder = nullptr;
+ heif_error err = heif_context_get_encoder_for_format(ctx, heif_compression_HEVC, &encoder);
+ REQUIRE(err.code == heif_error_Ok);
+
+ heif_track* track = nullptr;
+ err = heif_context_add_visual_sequence_track(ctx, static_cast<uint16_t>(W), static_cast<uint16_t>(H),
+ heif_track_type_video, nullptr, nullptr, &track);
+ REQUIRE(err.code == heif_error_Ok);
+
+ heif_image_set_duration(img, 1);
+ err = heif_track_encode_sequence_image(track, img, encoder, nullptr);
+ EncodeResult result{err.code, err.subcode, err.message};
+
+ heif_track_release(track);
+ heif_encoder_release(encoder);
+ heif_context_free(ctx);
+ return result;
+}
+
+void check_refused_by_libheif(const EncodeResult& result)
+{
+ INFO("encode error (" << result.code << "/" << result.subcode << "): " << result.message);
+ CHECK(result.code == heif_error_Unsupported_feature);
+ CHECK(result.subcode == heif_suberror_Unsupported_bit_depth);
+ CHECK(result.message.find("hvcC") != std::string::npos);
+}
+
+} // namespace
+
+
+TEST_CASE("HEVC images with 16 bits per sample are refused")
+{
+ if (!heif_have_encoder_for_format(heif_compression_HEVC)) {
+ SKIP("no HEVC encoder available");
+ }
+
+ SECTION("control: an 8 bit image encodes") {
+ heif_image* img = create_image(heif_colorspace_YCbCr, heif_chroma_420, 8);
+ EncodeResult result = encode_image(img);
+ INFO("encode error (" << result.code << "/" << result.subcode << "): " << result.message);
+ CHECK(result.code == heif_error_Ok);
+ heif_image_release(img);
+ }
+
+ SECTION("YCbCr 4:2:0") {
+ heif_image* img = create_image(heif_colorspace_YCbCr, heif_chroma_420, 16);
+ check_refused_by_libheif(encode_image(img));
+ heif_image_release(img);
+ }
+
+ SECTION("monochrome") {
+ heif_image* img = create_image(heif_colorspace_monochrome, heif_chroma_monochrome, 16);
+ check_refused_by_libheif(encode_image(img));
+ heif_image_release(img);
+ }
+
+ SECTION("RGB, which is converted to YCbCr for the encoder") {
+ heif_image* img = create_image(heif_colorspace_RGB, heif_chroma_444, 16);
+ check_refused_by_libheif(encode_image(img));
+ heif_image_release(img);
+ }
+
+ SECTION("frame of an image sequence") {
+ heif_image* img = create_image(heif_colorspace_YCbCr, heif_chroma_420, 16);
+ check_refused_by_libheif(encode_sequence_frame(img));
+ heif_image_release(img);
+ }
+}