diff --git a/src/jpeg.imageio/jpeginput.cpp b/src/jpeg.imageio/jpeginput.cpp index cacd06d337..45ff31db70 100644 --- a/src/jpeg.imageio/jpeginput.cpp +++ b/src/jpeg.imageio/jpeginput.cpp @@ -74,6 +74,21 @@ is_icc_profile_marker(jpeg_saved_marker_ptr marker) sizeof(icc_marker_prefix)); } +// Ultra HDR files using the ISO 21496-1 gain map format (as written by +// libultrahdr 1.x) signal the gain map metadata in an APP2 marker whose +// payload begins with "urn:iso:std:". This is distinct from the legacy Adobe +// gain map format, which is detected via the "hdrgm:Version" XMP attribute. +static const char iso_gainmap_marker_prefix[] = "urn:iso:std:"; + +static bool +is_iso_gainmap_marker(jpeg_saved_marker_ptr marker) +{ + return marker->marker == (JPEG_APP0 + 2) + && marker->data_length >= sizeof(iso_gainmap_marker_prefix) - 1 + && !memcmp(marker->data, iso_gainmap_marker_prefix, + sizeof(iso_gainmap_marker_prefix) - 1); +} + // For explanations of the error handling, see the "example.c" in the // libjpeg distribution. @@ -292,7 +307,10 @@ JpgInput::open(const std::string& name, ImageSpec& newspec) if (!subsampling.empty()) m_spec.attribute(JPEG_SUBSAMPLING_ATTR, subsampling); + bool iso_gainmap_signal = false; for (jpeg_saved_marker_ptr m = m_cinfo.marker_list; m; m = m->next) { + if (is_iso_gainmap_marker(m)) + iso_gainmap_signal = true; if (is_exif_marker(m)) { // The block starts with "Exif\0\0", so skip 6 bytes to get // to the start of the actual Exif data TIFF directory @@ -393,10 +411,13 @@ JpgInput::open(const std::string& name, ImageSpec& newspec) // Try to interpret as Ultra HDR image. // The libultrahdr API requires to load the whole file content in memory - // therefore we first check for the presence of the "hdrgm:Version" metadata - // to avoid this costly process when not necessary. + // therefore we first check for a cheap signal of the format to avoid this + // costly process when not necessary. Two gain map formats are recognized: + // the legacy Adobe format, signaled by the "hdrgm:Version" XMP attribute, + // and the ISO 21496-1 format (written by libultrahdr 1.x), signaled by an + // APP2 "urn:iso:std:" marker. // https://developer.android.com/media/platform/hdr-image-format#signal_of_the_format - if (m_spec.find_attribute("hdrgm:Version")) + if (m_spec.find_attribute("hdrgm:Version") || iso_gainmap_signal) m_is_uhdr = read_uhdr(m_io); newspec = m_spec; diff --git a/src/jpeg.imageio/jpegoutput.cpp b/src/jpeg.imageio/jpegoutput.cpp index 39fea4f327..6624ead14c 100644 --- a/src/jpeg.imageio/jpegoutput.cpp +++ b/src/jpeg.imageio/jpegoutput.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -34,7 +35,11 @@ class JpgOutput final : public ImageOutput { const char* format_name(void) const override { return "jpeg"; } int supports(string_view feature) const override { - return (feature == "exif" || feature == "iptc" || feature == "ioproxy"); + return (feature == "exif" || feature == "iptc" || feature == "ioproxy" +#if defined(USE_UHDR) + || feature == "uhdr" +#endif + ); } bool open(const std::string& name, const ImageSpec& spec, OpenMode mode = Create) override; @@ -46,7 +51,8 @@ class JpgOutput final : public ImageOutput { private: std::string m_filename; unsigned int m_dither; - int m_next_scanline; // Which scanline is the next to write? + int m_next_scanline; // Which scanline is the next to write? + bool m_compressor_created; // Has jpeg_create_compress() been called? std::vector m_scratch; struct jpeg_compress_struct m_cinfo; struct jpeg_error_mgr c_jerr; @@ -65,10 +71,29 @@ class JpgOutput final : public ImageOutput { unsigned long m_outsize = 0; #endif +#if defined(USE_UHDR) + // Ultra HDR output. When m_write_uhdr is true, we bypass the libjpeg + // compressor entirely: scanlines are accumulated into m_uhdr_pixels (a + // full-frame packed RGBA half-float buffer) during write_scanline(), and + // the actual encode via libultrahdr happens in close(). libultrahdr needs + // the whole raw image at once, so we cannot stream it. + bool m_write_uhdr = false; + std::vector m_uhdr_pixels; // Full-frame packed RGBA, 4 halfs/pixel + uhdr_color_gamut_t m_uhdr_cg = UHDR_CG_UNSPECIFIED; + int m_uhdr_quality = 95; +#endif + void init(void) { m_copy_coeffs = NULL; m_copy_decompressor = NULL; + m_compressor_created = false; +#if defined(USE_UHDR) + m_write_uhdr = false; + m_uhdr_pixels.clear(); + m_uhdr_cg = UHDR_CG_UNSPECIFIED; + m_uhdr_quality = 95; +#endif ioproxy_clear(); clear_outbuffer(); } @@ -96,6 +121,18 @@ class JpgOutput final : public ImageOutput { // Read the XResolution/YResolution and PixelAspectRatio metadata, store // in density fields m_cinfo.X_density,Y_density. void resmeta_to_density(); + +#if defined(USE_UHDR) + // Validate the spec for Ultra HDR output, infer the color gamut, and + // allocate the full-frame pixel buffer. Called from open() when the + // "jpeg:ultrahdr" attribute is set. Returns false (with an error set) if + // the spec is not suitable for Ultra HDR encoding. + bool setup_uhdr_output(); + + // Encode the accumulated m_uhdr_pixels buffer with libultrahdr and write + // the resulting JPEG stream to the IOProxy. Called from close(). + bool encode_uhdr(); +#endif }; @@ -142,8 +179,19 @@ JpgOutput::open(const std::string& name, const ImageSpec& newspec, if (!ioproxy_use_or_open(name)) return false; + // Ultra HDR output. If requested, we take a completely different path that + // buffers the whole image and encodes it with libultrahdr in close(), + // rather than streaming scanlines through libjpeg. + if (m_spec.get_int_attribute("jpeg:ultrahdr")) { + if (!setup_uhdr_output()) + return false; + m_next_scanline = 0; + return true; + } + m_cinfo.err = jpeg_std_error(&c_jerr); // set error handler jpeg_create_compress(&m_cinfo); // create compressor + m_compressor_created = true; Filesystem::IOProxy* m_io = ioproxy(); if (!strcmp(m_io->proxytype(), "file")) { auto fd = reinterpret_cast(m_io)->handle(); @@ -458,6 +506,129 @@ JpgOutput::resmeta_to_density() +#if defined(USE_UHDR) +bool +JpgOutput::setup_uhdr_output() +{ + // libultrahdr's half-float HDR intent requires linear pixel data. Only + // float-based OIIO formats can carry HDR values, so reject integer specs. + if (m_spec.format != TypeDesc::HALF && m_spec.format != TypeDesc::FLOAT) { + errorfmt( + "JPEG Ultra HDR output requires a half or float pixel data type, " + "not \"{}\"", + m_spec.format); + return false; + } + if (m_spec.nchannels < 3) { + errorfmt("JPEG Ultra HDR output requires at least 3 (RGB) channels, " + "not {}", + m_spec.nchannels); + return false; + } + + // The encoder mandates a known color gamut (BT.709 / Display-P3 / BT.2100) + // and linear transfer for the half-float HDR intent. Infer the gamut from + // the image's linear scene-referred color space; anything else (non-linear + // transfer, or primaries we can't map, e.g. ACEScg) is an error. + string_view cs = m_spec.get_string_attribute("oiio:ColorSpace"); + if (equivalent_colorspace(cs, "lin_rec709_scene")) + m_uhdr_cg = UHDR_CG_BT_709; + else if (equivalent_colorspace(cs, "lin_p3d65_scene")) + m_uhdr_cg = UHDR_CG_DISPLAY_P3; + else if (equivalent_colorspace(cs, "lin_rec2020_scene")) + m_uhdr_cg = UHDR_CG_BT_2100; + else { + errorfmt( + "JPEG Ultra HDR output requires a linear Rec.709, P3-D65, or " + "Rec.2020 color space, but the image color space is \"{}\"", + cs.size() ? cs : string_view("unknown")); + return false; + } + + auto compqual = m_spec.decode_compression_metadata("jpeg", 95); + if (Strutil::iequals(compqual.first, "jpeg")) + m_uhdr_quality = clamp(compqual.second, 1, 100); + else + m_uhdr_quality = 95; + + // Allocate the full-frame packed RGBA half buffer. write_scanline() fills + // it row by row; encode_uhdr() consumes it in close(). + m_uhdr_pixels.assign(size_t(m_spec.width) * size_t(m_spec.height) * 4, + half(0.0f)); + m_write_uhdr = true; + return true; +} + + + +bool +JpgOutput::encode_uhdr() +{ + uhdr_raw_image_t img {}; + img.fmt = UHDR_IMG_FMT_64bppRGBAHalfFloat; + img.cg = m_uhdr_cg; + img.ct = UHDR_CT_LINEAR; + img.range = UHDR_CR_FULL_RANGE; + img.w = m_spec.width; + img.h = m_spec.height; + img.planes[UHDR_PLANE_PACKED] = m_uhdr_pixels.data(); + img.planes[UHDR_PLANE_U] = nullptr; + img.planes[UHDR_PLANE_V] = nullptr; + img.stride[UHDR_PLANE_PACKED] = m_spec.width; // in pixels + img.stride[UHDR_PLANE_U] = 0; + img.stride[UHDR_PLANE_V] = 0; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + if (!enc) { + errorfmt("Could not create Ultra HDR encoder"); + return false; + } + + // Helper to check a libultrahdr call result, emit a useful error, and + // release the encoder on failure. + auto check = [&](const uhdr_error_info_t& err, const char* what) -> bool { + if (err.error_code == UHDR_CODEC_OK) + return true; + if (err.has_detail) + errorfmt("Ultra HDR {} failed (code {}): {}", what, + int(err.error_code), err.detail); + else + errorfmt("Ultra HDR {} failed (code {})", what, int(err.error_code)); + uhdr_release_encoder(enc); + return false; + }; + + if (!check(uhdr_enc_set_raw_image(enc, &img, UHDR_HDR_IMG), "set_raw_image")) + return false; + if (!check(uhdr_enc_set_quality(enc, m_uhdr_quality, UHDR_BASE_IMG), + "set_quality")) + return false; + if (!check(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG), + "set_output_format")) + return false; + if (!check(uhdr_encode(enc), "encode")) + return false; + + uhdr_compressed_image_t* stream = uhdr_get_encoded_stream(enc); + if (!stream || !stream->data) { + errorfmt("Ultra HDR encoder produced no output stream"); + uhdr_release_encoder(enc); + return false; + } + + if (ioproxy()->write(stream->data, stream->data_sz) != stream->data_sz) { + errorfmt("Error writing Ultra HDR data to \"{}\"", m_filename); + uhdr_release_encoder(enc); + return false; + } + + uhdr_release_encoder(enc); + return true; +} +#endif + + + bool JpgOutput::write_scanline(int y, int z, TypeDesc format, const void* data, stride_t xstride) @@ -467,6 +638,30 @@ JpgOutput::write_scanline(int y, int z, TypeDesc format, const void* data, errorfmt("Attempt to write scanlines out of order to {}", m_filename); return false; } + +#if defined(USE_UHDR) + if (m_write_uhdr) { + if (y >= m_spec.height) { + errorfmt("Attempt to write too many scanlines to {}", m_filename); + return false; + } + m_spec.auto_stride(xstride, format, m_spec.nchannels); + // Destination row: packed RGBA half. Pre-fill alpha to 1.0 so that + // RGB (3-channel) inputs get an opaque alpha; RGBA inputs overwrite it. + half* row = m_uhdr_pixels.data() + size_t(y) * m_spec.width * 4; + for (int x = 0; x < m_spec.width; ++x) + row[x * 4 + 3] = half(1.0f); + // Copy up to 4 channels (RGB, or RGBA) into the packed buffer, + // converting to half. Any channels beyond 4 are dropped. + int ncopy = std::min(m_spec.nchannels, 4); + convert_image(ncopy, m_spec.width, 1, 1, data, format, xstride, + AutoStride, AutoStride, row, TypeDesc::HALF, + 4 * sizeof(half), AutoStride, AutoStride); + ++m_next_scanline; + return true; + } +#endif + if (y >= (int)m_cinfo.image_height) { errorfmt("Attempt to write too many scanlines to {}", m_filename); return false; @@ -517,6 +712,24 @@ JpgOutput::close() return true; } +#if defined(USE_UHDR) + if (m_write_uhdr) { + // If the caller wrote fewer scanlines than the full height, the tail of + // m_uhdr_pixels is left at its initialized value (0 with alpha 1.0), + // which is a reasonable fill. Encode whatever we have. + bool ok = encode_uhdr(); + init(); + return ok; + } +#endif + + if (!m_compressor_created) { + // open() failed before the libjpeg compressor was created (e.g. a + // rejected Ultra HDR request). Nothing to finalize; just reset. + init(); + return true; + } + if (m_next_scanline < spec().height && m_copy_coeffs == NULL) { // But if we've only written some scanlines, write the rest to avoid // errors