From 6ef8b16aa2d1a911f7303c9f2c3beb38fee9c3e8 Mon Sep 17 00:00:00 2001 From: Romain Bouqueau Date: Thu, 12 Feb 2026 11:48:04 -0400 Subject: [PATCH 1/2] first VVC additions --- README.md | 5 ++- profiles/vvc.mezzanine_v4.csv | 12 +++++++ src/tcgen/models.py | 8 ++++- src/tcgen/run_encode.py | 67 +++++++++++++++++++++++++++-------- src/tcgen/tcgen.py | 9 +++-- 5 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 profiles/vvc.mezzanine_v4.csv diff --git a/README.md b/README.md index 0f65729..b568aa7 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Note: Once installed in a [python environment](https://docs.python.org/3/library 7. Upload batch content 8. Download database content -*_Important_*: the following workflow has been implemented while generating HEVC test content. Although it hasn't been tested with AVC content, it is expected to work exactly the same. For audio content, a [separate set of instructions](https://github.com/cta-wave/Test-Content-Generation/blob/master/Instructions/audio.md) is available. It is suggested to use this worflow for all content future generation. +*_Important_*: the following workflow has been implemented while generating HEVC test content. Although it hasn't been tested thoroughly with other codecs, it is expected to work exactly the same. For audio content, a [separate set of instructions](https://github.com/cta-wave/Test-Content-Generation/blob/master/Instructions/audio.md) is available. It is suggested to use this worflow for all content future generation. ### 1. Download mezzanine content @@ -67,7 +67,6 @@ Batch files used to produce reference content is stored in the [./profiles](prof ### 3. Batch encode/package content - #### 3.1 Video content typical usage of `tcgen encode`: @@ -81,7 +80,7 @@ For detail on each available options use : `tcgen encode --help` -The encoding and packaging is performed using [GPAC](http://gpac.io), leveraging [libavcodec](https://ffmpeg.org/libavcodec.html) with [x264](http://www.videolan.org/developers/x264.html) and [x265](https://www.x265.org/) to generate the CMAF content along with a DASH manifest. The intent is to keep the size of the post-processing (e.g. manifest manipulation) as small as possible. +The encoding and packaging is performed using [GPAC](http://gpac.io), leveraging [libavcodec](https://ffmpeg.org/libavcodec.html) with [x264](http://www.videolan.org/developers/x264.html), [x265](https://www.x265.org/) and [VVenC](https://github.com/fraunhoferhhi/vvenc) to generate the CMAF content along with a DASH manifest. The intent is to keep the size of the post-processing (e.g. manifest manipulation) as small as possible. diff --git a/profiles/vvc.mezzanine_v4.csv b/profiles/vvc.mezzanine_v4.csv new file mode 100644 index 0000000..b5ac5cb --- /dev/null +++ b/profiles/vvc.mezzanine_v4.csv @@ -0,0 +1,12 @@ +Stream ID,mezzanine radius,pic timing,VUI timing,sample entry,CMAF frag dur,init constraints,frag_type,resolution,framerate,bitrate,duration,cmaf_profile,wave_profile,cenc,sar,mezzanine_prefix_25HZ,mezzanine_prefix_30HZ +1,L1_1920x1080,False,True,hvc1,2.0,multiple,duration,1920x1080,1,8000,30.0,cmf2,vvc1,False,1/1,croatia_,tos_ +#1_enc,L1_1920x1080,False,True,hvc1,2.0,multiple,duration,1920x1080,1,8000,30.0,cmf2,vvc1,True,1/1,croatia_,tos_ +#2,L1_1920x1080,True,False,hev1,2.0,single,pframes,1920x1080,1,8000,30.0,cmfc,vvc1,False,1/1,croatia_,tos_ +#3,L1_1920x1080,True,True,hvc1,2.0,multiple,duration,1920x1080,1,8000,30.0,cmf2,vvc1,False,1/1,croatia_,tos_ +#4,L1_1920x1080,False,False,hvc1,2.0,multiple,duration,1920x1080,1,8000,30.0,cmf2,vvc1,False,1/1,croatia_,tos_ +#5,L1_1920x1080,False,True,hev1,2.0,multiple,duration,1920x1080,1,8000,30.0,cmf2,vvc1,False,1/1,croatia_,tos_ +#6,L1_1920x1080,False,True,hvc1,2.0,multiple,pframes,1920x1080,1,8000,30.0,cmf2,vvc1,False,1/1,croatia_,tos_ +#7,L1_1920x1080,False,True,hvc1,2.0,multiple,duration,1920x1080,1,8000,30.0,cmfc,vvc1,False,1/1,croatia_,tos_ +#10,L1_1920x1080,False,True,hvc1,2.0,multiple,duration,1920x1080,1,8000,60.0,cmf2,vvc1,False,1/1,croatia_,tos_ +#11,L1_1920x1080,False,True,hvc1,2.0,multiple,duration,1920x1080,0.5,5000,60.0,cmf2,vvc1,False,1/1,croatia_,tos_ +#12,J1_1280x720,False,True,hvc1,2.0,multiple,duration,1280x720,0.5,3100,60.0,cmf2,vvc1,False,1/1,croatia_,tos_ diff --git a/src/tcgen/models.py b/src/tcgen/models.py index 26cbcac..70def31 100644 --- a/src/tcgen/models.py +++ b/src/tcgen/models.py @@ -16,6 +16,7 @@ "cud1": ("video", "h265", None), "clg1": ("video", "h265", None), "chd1": ("video", "h265", None), + "vvc1": ("video", "h266", None), "caac": ("audio", "aac", "caac"), "dts1": ("audio", "copy", None), "dts2": ("audio", "copy", None) @@ -239,6 +240,7 @@ class CmafBrand(str, Enum): CUD1: str = "cud1" CLG1: str = "clg1" CHD1: str = "chd1" + VVCCHD: str = "vvc1" def __str__(self): return self.value @@ -253,6 +255,10 @@ def from_string(cls, s): return cls.CLG1 elif s == "chd1" : return cls.CHD1 + elif s == "vvc1" : + return cls.VVCCHD + else: + raise Exception(f'unknown CMAF media profile: {s}') def locate_source_content(tc:'TestContent', fps_family:FPS_FAMILY): m = tc.get_mezzanine(fps_family) @@ -556,7 +562,7 @@ def from_matrix_column(col) -> 'TestContent': # 13 - Duration of stream duration = float(col[12].rstrip('s')) if bool(col[12]) else -1 - # 14 - AVC/HEVC profile and level + # 14 - codec profile and level codec = col[13] # 15 - CMAF media profile diff --git a/src/tcgen/run_encode.py b/src/tcgen/run_encode.py index d978cac..656b3e2 100644 --- a/src/tcgen/run_encode.py +++ b/src/tcgen/run_encode.py @@ -13,6 +13,7 @@ class VideoCodecOptions(Enum): AVC = "h264" HEVC = "h265" + VVC = "h266" class AudioCodecOptions(Enum): AAC = "aac" @@ -105,6 +106,16 @@ class HEVCCHD1: m_resolution_h = "2160" m_frame_rate = 60 +# VVC: ISO/IEC 23000-19 +class VVCCHD: + m_profile = "main10" + m_level = "41" + m_color_primary = "9" # GF_COLOR_PRIM_BT2020 + m_color_trc = "16" # GF_COLOR_TRC_SMPTE2084 + m_colorspace = "9" # GF_COLOR_MX_BT2020_NCL + m_resolution_w = "1920" + m_resolution_h = "1080" + m_frame_rate = 60 # DASHing class DASH: @@ -245,9 +256,10 @@ def __init__(self, representation_config): self.m_media_type = value elif name == "codec": if value != VideoCodecOptions.AVC.value and value != VideoCodecOptions.HEVC.value \ + and value != VideoCodecOptions.VVC.value \ and value != AudioCodecOptions.AAC.value and value != AudioCodecOptions.COPY.value: - print("Supported codecs are AVC denoted by \"h264\" and HEVC denoted by \"h265\" for video, and " - "AAC denoted by \"aac\" or the special value \"copy\" disables the audio transcoding.") + print("Supported codecs are AVC denoted by \"h264\", HEVC (\"h265\") and VVC (\"h266\") for video, and " + "AAC denoted by \"aac\" or the special value \"copy\" disables the audio transcoding. Found \"" + value + "\".") sys.exit(1) self.m_codec = value elif name == "vse": @@ -255,7 +267,7 @@ def __init__(self, representation_config): value != VisualSampleEntry.AVC1p3.value and \ value != VisualSampleEntry.HEV1.value and value != VisualSampleEntry.HVC1.value: print("Supported video sample entries for AVC are \"avc1\", \"avc3\", \"avc1+3\" and" - " for HEVC \"hev1\" and \"hvc1\".") + " for HEVC \"hev1\" and \"hvc1\".") # ROMAIN sys.exit(1) else: self.m_video_sample_entry = value @@ -358,6 +370,22 @@ def __init__(self, representation_config): if self.m_resolution_w is None and self.m_resolution_h is None: self.m_resolution_w = HEVCCLG1.m_resolution_w self.m_resolution_h = HEVCCLG1.m_resolution_h + elif value == "vvc1": + if self.m_profile is None: + self.m_profile = VVCCHD.m_profile + if self.m_level is None: + self.m_level = VVCCHD.m_level + if self.m_frame_rate is None: + self.m_frame_rate = VVCCHD.m_frame_rate + if self.m_color_primary is None: + self.m_color_primary = VVCCHD.m_color_primary + if self.m_color_trc is None: + self.m_color_trc = VVCCHD.m_color_trc + if self.m_colorspace is None: + self.m_colorspace = VVCCHD.m_colorspace + if self.m_resolution_w is None and self.m_resolution_h is None: + self.m_resolution_w = VVCCHD.m_resolution_w + self.m_resolution_h = VVCCHD.m_resolution_h else: print("Unknown CMAF profile: " + name) @@ -431,6 +459,7 @@ def format_command(self, i): if self.m_media_type in ("v", "video"): is_avc = self.m_codec == VideoCodecOptions.AVC.value is_hevc = self.m_codec == VideoCodecOptions.HEVC.value + is_vvc = self.m_codec == VideoCodecOptions.VVC.value # Resize command += "ffsws:osize=" + self.m_resolution_w + "x" + self.m_resolution_h @@ -447,6 +476,8 @@ def format_command(self, i): command += ":c=libx264" elif is_hevc: command += ":c=libx265" + elif is_vvc: + command += ":c=vvc" command += ":b=" + self.m_bitrate + "k" command += ":bf=" + str(self.m_num_b_frames) command += ":fintra=" + self.m_segment_duration @@ -489,20 +520,26 @@ def format_command(self, i): if bool(self.m_max_cll_fall): command += f":max-cll={self.m_max_cll_fall}" - if self.m_pic_timing == "True": - if is_avc: - command += ":nal-hrd=vbr" - elif is_hevc: - command += ":hrd=1" + #elif is_vvc: # ROMAIN TODO + # command += "::vvenc-params=\"" + # command += ":profile=" + self.m_profile + # command += ":level=" + self.m_level - # common x264 / x265 options - command += ":vbv-bufsize=" + str(int(self.m_bitrate) * 3) + \ - ":vbv-maxrate=" + str(int(int(self.m_bitrate) * 3 / 2)) + if is_avc or is_hevc: + if self.m_pic_timing == "True": + if is_avc: + command += ":nal-hrd=vbr" + elif is_hevc: + command += ":hrd=1" - if self.m_aspect_ratio_x and self.m_aspect_ratio_y: - command += ":sar=" + self.m_aspect_ratio_x + "\\:" + self.m_aspect_ratio_y - - command += "\":" # closing encoder specific parameters + # common x264 / x265 options + command += ":vbv-bufsize=" + str(int(self.m_bitrate) * 3) + \ + ":vbv-maxrate=" + str(int(int(self.m_bitrate) * 3 / 2)) + + if self.m_aspect_ratio_x and self.m_aspect_ratio_y: + command += ":sar=" + self.m_aspect_ratio_x + "\\:" + self.m_aspect_ratio_y + + command += "\":" # closing encoder specific parameters bsrw = None rmseis = [] diff --git a/src/tcgen/tcgen.py b/src/tcgen/tcgen.py index 90c9f5a..d1b65ee 100755 --- a/src/tcgen/tcgen.py +++ b/src/tcgen/tcgen.py @@ -11,6 +11,9 @@ import asyncio import xml.etree.ElementTree as ET import shutil +import logging +import sys +import traceback from tcgen.models import TestContent, FPS_FAMILY, locate_source_content, Mezzanine from tcgen.database import Database, most_recent_batch @@ -36,10 +39,10 @@ def cli(ctx): @click.option('-b', '--batch-dir', default=datetime.today().strftime('%Y-%m-%d'), help='batch directory name. default value uses the current date, eg. 2024-12-31') @click.option('--encode/--no-encode', default=True, help="encode content") @click.option('--format-mpd/--no-format-mpd', default=True, help="patch mpd content to match CTA WAVE requirements") -@click.option('-t', '--test-id', help='process only vector with id "-', default=None) +@click.option('-t', '--test-id', help='process only vector with id', default=None) @click.option('-f', '--fps-family', default='ALL', help='process only one of 14.985_29.97_59.94 - 12.5_25_50 - 15_30_60') @click.option('--drm-config', default=(Path(__file__) / '../../../DRM.xml').resolve(), help='path to DRM.xml config file') -@click.option('--dry_run/--no-dry-run', default=False, help="dry run, usefull for debugging") +@click.option('--dry_run/--no-dry-run', default=False, help="dry run, useful for debugging") def encode(ctx, mezzanine, config, vectors_dir, batch_dir, encode, format_mpd, test_id, fps_family, drm_config, dry_run): """ Encode content from MEZZANINE directory into test vectors using content options specified in CONFIG. @@ -73,6 +76,7 @@ def encode(ctx, mezzanine, config, vectors_dir, batch_dir, encode, format_mpd, t patch_mpd(output_mpd, m, tc) except BaseException as e: + traceback.print_exc() print(e) @@ -119,6 +123,7 @@ def export(ctx, mezzanine, config, vectors_dir, database, zip): m = locate_source_content(tv, fps) db.add_entry(tv, m, batch_dir.name) except BaseException as e: + traceback.print_exc() logging.warning(f'{test_entry_key} : {e}') if database is not None: From d2dcdcd2b73ab71d56c935ac95629eff6b148004 Mon Sep 17 00:00:00 2001 From: Romain Bouqueau Date: Fri, 13 Feb 2026 13:16:47 -0400 Subject: [PATCH 2/2] vvc: add GOP length fix --- src/tcgen/run_encode.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/tcgen/run_encode.py b/src/tcgen/run_encode.py index 656b3e2..a8a6847 100644 --- a/src/tcgen/run_encode.py +++ b/src/tcgen/run_encode.py @@ -267,7 +267,7 @@ def __init__(self, representation_config): value != VisualSampleEntry.AVC1p3.value and \ value != VisualSampleEntry.HEV1.value and value != VisualSampleEntry.HVC1.value: print("Supported video sample entries for AVC are \"avc1\", \"avc3\", \"avc1+3\" and" - " for HEVC \"hev1\" and \"hvc1\".") # ROMAIN + " for HEVC \"hev1\" and \"hvc1\".") # TODO: add 'vvc1' and 'vvi1' sys.exit(1) else: self.m_video_sample_entry = value @@ -520,10 +520,10 @@ def format_command(self, i): if bool(self.m_max_cll_fall): command += f":max-cll={self.m_max_cll_fall}" - #elif is_vvc: # ROMAIN TODO - # command += "::vvenc-params=\"" - # command += ":profile=" + self.m_profile - # command += ":level=" + self.m_level + elif is_vvc: + command += "::vvenc-params=\"" + command += "refreshsec=" + self.m_segment_duration + command += ":refreshtype=idr" if is_avc or is_hevc: if self.m_pic_timing == "True": @@ -536,10 +536,11 @@ def format_command(self, i): command += ":vbv-bufsize=" + str(int(self.m_bitrate) * 3) + \ ":vbv-maxrate=" + str(int(int(self.m_bitrate) * 3 / 2)) + # FIXME: VVenC parsing is broken if self.m_aspect_ratio_x and self.m_aspect_ratio_y: command += ":sar=" + self.m_aspect_ratio_x + "\\:" + self.m_aspect_ratio_y - command += "\":" # closing encoder specific parameters + command += "\":" # closing encoder specific parameters bsrw = None rmseis = []