From d89c9c5155017a7a76ff90130e04833d5322022f Mon Sep 17 00:00:00 2001 From: Aras Pranckevicius Date: Mon, 21 Jul 2025 19:26:07 +0200 Subject: [PATCH] Video: HDR video input/output support HDR video files are properly read into Blender, and can be rendered out of Blender. HDR video reading / decoding: - Two flavors of HDR are recognized, based on color related video metadata: "PQ" (Rec.2100 Perceptual Quantizer, aka SMPTE 2084) and "HLG" (Rec.2100 Hybrid-Log-Gamma, aka ARIB STD B67). Both are read effectively into floating point images, and their color space transformations are done through OpenColorIO. - The OCIO config shipped in Blender has been extended to contain Rec.2100-PQ and Rec.2100-HLG color spaces. - Note that if you already had a HDR video in sequencer or movie clip, it would have looked "incorrect" previously, and it will continue to look incorrect, since it already has "wrong" color space assigned to it. Either re-add it (which should assign the correct color space), or manually change the color space to PQ or HLG one as needed. HDR video writing / encoding" - For H.265 and AV1 the video encoding options now display the HDR mode. Similar to reading, there are PQ and HLG HDR mode options. - Reference white is assumed to be 100 nits. - YUV uses "full" ("PC/jpeg") color range. - No mastering display metadata is written into the video file, since generally that information is not known inside Blender. More details and screenshots in the PR. Co-authored-by: Sergey Sharybin Pull Request: https://projects.blender.org/blender/blender/pulls/120033 --- release/datafiles/colormanagement/config.ocio | 32 +++ scripts/startup/bl_ui/properties_output.py | 10 +- source/blender/imbuf/IMB_colormanagement.hh | 16 +- .../blender/imbuf/intern/colormanagement.cc | 33 ++- .../blender/imbuf/movie/intern/movie_read.cc | 67 ++++- .../blender/imbuf/movie/intern/movie_write.cc | 235 ++++++++++++++++-- source/blender/makesdna/DNA_scene_types.h | 8 +- source/blender/makesrna/intern/rna_scene.cc | 23 ++ .../ffmpeg/hdr_input_hlg_12bit.blend | 3 + .../ffmpeg/hdr_input_pq_12bit.blend | 3 + .../ffmpeg/media/generate/README.txt | 14 ++ .../hdr_simple_export_hlg_12bit.blend | 3 + .../generate/hdr_simple_export_pq_12bit.blend | 3 + .../generate/hdr_simple_still_test_file.blend | 3 + .../media/hdr_simple_export_hlg_12bit.mov | 3 + .../media/hdr_simple_export_pq_12bit.mov | 3 + .../media/hdr_simple_still_test_file.exr | 3 + .../ffmpeg/reference/hdr_input_hlg_12bit.png | 3 + .../ffmpeg/reference/hdr_input_pq_12bit.png | 3 + .../reference/video_output_hlg_12bit_mov.png | 3 + .../reference/video_output_pq_12bit_mov.png | 3 + .../video_output_hlg_12bit_mov.blend | 3 + .../video_output_pq_12bit_mov.blend | 3 + tests/python/CMakeLists.txt | 20 ++ tests/python/modules/render_report.py | 9 + tests/python/sequencer_input_colorspace.py | 58 +++++ tests/python/sequencer_render_tests.py | 12 +- tests/python/sequencer_video_output_tests.py | 123 +++++++++ 28 files changed, 667 insertions(+), 35 deletions(-) create mode 100644 tests/files/sequence_editing/ffmpeg/hdr_input_hlg_12bit.blend create mode 100644 tests/files/sequence_editing/ffmpeg/hdr_input_pq_12bit.blend create mode 100644 tests/files/sequence_editing/ffmpeg/media/generate/README.txt create mode 100644 tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_export_hlg_12bit.blend create mode 100644 tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_export_pq_12bit.blend create mode 100644 tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_still_test_file.blend create mode 100644 tests/files/sequence_editing/ffmpeg/media/hdr_simple_export_hlg_12bit.mov create mode 100644 tests/files/sequence_editing/ffmpeg/media/hdr_simple_export_pq_12bit.mov create mode 100644 tests/files/sequence_editing/ffmpeg/media/hdr_simple_still_test_file.exr create mode 100644 tests/files/sequence_editing/ffmpeg/reference/hdr_input_hlg_12bit.png create mode 100644 tests/files/sequence_editing/ffmpeg/reference/hdr_input_pq_12bit.png create mode 100644 tests/files/sequence_editing/video_output/reference/video_output_hlg_12bit_mov.png create mode 100644 tests/files/sequence_editing/video_output/reference/video_output_pq_12bit_mov.png create mode 100644 tests/files/sequence_editing/video_output/video_output_hlg_12bit_mov.blend create mode 100644 tests/files/sequence_editing/video_output/video_output_pq_12bit_mov.blend create mode 100644 tests/python/sequencer_input_colorspace.py create mode 100644 tests/python/sequencer_video_output_tests.py diff --git a/release/datafiles/colormanagement/config.ocio b/release/datafiles/colormanagement/config.ocio index 72a87fca13b..171178116d6 100644 --- a/release/datafiles/colormanagement/config.ocio +++ b/release/datafiles/colormanagement/config.ocio @@ -461,6 +461,38 @@ colorspaces: - ! {src: pbrNeutral.cube, interpolation: tetrahedral} - ! {src: Linear Rec.709, dst: sRGB} + - ! + name: Rec.2100-PQ + aliases: [rec2100_pq_display, Rec.2100-PQ - Display, pq_rec2020_display] + family: Display + equalitygroup: "" + bitdepth: 32f + description: Rec.2100-PQ 10000 nits peak display with reference white at 100 nits + isdata: false + categories: [file-io] + encoding: hdr-video + allocation: uniform + from_scene_reference: ! + children: + - ! {src: Linear CIE-XYZ E, dst: Linear CIE-XYZ D65} + - ! {style: DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ} + + - ! + name: Rec.2100-HLG + aliases: [rec2100_hlg_display, Rec.2100-HLG - Display, hlg_rec2020_display] + family: Display + equalitygroup: "" + bitdepth: 32f + description: Rec.2100-HLG 1000 nits peak display with reference white at 100 nits + isdata: false + categories: [file-io] + encoding: hdr-video + allocation: uniform + from_scene_reference: ! + children: + - ! {src: Linear CIE-XYZ E, dst: Linear CIE-XYZ D65} + - ! {style: DISPLAY - CIE-XYZ-D65_to_REC.2100-HLG-1000nit} + looks: - ! name: Very High Contrast diff --git a/scripts/startup/bl_ui/properties_output.py b/scripts/startup/bl_ui/properties_output.py index 3dfebccd239..bb6de4ab843 100644 --- a/scripts/startup/bl_ui/properties_output.py +++ b/scripts/startup/bl_ui/properties_output.py @@ -528,13 +528,21 @@ class RENDER_PT_encoding_video(RenderOutputButtonsPanel, Panel): if needs_codec and ffmpeg.codec == 'NONE': return + image_settings = context.scene.render.image_settings + # Color depth. List of codecs needs to be in sync with # `IMB_ffmpeg_valid_bit_depths` in source code. use_bpp = needs_codec and ffmpeg.codec in {'H264', 'H265', 'AV1', 'PRORES', 'FFV1'} if use_bpp: - image_settings = context.scene.render.image_settings layout.prop(image_settings, "color_depth", expand=True) + # HDR options. + use_hdr = needs_codec and ffmpeg.codec in { + 'H265', 'AV1'} and image_settings.color_depth in { + '10', '12'} and image_settings.color_mode != 'BW' + if use_hdr: + layout.prop(ffmpeg, "video_hdr") + if ffmpeg.codec == 'DNXHD': layout.prop(ffmpeg, "use_lossless_output") diff --git a/source/blender/imbuf/IMB_colormanagement.hh b/source/blender/imbuf/IMB_colormanagement.hh index 8477198aad8..21fe2c3f2e7 100644 --- a/source/blender/imbuf/IMB_colormanagement.hh +++ b/source/blender/imbuf/IMB_colormanagement.hh @@ -47,10 +47,22 @@ void IMB_colormanagegent_copy_settings(ImBuf *ibuf_src, ImBuf *ibuf_dst); void IMB_colormanagement_assign_float_colorspace(ImBuf *ibuf, const char *name); void IMB_colormanagement_assign_byte_colorspace(ImBuf *ibuf, const char *name); -const char *IMB_colormanagement_get_float_colorspace(ImBuf *ibuf); -const char *IMB_colormanagement_get_rect_colorspace(ImBuf *ibuf); +const char *IMB_colormanagement_get_float_colorspace(const ImBuf *ibuf); +const char *IMB_colormanagement_get_rect_colorspace(const ImBuf *ibuf); const char *IMB_colormanagement_space_from_filepath_rules(const char *filepath); +/* Get colorspace name used for Rec.2100 PQ Display conversion. + * + * Searches for one of the color spaces or aliases: Rec.2100-PQ, Rec.2100-PQ - Display, rec2100_pq, + * rec2100_pq_display. If none found returns nullptr. */ +const char *IMB_colormanagement_get_rec2100_pq_display_colorspace(); + +/* Get colorspace name used for Rec.2100 HLG Display conversion. + * + * Searches for one of the color spaces or aliases: Rec.2100-HLG, Rec.2100-HLG - Display, + * rec2100_hlg, rec2100_hlg_display. If none found returns nullptr. */ +const char *IMB_colormanagement_get_rec2100_hlg_display_colorspace(); + const ColorSpace *IMB_colormanagement_space_get_named(const char *name); bool IMB_colormanagement_space_is_data(const ColorSpace *colorspace); bool IMB_colormanagement_space_is_scene_linear(const ColorSpace *colorspace); diff --git a/source/blender/imbuf/intern/colormanagement.cc b/source/blender/imbuf/intern/colormanagement.cc index 63f6c6e667d..96ff8ea4ee2 100644 --- a/source/blender/imbuf/intern/colormanagement.cc +++ b/source/blender/imbuf/intern/colormanagement.cc @@ -1140,7 +1140,7 @@ void IMB_colormanagement_assign_byte_colorspace(ImBuf *ibuf, const char *name) } } -const char *IMB_colormanagement_get_float_colorspace(ImBuf *ibuf) +const char *IMB_colormanagement_get_float_colorspace(const ImBuf *ibuf) { if (ibuf->float_buffer.colorspace) { return ibuf->float_buffer.colorspace->name().c_str(); @@ -1149,7 +1149,7 @@ const char *IMB_colormanagement_get_float_colorspace(ImBuf *ibuf) return IMB_colormanagement_role_colorspace_name_get(COLOR_ROLE_SCENE_LINEAR); } -const char *IMB_colormanagement_get_rect_colorspace(ImBuf *ibuf) +const char *IMB_colormanagement_get_rect_colorspace(const ImBuf *ibuf) { if (ibuf->byte_buffer.colorspace) { return ibuf->byte_buffer.colorspace->name().c_str(); @@ -1163,6 +1163,35 @@ const char *IMB_colormanagement_space_from_filepath_rules(const char *filepath) return g_config->get_color_space_from_filepath(filepath); } +static const char *get_first_resolved_colorspace_name(const blender::Span names) +{ + for (const char *name : names) { + const ColorSpace *colorspace = IMB_colormanagement_space_get_named(name); + if (colorspace) { + return colorspace->name().c_str(); + } + } + return nullptr; +} + +const char *IMB_colormanagement_get_rec2100_pq_display_colorspace() +{ + return get_first_resolved_colorspace_name({"Rec.2100-PQ", + "Rec.2100-PQ - Display", + "rec2100_pq", + "rec2100_pq_display", + "pq_rec2020_display"}); +} + +const char *IMB_colormanagement_get_rec2100_hlg_display_colorspace() +{ + return get_first_resolved_colorspace_name({"Rec.2100-HLG", + "Rec.2100-HLG - Display", + "rec2100_hlg", + "rec2100_hlg_display", + "hlg_rec2020_display"}); +} + const ColorSpace *IMB_colormanagement_space_get_named(const char *name) { return g_config->get_color_space(name); diff --git a/source/blender/imbuf/movie/intern/movie_read.cc b/source/blender/imbuf/movie/intern/movie_read.cc index 5b2cc1a5ae9..5bf381d6d77 100644 --- a/source/blender/imbuf/movie/intern/movie_read.cc +++ b/source/blender/imbuf/movie/intern/movie_read.cc @@ -16,6 +16,7 @@ #include #include "BLI_path_utils.hh" +#include "BLI_span.hh" #include "BLI_string.h" #include "BLI_task.hh" #include "BLI_threads.h" @@ -55,6 +56,8 @@ extern "C" { static void free_anim_ffmpeg(MovieReader *anim); #endif +static bool anim_getnew(MovieReader *anim); + void MOV_close(MovieReader *anim) { if (anim == nullptr) { @@ -98,6 +101,45 @@ IDProperty *MOV_load_metadata(MovieReader *anim) return anim->metadata; } +static void probe_video_colorspace(MovieReader *anim, char r_colorspace_name[IM_MAX_SPACE]) +{ + /* Use default role as fallback (i.e. it is an unknown combination of colorspace and primaries) + */ + BLI_strncpy(r_colorspace_name, + IMB_colormanagement_role_colorspace_name_get(COLOR_ROLE_DEFAULT_BYTE), + IM_MAX_SPACE); + + if (anim->state == MovieReader::State::Uninitialized) { + if (!anim_getnew(anim)) { + return; + } + } + + const AVColorTransferCharacteristic color_trc = anim->pCodecCtx->color_trc; + const AVColorSpace colorspace = anim->pCodecCtx->colorspace; + const AVColorPrimaries color_primaries = anim->pCodecCtx->color_primaries; + + if (color_trc == AVCOL_TRC_ARIB_STD_B67 && color_primaries == AVCOL_PRI_BT2020 && + colorspace == AVCOL_SPC_BT2020_NCL) + { + const char *hlg_name = IMB_colormanagement_get_rec2100_hlg_display_colorspace(); + if (hlg_name) { + BLI_strncpy(r_colorspace_name, hlg_name, IM_MAX_SPACE); + } + return; + } + + if (color_trc == AVCOL_TRC_SMPTEST2084 && color_primaries == AVCOL_PRI_BT2020 && + colorspace == AVCOL_SPC_BT2020_NCL) + { + const char *pq_name = IMB_colormanagement_get_rec2100_pq_display_colorspace(); + if (pq_name) { + BLI_strncpy(r_colorspace_name, pq_name, IM_MAX_SPACE); + } + return; + } +} + MovieReader *MOV_open_file(const char *filepath, const int ib_flags, const int streamindex, @@ -110,20 +152,27 @@ MovieReader *MOV_open_file(const char *filepath, anim = MEM_new("anim struct"); if (anim != nullptr) { - /* Initialize colorspace to default if not yet set. */ - const char *default_colorspace = IMB_colormanagement_role_colorspace_name_get( - COLOR_ROLE_DEFAULT_BYTE); - if (colorspace && colorspace[0] == '\0') { - BLI_strncpy(colorspace, default_colorspace, IM_MAX_SPACE); - } - - /* Inherit colorspace from argument if provided. */ - STRNCPY(anim->colorspace, colorspace ? colorspace : default_colorspace); STRNCPY(anim->filepath, filepath); anim->ib_flags = ib_flags; anim->streamindex = streamindex; anim->keep_original_colorspace = keep_original_colorspace; + + if (colorspace && colorspace[0] != '\0') { + /* Use colorspace from argument, if provided. */ + STRNCPY(anim->colorspace, colorspace); + } + else { + /* Try to initialize colorspace from the FFmpeg stream by interpreting color information from + * it. */ + char file_colorspace[IM_MAX_SPACE]; + probe_video_colorspace(anim, file_colorspace); + STRNCPY(anim->colorspace, file_colorspace); + if (colorspace) { + /* Copy the used colorspace into output argument. */ + BLI_strncpy(colorspace, file_colorspace, IM_MAX_SPACE); + } + } } return anim; } diff --git a/source/blender/imbuf/movie/intern/movie_write.cc b/source/blender/imbuf/movie/intern/movie_write.cc index d07c2b1fdc4..31b9d497039 100644 --- a/source/blender/imbuf/movie/intern/movie_write.cc +++ b/source/blender/imbuf/movie/intern/movie_write.cc @@ -39,6 +39,8 @@ # include "MOV_enums.hh" # include "MOV_util.hh" +# include "IMB_colormanagement.hh" + # include "ffmpeg_swscale.hh" # include "movie_util.hh" @@ -200,15 +202,167 @@ static bool write_video_frame(MovieWriter *context, AVFrame *frame, ReportList * return success; } -/* read and encode a frame of video from the buffer */ -static AVFrame *generate_video_frame(MovieWriter *context, const ImBuf *image) +/* Allocate new ImBuf of the size of the given input which only contains float buffer with pixels + * from the input. + * + * For the float image buffers it is similar to IMB_dupImBuf() but it ensures that the byte buffer + * is not allocated. + * + * For the byte image buffers it is similar to IMB_dupImBuf() followed by IMB_float_from_byte(), + * but without temporary allocation, and result containing only single float buffer. + * + * No color space conversion is performed. The result float buffer might be in a non-linear space + * denoted by the float_buffer.colorspace. */ +static ImBuf *alloc_imbuf_for_hdr_transform(const ImBuf *input_ibuf) { + if (!input_ibuf) { + return nullptr; + } + + /* Allocate new image buffer without float buffer just yet. + * This allows to properly initialize the number of channels used in the buffer. */ + /* TODO(sergey): Make it a reusable function. + * This is a common pattern used in few areas with the goal to bypass the hardcoded number of + * channels used by IMB_allocImBuf(). */ + ImBuf *result_ibuf = IMB_allocImBuf(input_ibuf->x, input_ibuf->y, input_ibuf->planes, 0); + result_ibuf->channels = input_ibuf->float_buffer.data ? input_ibuf->channels : 4; + + /* Allocate float buffer with the proper number of channels. */ + const size_t num_pixels = IMB_get_pixel_count(input_ibuf); + float *buffer = MEM_malloc_arrayN(num_pixels * result_ibuf->channels, "movie hdr image"); + IMB_assign_float_buffer(result_ibuf, buffer, IB_TAKE_OWNERSHIP); + + /* Transfer flags related to color space conversion from the original image buffer. */ + result_ibuf->flags |= (input_ibuf->flags & IB_alphamode_channel_packed); + + if (input_ibuf->float_buffer.data) { + /* Simple case: copy pixels from the source image as-is, without any conversion. + * The result has the same colorspace as the input. */ + memcpy(result_ibuf->float_buffer.data, + input_ibuf->float_buffer.data, + num_pixels * input_ibuf->channels * sizeof(float)); + result_ibuf->float_buffer.colorspace = input_ibuf->float_buffer.colorspace; + } + else { + /* Convert byte buffer to float buffer. + * The exact profile is not important here: it should match for the source and destination so + * that the function only does alpha and byte->float conversions. */ + const bool predivide = IMB_alpha_affects_rgb(input_ibuf); + IMB_buffer_float_from_byte(buffer, + input_ibuf->byte_buffer.data, + IB_PROFILE_SRGB, + IB_PROFILE_SRGB, + predivide, + input_ibuf->x, + input_ibuf->y, + result_ibuf->x, + input_ibuf->x); + } + + return result_ibuf; +} + +static ImBuf *do_pq_transform(const ImBuf *input_ibuf) +{ + ImBuf *ibuf = alloc_imbuf_for_hdr_transform(input_ibuf); + if (!ibuf) { + /* Error in input or allocation has failed. */ + return nullptr; + } + + /* Get `Rec.2100-PQ Display` or its alias from the OpenColorIO configuration. */ + const char *rec2100_pq_colorspace = IMB_colormanagement_get_rec2100_pq_display_colorspace(); + if (!rec2100_pq_colorspace) { + /* TODO(sergey): Error reporting if the colorspace is not found. */ + return ibuf; + } + + /* Convert from the current floating point buffer colorspace to Rec.2100-PQ. */ + IMB_colormanagement_transform_float(ibuf->float_buffer.data, + ibuf->x, + ibuf->y, + ibuf->channels, + IMB_colormanagement_get_float_colorspace(input_ibuf), + rec2100_pq_colorspace, + IMB_alpha_affects_rgb(ibuf)); + + return ibuf; +} + +static ImBuf *do_hlg_transform(const ImBuf *input_ibuf) +{ + ImBuf *ibuf = alloc_imbuf_for_hdr_transform(input_ibuf); + if (!ibuf) { + /* Error in input or allocation has failed. */ + return nullptr; + } + + /* Get `Rec.2100-HLG Display` or its alias from the OpenColorIO configuration. + * The color space is supposed to be Rec.2100-HLG, 1000 nit. */ + const char *rec2100_hlg_colorspace = IMB_colormanagement_get_rec2100_hlg_display_colorspace(); + if (!rec2100_hlg_colorspace) { + /* TODO(sergey): Error reporting if the colorspace is not found. */ + return ibuf; + } + + /* Convert from the current floating point buffer colorspace to Rec.2100-HLG, 1000 nit. */ + IMB_colormanagement_transform_float(ibuf->float_buffer.data, + ibuf->x, + ibuf->y, + ibuf->channels, + IMB_colormanagement_get_float_colorspace(input_ibuf), + rec2100_hlg_colorspace, + IMB_alpha_affects_rgb(ibuf)); + + return ibuf; +} + +static const ImBuf *do_hdr_transform_if_needed(MovieWriter *context, const ImBuf *input_ibuf) +{ + if (!input_ibuf) { + return nullptr; + } + + if (!context || !context->video_codec) { + return input_ibuf; + } + + const AVCodecContext &codec = *context->video_codec; + + const AVColorTransferCharacteristic color_trc = codec.color_trc; + const AVColorSpace colorspace = codec.colorspace; + const AVColorPrimaries color_primaries = codec.color_primaries; + + if (color_trc == AVCOL_TRC_SMPTEST2084 && color_primaries == AVCOL_PRI_BT2020 && + colorspace == AVCOL_SPC_BT2020_NCL) + { + return do_pq_transform(input_ibuf); + } + + if (color_trc == AVCOL_TRC_ARIB_STD_B67 && color_primaries == AVCOL_PRI_BT2020 && + colorspace == AVCOL_SPC_BT2020_NCL) + { + return do_hlg_transform(input_ibuf); + } + + return input_ibuf; +} + +/* read and encode a frame of video from the buffer */ +static AVFrame *generate_video_frame(MovieWriter *context, const ImBuf *input_ibuf) +{ + const ImBuf *image = do_hdr_transform_if_needed(context, input_ibuf); + const uint8_t *pixels = image->byte_buffer.data; const float *pixels_fl = image->float_buffer.data; + /* Use float input if needed. */ const bool use_float = context->img_convert_frame != nullptr && context->img_convert_frame->format != AV_PIX_FMT_RGBA; if ((!use_float && (pixels == nullptr)) || (use_float && (pixels_fl == nullptr))) { + if (image != input_ibuf) { + IMB_freeImBuf(const_cast(image)); + } return nullptr; } @@ -291,6 +445,10 @@ static AVFrame *generate_video_frame(MovieWriter *context, const ImBuf *image) ffmpeg_sws_scale_frame(context->img_convert_ctx, context->current_frame, rgb_frame); } + if (image != input_ibuf) { + IMB_freeImBuf(const_cast(image)); + } + return context->current_frame; } @@ -770,6 +928,15 @@ static AVStream *alloc_video_stream(MovieWriter *context, const bool is_10_bpp = rd->im_format.depth == R_IMF_CHAN_DEPTH_10; const bool is_12_bpp = rd->im_format.depth == R_IMF_CHAN_DEPTH_12; const bool is_16_bpp = rd->im_format.depth == R_IMF_CHAN_DEPTH_16; + + eFFMpegVideoHdr hdr = eFFMpegVideoHdr(rd->ffcodecdata.video_hdr); + /* Never use HDR for non-10/12 bpp or grayscale outputs. */ + if ((!is_10_bpp && !is_12_bpp) || rd->im_format.planes == R_IMF_PLANES_BW) { + hdr = FFM_VIDEO_HDR_NONE; + } + const bool is_hdr_pq = hdr == FFM_VIDEO_HDR_REC2100_PQ; + const bool is_hdr_hlg = hdr == FFM_VIDEO_HDR_REC2100_HLG; + if (is_10_bpp) { c->pix_fmt = AV_PIX_FMT_YUV420P10LE; } @@ -916,7 +1083,21 @@ static AVStream *alloc_video_stream(MovieWriter *context, /* If output pixel format is not RGB(A), setup colorspace metadata. */ const AVPixFmtDescriptor *pix_fmt_desc = av_pix_fmt_desc_get(c->pix_fmt); const bool set_bt709 = (pix_fmt_desc->flags & AV_PIX_FMT_FLAG_RGB) == 0; - if (set_bt709) { + if (is_hdr_pq) { + /* TODO(sergey): Consider making the range an option to cover more use-cases. */ + c->color_range = AVCOL_RANGE_JPEG; + c->color_primaries = AVCOL_PRI_BT2020; + c->color_trc = AVCOL_TRC_SMPTEST2084; + c->colorspace = AVCOL_SPC_BT2020_NCL; + } + else if (is_hdr_hlg) { + /* TODO(sergey): Consider making the range an option to cover more use-cases. */ + c->color_range = AVCOL_RANGE_JPEG; + c->color_primaries = AVCOL_PRI_BT2020; + c->color_trc = AVCOL_TRC_ARIB_STD_B67; + c->colorspace = AVCOL_SPC_BT2020_NCL; + } + else if (set_bt709) { c->color_range = AVCOL_RANGE_MPEG; c->color_primaries = AVCOL_PRI_BT709; c->color_trc = AVCOL_TRC_BT709; @@ -966,22 +1147,40 @@ static AVStream *alloc_video_stream(MovieWriter *context, context->img_convert_ctx = nullptr; } else { - /* Output pixel format is different, allocate frame for conversion. */ - AVPixelFormat src_format = is_10_bpp || is_12_bpp || is_16_bpp ? AV_PIX_FMT_GBRAPF32LE : - AV_PIX_FMT_RGBA; + /* Output pixel format is different, allocate frame for conversion. + * Setup RGB->YUV conversion with proper coefficients (depending on whether it is SDR BT.709, + * or HDR BT.2020). */ + const AVPixelFormat src_format = is_10_bpp || is_12_bpp || is_16_bpp ? AV_PIX_FMT_GBRAPF32LE : + AV_PIX_FMT_RGBA; context->img_convert_frame = alloc_frame(src_format, c->width, c->height); - /* Setup BT.709 coefficients for RGB->YUV conversion, if needed. */ - context->img_convert_ctx = ffmpeg_sws_get_context(c->width, - c->height, - src_format, - false, - -1, - c->width, - c->height, - c->pix_fmt, - false, - set_bt709 ? AVCOL_SPC_BT709 : -1, - SWS_BICUBIC); + if (is_hdr_pq || is_hdr_hlg) { + /* Special conversion for the Rec.2100 PQ and HLG output: the result color space is BT.2020, + * and also use full range. */ + context->img_convert_ctx = ffmpeg_sws_get_context(c->width, + c->height, + src_format, + true, + -1, + c->width, + c->height, + c->pix_fmt, + true, + AVCOL_SPC_BT2020_NCL, + SWS_BICUBIC); + } + else { + context->img_convert_ctx = ffmpeg_sws_get_context(c->width, + c->height, + src_format, + false, + -1, + c->width, + c->height, + c->pix_fmt, + false, + set_bt709 ? AVCOL_SPC_BT709 : -1, + SWS_BICUBIC); + } } avcodec_parameters_from_context(st->codecpar, c); diff --git a/source/blender/makesdna/DNA_scene_types.h b/source/blender/makesdna/DNA_scene_types.h index b3a3d89f20d..47a6be02614 100644 --- a/source/blender/makesdna/DNA_scene_types.h +++ b/source/blender/makesdna/DNA_scene_types.h @@ -155,6 +155,12 @@ typedef enum IMB_Ffmpeg_Codec_ID { FFMPEG_CODEC_ID_OPUS = 86076, } IMB_Ffmpeg_Codec_ID; +typedef enum eFFMpegVideoHdr { + FFM_VIDEO_HDR_NONE = 0, + FFM_VIDEO_HDR_REC2100_HLG = 1, + FFM_VIDEO_HDR_REC2100_PQ = 2, +} eFFMpegVideoHdr; + typedef struct FFMpegCodecData { int type; int codec; /* Use `codec_id_get()` instead! IMB_Ffmpeg_Codec_ID */ @@ -178,7 +184,7 @@ typedef struct FFMpegCodecData { int rc_buffer_size; int mux_packet_size; int mux_rate; - char _pad0[4]; + int video_hdr; /* eFFMpegVideoHdr */ #ifdef __cplusplus IMB_Ffmpeg_Codec_ID codec_id_get() const diff --git a/source/blender/makesrna/intern/rna_scene.cc b/source/blender/makesrna/intern/rna_scene.cc index bc259ef3b4a..cdad373950e 100644 --- a/source/blender/makesrna/intern/rna_scene.cc +++ b/source/blender/makesrna/intern/rna_scene.cc @@ -6481,6 +6481,21 @@ static void rna_def_scene_ffmpeg_settings(BlenderRNA *brna) {0, nullptr, 0, nullptr, nullptr}, }; + static const EnumPropertyItem ffmpeg_hdr_items[] = { + {FFM_VIDEO_HDR_NONE, "NONE", 0, "None", "No High Dynamic Range"}, + {FFM_VIDEO_HDR_REC2100_PQ, + "REQ2100_PQ", + 0, + "Rec.2100 PQ", + "Rec.2100 color space with Perceptual Quantizer HDR encoding"}, + {FFM_VIDEO_HDR_REC2100_HLG, + "REQ2100_HLG", + 0, + "Rec.2100 HLG", + "Rec.2100 color space with Hybrid-Log Gamma HDR encoding"}, + {0, nullptr, 0, nullptr, nullptr}, + }; + static const EnumPropertyItem ffmpeg_audio_codec_items[] = { {FFMPEG_CODEC_ID_NONE, "NONE", @@ -6543,6 +6558,14 @@ static void rna_def_scene_ffmpeg_settings(BlenderRNA *brna) RNA_def_property_ui_text(prop, "Bitrate", "Video bitrate (kbit/s)"); RNA_def_property_update(prop, NC_SCENE | ND_RENDER_OPTIONS, nullptr); + prop = RNA_def_property(srna, "video_hdr", PROP_ENUM, PROP_NONE); + RNA_def_property_enum_sdna(prop, nullptr, "video_hdr"); + RNA_def_property_clear_flag(prop, PROP_ANIMATABLE); + RNA_def_property_enum_items(prop, ffmpeg_hdr_items); + RNA_def_property_enum_default(prop, FFM_VIDEO_HDR_NONE); + RNA_def_property_ui_text(prop, "HDR", "High Dynamic Range options"); + RNA_def_property_update(prop, NC_SCENE | ND_RENDER_OPTIONS, nullptr); + prop = RNA_def_property(srna, "minrate", PROP_INT, PROP_NONE); RNA_def_property_int_sdna(prop, nullptr, "rc_min_rate"); RNA_def_property_clear_flag(prop, PROP_ANIMATABLE); diff --git a/tests/files/sequence_editing/ffmpeg/hdr_input_hlg_12bit.blend b/tests/files/sequence_editing/ffmpeg/hdr_input_hlg_12bit.blend new file mode 100644 index 00000000000..f0b6986e6ac --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/hdr_input_hlg_12bit.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f282b202f961a7564542372749a1b38ebf691852c32427698fd003626f59500c +size 316552 diff --git a/tests/files/sequence_editing/ffmpeg/hdr_input_pq_12bit.blend b/tests/files/sequence_editing/ffmpeg/hdr_input_pq_12bit.blend new file mode 100644 index 00000000000..7dba01cf9df --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/hdr_input_pq_12bit.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5146edf305216d49e33287e0495a7837e9eac58191071756734d65becbdef38 +size 316552 diff --git a/tests/files/sequence_editing/ffmpeg/media/generate/README.txt b/tests/files/sequence_editing/ffmpeg/media/generate/README.txt new file mode 100644 index 00000000000..81d076fc71a --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/media/generate/README.txt @@ -0,0 +1,14 @@ +.blend files used to generate: +- hdr_simple_export_hlg_12bit.mov +- hdr_simple_export_pq_12bit.mov + +Step 1: +Open and render hdr_simple_still_test_file.blend +It will generate hdr_simple_still_test_file.exr file + +Step 2: +Open and render hdr_simple_export_hlg_12bit.blend and hdr_simple_export_pq_12bit.blend. +These files generate videos in the out/ folder. + +Step 3: +Copy files from the out/ folder to their resired final destination. diff --git a/tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_export_hlg_12bit.blend b/tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_export_hlg_12bit.blend new file mode 100644 index 00000000000..c2b7e3e7fca --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_export_hlg_12bit.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:981c1e83f0d6d9065f04acca51c9e74094d1975d7d554677a9178c6668602050 +size 314536 diff --git a/tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_export_pq_12bit.blend b/tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_export_pq_12bit.blend new file mode 100644 index 00000000000..c0903804616 --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_export_pq_12bit.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c3f1b7419fd2a445ee0e555c6db5026417b8cc4651aa48415ae98ef05d0a6e9 +size 314536 diff --git a/tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_still_test_file.blend b/tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_still_test_file.blend new file mode 100644 index 00000000000..93fe7ad7fad --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/media/generate/hdr_simple_still_test_file.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3d4ef2e02cfaf99d143ac6a5f45beabfdeaddda5aaea05fe6b1a051f8b5f997 +size 1224127 diff --git a/tests/files/sequence_editing/ffmpeg/media/hdr_simple_export_hlg_12bit.mov b/tests/files/sequence_editing/ffmpeg/media/hdr_simple_export_hlg_12bit.mov new file mode 100644 index 00000000000..867639092a2 --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/media/hdr_simple_export_hlg_12bit.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:518b14cb1149fd2d7556e4a4affc7dbdd56ba2ac97ae564639a5fbd281a9d4f8 +size 16906 diff --git a/tests/files/sequence_editing/ffmpeg/media/hdr_simple_export_pq_12bit.mov b/tests/files/sequence_editing/ffmpeg/media/hdr_simple_export_pq_12bit.mov new file mode 100644 index 00000000000..bbfa4984c6a --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/media/hdr_simple_export_pq_12bit.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84629be9fc4677242d6c65ed06d51e67775c9ad0446b6d7e9c7f9b480d8552a8 +size 50158 diff --git a/tests/files/sequence_editing/ffmpeg/media/hdr_simple_still_test_file.exr b/tests/files/sequence_editing/ffmpeg/media/hdr_simple_still_test_file.exr new file mode 100644 index 00000000000..402c28817a1 --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/media/hdr_simple_still_test_file.exr @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:52aeb50d49d4e2fd3c8693b6e527502a8dc6e40e01cb44e3a85a8e35bc865080 +size 468319 diff --git a/tests/files/sequence_editing/ffmpeg/reference/hdr_input_hlg_12bit.png b/tests/files/sequence_editing/ffmpeg/reference/hdr_input_hlg_12bit.png new file mode 100644 index 00000000000..3e56691b312 --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/reference/hdr_input_hlg_12bit.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a52c4c8a8ea44d3f1c7a1e95ce58b93dca9aecd6943bc484840efc4fa53d4285 +size 550375 diff --git a/tests/files/sequence_editing/ffmpeg/reference/hdr_input_pq_12bit.png b/tests/files/sequence_editing/ffmpeg/reference/hdr_input_pq_12bit.png new file mode 100644 index 00000000000..33ce1ce0447 --- /dev/null +++ b/tests/files/sequence_editing/ffmpeg/reference/hdr_input_pq_12bit.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e4c511d3e6bea20c8182fae6c3d208e098aaf1d0b42b40ed6e2477842a928cc +size 570050 diff --git a/tests/files/sequence_editing/video_output/reference/video_output_hlg_12bit_mov.png b/tests/files/sequence_editing/video_output/reference/video_output_hlg_12bit_mov.png new file mode 100644 index 00000000000..e0c0cde004b --- /dev/null +++ b/tests/files/sequence_editing/video_output/reference/video_output_hlg_12bit_mov.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:99fa779482012f420251c2c251050a4f5b2df0bb764bcd2e20425718218f6c37 +size 172909 diff --git a/tests/files/sequence_editing/video_output/reference/video_output_pq_12bit_mov.png b/tests/files/sequence_editing/video_output/reference/video_output_pq_12bit_mov.png new file mode 100644 index 00000000000..2b8b895a83e --- /dev/null +++ b/tests/files/sequence_editing/video_output/reference/video_output_pq_12bit_mov.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b32f68219d5a0ef354a5f927bdc67e407bcf6567a197a9021643a9f0dff40520 +size 156812 diff --git a/tests/files/sequence_editing/video_output/video_output_hlg_12bit_mov.blend b/tests/files/sequence_editing/video_output/video_output_hlg_12bit_mov.blend new file mode 100644 index 00000000000..62244a1929b --- /dev/null +++ b/tests/files/sequence_editing/video_output/video_output_hlg_12bit_mov.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:034e8d9d81ad321291b3c2c3887271aa6df17ec10b68703e9fdceb1ef8c066ec +size 313840 diff --git a/tests/files/sequence_editing/video_output/video_output_pq_12bit_mov.blend b/tests/files/sequence_editing/video_output/video_output_pq_12bit_mov.blend new file mode 100644 index 00000000000..2772d9e7c08 --- /dev/null +++ b/tests/files/sequence_editing/video_output/video_output_pq_12bit_mov.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01fce87094967babfd14a51b5f988ba3a8cc48f0e5d6a63ec1cf38fc8aa630f4 +size 313576 diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index 5a814646e0e..0139dac2195 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -1261,6 +1261,10 @@ if(TEST_SRC_DIR_EXISTS) ffmpeg ) + set(video_output_tests + video_output + ) + foreach(render_test ${render_tests}) add_render_test( sequencer_render_${render_test} @@ -1269,6 +1273,22 @@ if(TEST_SRC_DIR_EXISTS) --outdir "${TEST_OUT_DIR}/sequence_editing" ) endforeach() + + foreach(video_output_test ${video_output_tests}) + add_render_test( + sequencer_render_${video_output_test} + ${CMAKE_CURRENT_LIST_DIR}/sequencer_video_output_tests.py + --testdir "${TEST_SRC_DIR}/sequence_editing/${video_output_test}" + --outdir "${TEST_OUT_DIR}/sequence_editing" + ) + endforeach() + + add_blender_test( + sequencer_input_colorspace + --python ${CMAKE_CURRENT_LIST_DIR}/sequencer_input_colorspace.py + -- + --testdir "${TEST_SRC_DIR}/sequence_editing" + ) endif() # ------------------------------------------------------------------------------ diff --git a/tests/python/modules/render_report.py b/tests/python/modules/render_report.py index 4d9e08cc3d7..9330f183fe9 100644 --- a/tests/python/modules/render_report.py +++ b/tests/python/modules/render_report.py @@ -550,6 +550,7 @@ class Report: remaining_filepaths.pop(0) file_crashed = False for test in self._get_filepath_tests(filepath): + self.postprocess_test(blender, test) if not os.path.exists(test.tmp_out_img) or os.path.getsize(test.tmp_out_img) == 0: if crash: # In case of crash, stop after missing files and re-render remaining @@ -589,6 +590,14 @@ class Report: return test_results + def postprocess_test(self, blender, test): + """ + Post-process test result after the Blender has run. + For example, this function is where conversion from video to a still image suitable for image diffing. + """ + + pass + def _run_all_tests(self, dirname, dirpath, blender, arguments_cb, batch, fail_silently): passed_tests = [] failed_tests = [] diff --git a/tests/python/sequencer_input_colorspace.py b/tests/python/sequencer_input_colorspace.py new file mode 100644 index 00000000000..ae73686f758 --- /dev/null +++ b/tests/python/sequencer_input_colorspace.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2015-2025 Blender Authors +# +# SPDX-License-Identifier: Apache-2.0 + +# ./blender.bin --background --factory-startup \ +# --python tests/python/sequencer_input_colorspace.py -- --testdir tests/files/sequence_editing/ + +import bpy + +import argparse +import sys +import unittest + +from pathlib import Path + +TEST_DIR: Path + + +class MovieInputTest(unittest.TestCase): + def get_movie_colorspace(self, filepath: Path): + scene = bpy.context.scene + ed = scene.sequence_editor_create() + strip = ed.strips.new_movie(name='input', filepath=str(filepath), channel=1, frame_start=1) + colorspace = strip.colorspace_settings.name + ed.strips.remove(strip) + return colorspace + + +class FFmpegHDRColorspace(MovieInputTest): + def test_pq(self): + prefix = TEST_DIR / Path("ffmpeg") / "media" + + self.assertEqual(self.get_movie_colorspace(prefix / "hdr_simple_export_pq_12bit.mov"), "Rec.2100-PQ") + + def test_hlg(self): + prefix = TEST_DIR / Path("ffmpeg") / "media" + + self.assertEqual(self.get_movie_colorspace(prefix / "hdr_simple_export_hlg_12bit.mov"), "Rec.2100-HLG") + + +def main(): + global TEST_DIR + + argv = [sys.argv[0]] + if '--' in sys.argv: + argv += sys.argv[sys.argv.index('--') + 1:] + + parser = argparse.ArgumentParser() + parser.add_argument('--testdir', required=True, type=Path) + + args, remaining = parser.parse_known_args(argv) + + TEST_DIR = args.testdir + unittest.main(argv=remaining) + + +if __name__ == "__main__": + main() diff --git a/tests/python/sequencer_render_tests.py b/tests/python/sequencer_render_tests.py index ff5e65957fa..41a464820de 100644 --- a/tests/python/sequencer_render_tests.py +++ b/tests/python/sequencer_render_tests.py @@ -9,6 +9,13 @@ import sys from pathlib import Path +BLOCKLIST = [ + "hdr_simple_export_hlg_12bit.blend", + "hdr_simple_export_pq_12bit.blend", + "hdr_simple_still_test_file.blend", +] + + def get_arguments(filepath, output_filepath): dirname = os.path.dirname(filepath) basedir = os.path.dirname(dirname) @@ -21,8 +28,9 @@ def get_arguments(filepath, output_filepath): "--debug-exit-on-error", filepath, "-o", output_filepath, + "-F", "PNG", "-f", "1", - "-F", "PNG"] + ] return args @@ -44,7 +52,7 @@ def main(): args = parser.parse_args() from modules import render_report - report = render_report.Report("Sequencer", args.outdir, args.oiiotool) + report = render_report.Report("Sequencer", args.outdir, args.oiiotool, blocklist=BLOCKLIST) report.set_pixelated(True) # Default error tolerances are quite large, lower them. report.set_fail_threshold(2.0 / 255.0) diff --git a/tests/python/sequencer_video_output_tests.py b/tests/python/sequencer_video_output_tests.py new file mode 100644 index 00000000000..aec9a7862ea --- /dev/null +++ b/tests/python/sequencer_video_output_tests.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2015-2025 Blender Authors +# +# SPDX-License-Identifier: Apache-2.0 + +import argparse +import sys +import subprocess + +from pathlib import Path +from modules import render_report + + +def get_movie_file_suffix(filepath): + """ + Get suffix used for the video output. + The script does not have access to the .blend file content, so deduct it from the .blend filename. + """ + + return Path(filepath).stem.split("_")[-1] + + +def get_arguments(filepath, output_filepath): + suffix = get_movie_file_suffix(filepath) + + args = [ + "--background", + "--factory-startup", + "--enable-autoexec", + "--debug-memory", + "--debug-exit-on-error", + filepath, + "-o", f"{output_filepath}.{suffix}", + "-a", + ] + + return args + + +def create_argparse(): + parser = argparse.ArgumentParser( + description="Run test script for each blend file in TESTDIR, comparing the render result with known output." + ) + parser.add_argument("--blender", required=True) + parser.add_argument("--testdir", required=True) + parser.add_argument("--outdir", required=True) + parser.add_argument("--oiiotool", required=True) + parser.add_argument("--batch", default=False, action="store_true") + return parser + + +class VideoOutputReport(render_report.Report): + def postprocess_test(self, blender, test): + suffix = get_movie_file_suffix(test.filepath) + + video_file = Path(f"{test.tmp_out_img_base}.{suffix}").as_posix() + + # If oiiotool supports the FFmpeg this could be used instead. + """ + command = ( + self.oiiotool, + "-i", video_file, + "-o", test.tmp_out_img, + ) + + try: + subprocess.check_output(command) + except subprocess.CalledProcessError as e: + pass + """ + + # Blender's render pipeline always appends frame suffix unless # is present in the file path. + # Here we need the file name to match exactly, so we trick Blender by going 0001 -> #### mask + # allowing render piepline to expand it back to 0001. + out_filepath = test.tmp_out_img.replace("0001", "####") + + python_expr = ( + f""" +import bpy +scene = bpy.context.scene +scene.render.resolution_x = 1920 +scene.render.resolution_y = 1080 +scene.render.resolution_percentage = 25 +ed = scene.sequence_editor_create() +strip = ed.strips.new_movie(name='input', filepath='{video_file}', channel=1, frame_start=1) +strip.colorspace_settings.name = 'Non-Color' +""") + + command = ( + blender, + "--background", + "--factory-startup", + "--enable-autoexec", + "--python-expr", python_expr, + "-o", out_filepath, + "-F", "PNG", + "-f", "1", + ) + + try: + subprocess.check_output(command) + except subprocess.CalledProcessError as e: + pass + + +def main(): + parser = create_argparse() + args = parser.parse_args() + + report = VideoOutputReport("Sequencer", args.outdir, args.oiiotool) + report.set_pixelated(True) + # Default error tolerances are quite large, lower them. + report.set_fail_threshold(2.0 / 255.0) + report.set_fail_percent(0.01) + report.set_reference_dir("reference") + + ok = report.run(args.testdir, args.blender, get_arguments, batch=args.batch) + + sys.exit(not ok) + + +if __name__ == "__main__": + main()