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 <sergey@blender.org>
Pull Request: https://projects.blender.org/blender/blender/pulls/120033
This commit is contained in:
committed by
Aras Pranckevicius
parent
7c7c68fd7a
commit
d89c9c5155
@@ -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()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
58
tests/python/sequencer_input_colorspace.py
Normal file
58
tests/python/sequencer_input_colorspace.py
Normal file
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
123
tests/python/sequencer_video_output_tests.py
Normal file
123
tests/python/sequencer_video_output_tests.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user