Commit c090c6e8 for libheif
commit c090c6e844e4ecd5915d0270fb87d3bd512257d5
Author: Chris Scribner <scriby@gmail.com>
Date: Wed Feb 18 19:14:10 2026 -0800
Add support for I420, I422, and I444 pixel formats in the webcodecs decoder.
Fixes issue #1686 related to decoding images produced by copying & pasting subjects out of images on iOS devices, which are created with 4:4:4 chroma.
diff --git a/libheif/plugins/decoder_webcodecs.cc b/libheif/plugins/decoder_webcodecs.cc
index 284a90f0..a6b23880 100644
--- a/libheif/plugins/decoder_webcodecs.cc
+++ b/libheif/plugins/decoder_webcodecs.cc
@@ -60,8 +60,8 @@ static char plugin_name[MAX_PLUGIN_NAME_LENGTH];
* prefers hardware decoding when available.
*
* As of this writing, most HEIC images will be decoded directly into the NV12
- * pixel format. For images returned in NV12 format, the format will be
- * preserved when returning the data to C++.
+ * pixel format. For images returned in NV12 or planar YUV format (I420, I422,
+ * I444), the format will be preserved when returning the data to C++.
*
* Any other image format returned by the WebCodecs API will be converted to
* RGBA before being returned to C++ to ensure that the result can be
@@ -124,12 +124,13 @@ EM_JS(emscripten::EM_VAL, decode_with_browser_hevc, (const char *codec_ptr, uint
return;
}
- const format = decoded.format === 'NV12' ? 'NV12' : 'RGBA';
+ const nativeFormats = ['NV12', 'I420', 'I422', 'I444'];
+ const format = nativeFormats.includes(decoded.format) ? decoded.format : 'RGBA';
const fullRange = decoded.colorSpace ? decoded.colorSpace.fullRange : false;
- const formatOptions = format === 'NV12' ?
+ const formatOptions = nativeFormats.includes(format) ?
{} :
{'format': format, 'colorSpace': 'srgb'};
- const bufferSize = format === 'NV12' ?
+ const bufferSize = nativeFormats.includes(format) ?
decoded.allocationSize() :
decoded.codedWidth * decoded.codedHeight * 4;
@@ -415,98 +416,166 @@ static struct heif_error webcodecs_push_data(void* decoder_raw, const void* data
}
-static struct heif_error convert_webcodecs_result_to_heif_image(const std::unique_ptr<uint8_t[]>& buffer,
- int width, int height,
- int y_offset, int y_src_stride,
- int uv_offset, int uv_src_stride,
- struct heif_image** out_img,
- heif_chroma chroma,
- bool is_full_range) {
+static void normalize_luma_range(uint8_t* dst, int stride, int width, int height) {
+ // Luma data coming from the browser's VideoDecoder API may be using a
+ // limited range (16-235) instead of the full range (0-255). If this is the
+ // case, we need to normalize the data to the full range.
+ for (int y = 0; y < height; y++) {
+ uint8_t* p = dst + y * stride;
+ for (int x = 0; x < width; x++) {
+ float v = (static_cast<float>(p[x]) - 16.0f) * 255.0f / 219.0f;
+ p[x] = static_cast<uint8_t>(std::min(255.0f, std::max(0.0f, v + 0.5f)));
+ }
+ }
+}
+
+static void normalize_chroma_range(uint8_t* dst, int stride, int width, int height) {
+ // Chroma data coming from the browser's VideoDecoder API may be using a
+ // limited range (16-240) instead of the full range (0-255). If this is the
+ // case, we need to normalize the data to the full range.
+ for (int y = 0; y < height; y++) {
+ uint8_t* p = dst + y * stride;
+ for (int x = 0; x < width; x++) {
+ float v = (static_cast<float>(p[x]) - 16.0f) * 255.0f / 224.0f;
+ p[x] = static_cast<uint8_t>(std::min(255.0f, std::max(0.0f, v + 0.5f)));
+ }
+ }
+}
+
+static struct heif_error convert_planar_yuv_to_heif_image(
+ const uint8_t* y_src, int y_src_stride,
+ const uint8_t* u_src, int u_src_stride,
+ const uint8_t* v_src, int v_src_stride,
+ int width, int height,
+ struct heif_image** out_img,
+ heif_chroma chroma,
+ bool is_full_range) {
heif_error err;
bool is_mono = chroma == heif_chroma_monochrome;
- err = heif_image_create(width,
- height,
- is_mono ? heif_colorspace_monochrome : heif_colorspace_YCbCr,
- is_mono ? heif_chroma_monochrome : heif_chroma_420,
- out_img);
+
+ int chroma_w = width;
+ int chroma_h = height;
+ if (chroma == heif_chroma_420 || is_mono) {
+ chroma_w = width / 2;
+ chroma_h = height / 2;
+ } else if (chroma == heif_chroma_422) {
+ chroma_w = width / 2;
+ }
+
+ err = heif_image_create(
+ width, height,
+ is_mono ? heif_colorspace_monochrome
+ : heif_colorspace_YCbCr,
+ is_mono ? heif_chroma_monochrome : chroma,
+ out_img);
if (err.code) {
return err;
}
- err = heif_image_add_plane(*out_img, heif_channel_Y, width, height, 8);
+ err = heif_image_add_plane(
+ *out_img, heif_channel_Y, width, height, 8);
if (err.code) {
heif_image_release(*out_img);
return err;
}
+ int y_stride;
+ uint8_t* y_dst = heif_image_get_plane(
+ *out_img, heif_channel_Y, &y_stride);
+ for (int i = 0; i < height; ++i) {
+ memcpy(y_dst + i * y_stride,
+ y_src + i * y_src_stride,
+ width);
+ }
+
+ if (!is_full_range) {
+ normalize_luma_range(y_dst, y_stride, width, height);
+ }
+
if (!is_mono) {
- err = heif_image_add_plane(*out_img, heif_channel_Cb, width / 2, height / 2, 8);
+ err = heif_image_add_plane(
+ *out_img, heif_channel_Cb,
+ chroma_w, chroma_h, 8);
if (err.code) {
heif_image_release(*out_img);
return err;
}
- err = heif_image_add_plane(*out_img, heif_channel_Cr, width / 2, height / 2, 8);
+ err = heif_image_add_plane(
+ *out_img, heif_channel_Cr,
+ chroma_w, chroma_h, 8);
if (err.code) {
heif_image_release(*out_img);
return err;
}
- }
- // The y plane can be reused as-is.
+ int cb_stride;
+ uint8_t* cb_dst = heif_image_get_plane(
+ *out_img, heif_channel_Cb, &cb_stride);
+ for (int i = 0; i < chroma_h; ++i) {
+ memcpy(cb_dst + i * cb_stride,
+ u_src + i * u_src_stride,
+ chroma_w);
+ }
- int y_stride;
- uint8_t* y_dst = heif_image_get_plane(*out_img, heif_channel_Y, &y_stride);
+ int cr_stride;
+ uint8_t* cr_dst = heif_image_get_plane(
+ *out_img, heif_channel_Cr, &cr_stride);
+ for (int i = 0; i < chroma_h; ++i) {
+ memcpy(cr_dst + i * cr_stride,
+ v_src + i * v_src_stride,
+ chroma_w);
+ }
- for (int i = 0; i < height; ++i) {
- memcpy(y_dst + i * y_stride,
- buffer.get() + y_offset + i * y_src_stride,
- width);
+ if (!is_full_range) {
+ normalize_chroma_range(
+ cb_dst, cb_stride, chroma_w, chroma_h);
+ normalize_chroma_range(
+ cr_dst, cr_stride, chroma_w, chroma_h);
+ }
}
- // NV12 luma data coming from the browser's VideoDecoder API may be using a
- // limited range (16-235) instead of the full range (0-255). If this is the
- // case, we need to normalize the data to the full range.
- if (!is_full_range) {
- for (int y = 0; y < height; y++) {
- uint8_t* p = y_dst + y * y_stride;
- for (int x = 0; x < width; x++) {
- float v = (static_cast<float>(p[x]) - 16.0f) * 255.0f / 219.0f;
- p[x] = static_cast<uint8_t>(std::min(255.0f, std::max(0.0f, v + 0.5f)));
- }
- }
+ return {heif_error_Ok, heif_suberror_Unspecified, kSuccess};
+}
+
+static struct heif_error convert_nv12_to_heif_image(
+ const std::unique_ptr<uint8_t[]>& buffer,
+ int width, int height,
+ int y_offset, int y_src_stride,
+ int uv_offset, int uv_src_stride,
+ struct heif_image** out_img,
+ heif_chroma chroma,
+ bool is_full_range) {
+ bool is_mono = chroma == heif_chroma_monochrome;
+
+ if (is_mono) {
+ return convert_planar_yuv_to_heif_image(
+ buffer.get() + y_offset, y_src_stride,
+ nullptr, 0, nullptr, 0,
+ width, height, out_img,
+ heif_chroma_monochrome, is_full_range);
}
- if (!is_mono) {
- // In the NV12 format, the U and V planes are interleaved (UVUVUV...), whereas
- // in libheif they are two separate planes. This code splits the interleaved UV
- // bytes into two separate planes for use in libheif.
-
- int u_stride;
- uint8_t* u_dst = heif_image_get_plane(*out_img, heif_channel_Cb, &u_stride);
- int v_stride;
- uint8_t* v_dst = heif_image_get_plane(*out_img, heif_channel_Cr, &v_stride);
-
- for (int i = 0; i < height / 2; ++i) {
- uint8_t* uv_src = buffer.get() + uv_offset + i * uv_src_stride;
- for (int j = 0; j < width / 2; ++j) {
- // NV12 chroma data coming from the browser's VideoDecoder API may be using a
- // limited range (16-240) instead of the full range (0-255). If this is the
- // case, we need to normalize the data to the full range.
- if (!is_full_range) {
- float u = (static_cast<float>(uv_src[j * 2]) - 16.0f) * 255.0f / 224.0f;
- float v = (static_cast<float>(uv_src[j * 2 + 1]) - 16.0f) * 255.0f / 224.0f;
- u_dst[i * u_stride + j] = static_cast<uint8_t>(std::min(255.0f, std::max(0.0f, u + 0.5f)));
- v_dst[i * v_stride + j] = static_cast<uint8_t>(std::min(255.0f, std::max(0.0f, v + 0.5f)));
- } else {
- u_dst[i * u_stride + j] = uv_src[j * 2];
- v_dst[i * v_stride + j] = uv_src[j * 2 + 1];
- }
- }
+ int chroma_w = width / 2;
+ int chroma_h = height / 2;
+ std::vector<uint8_t> u_buf(chroma_w * chroma_h);
+ std::vector<uint8_t> v_buf(chroma_w * chroma_h);
+
+ for (int i = 0; i < chroma_h; ++i) {
+ const uint8_t* uv_row =
+ buffer.get() + uv_offset + i * uv_src_stride;
+ for (int j = 0; j < chroma_w; ++j) {
+ u_buf[i * chroma_w + j] = uv_row[j * 2];
+ v_buf[i * chroma_w + j] = uv_row[j * 2 + 1];
}
}
- return {heif_error_Ok, heif_suberror_Unspecified, kSuccess};
+ return convert_planar_yuv_to_heif_image(
+ buffer.get() + y_offset, y_src_stride,
+ u_buf.data(), chroma_w,
+ v_buf.data(), chroma_w,
+ width, height, out_img,
+ heif_chroma_420, is_full_range);
}
/**
@@ -539,7 +608,8 @@ static std::string get_hevc_codec_string(const HEVCDecoderConfigurationRecord& c
constraint_flags |= (1ULL << (47 - i));
}
}
- snprintf(buffer, sizeof(buffer), "%06X", (unsigned int)(constraint_flags >> 24));
+ snprintf(buffer, sizeof(buffer), "%06X",
+ (unsigned int)(constraint_flags >> 24));
codec_string += buffer;
return codec_string;
@@ -679,6 +749,8 @@ static struct heif_error webcodecs_decode_image(void* decoder_raw,
"Decoding failed: result.planes is undefined or not an array"};
}
+ bool is_full_range = !result["fullRange"].isUndefined() && result["fullRange"].as<bool>();
+
// Most HEIC images in the browser will be decoded natively in NV12 pixel
// format. Using the bytes directly helps retain the original image fidelity.
if (format == "NV12") {
@@ -717,14 +789,40 @@ static struct heif_error webcodecs_decode_image(void* decoder_raw,
uv_src_stride = uv_plane["stride"].as<int>();
}
- bool is_full_range = !result["fullRange"].isUndefined() && result["fullRange"].as<bool>();
- return convert_webcodecs_result_to_heif_image(buffer, width, height, y_offset, y_src_stride, uv_offset, uv_src_stride, out_img, (heif_chroma)config.chroma_format, is_full_range);
- } else if (format == "RGBA") {
- // Also handle RGBA images as a fallback in cases where the browser returns
- // something other than NV12. As of now only RGBA is handled as an
- // alternative format for simplicity's sake, but other formats could be
- // handled explicitly in the future.
+ return convert_nv12_to_heif_image(buffer, width, height, y_offset, y_src_stride, uv_offset, uv_src_stride, out_img, (heif_chroma)config.chroma_format, is_full_range);
+ } else if (format == "I420" || format == "I422" || format == "I444") {
+ if (planes["length"].as<size_t>() < 3) {
+ return {heif_error_Decoder_plugin_error,
+ heif_suberror_Unspecified,
+ "Decoding failed: planar YUV format requires 3 planes"};
+ }
+
+ emscripten::val y_plane = planes[0];
+ emscripten::val u_plane = planes[1];
+ emscripten::val v_plane = planes[2];
+ if (y_plane.isUndefined() || u_plane.isUndefined() || v_plane.isUndefined()) {
+ return {heif_error_Decoder_plugin_error,
+ heif_suberror_Unspecified,
+ "Decoding failed: one or more YUV planes are undefined"};
+ }
+ heif_chroma chroma = heif_chroma_420;
+ if (format == "I422") {
+ chroma = heif_chroma_422;
+ } else if (format == "I444") {
+ chroma = heif_chroma_444;
+ }
+
+ return convert_planar_yuv_to_heif_image(
+ buffer.get() + y_plane["offset"].as<int>(),
+ y_plane["stride"].as<int>(),
+ buffer.get() + u_plane["offset"].as<int>(),
+ u_plane["stride"].as<int>(),
+ buffer.get() + v_plane["offset"].as<int>(),
+ v_plane["stride"].as<int>(),
+ width, height,
+ out_img, chroma, is_full_range);
+ } else if (format == "RGBA") {
if (planes["length"].as<size_t>() < 1) {
return {heif_error_Decoder_plugin_error,
heif_suberror_Unspecified,
@@ -770,7 +868,7 @@ static struct heif_error webcodecs_decode_image(void* decoder_raw,
} else {
return {heif_error_Decoder_plugin_error,
heif_suberror_Unsupported_color_conversion,
- "Decoding failed: format is not NV12 or RGBA"};
+ "Decoding failed: unsupported pixel format"};
}
}