21

将 Dolby Vision Profile 7 转换为 Profile 8

 1 year ago
source link: https://blog.starryloki.com/2023/02/16/%E5%B0%81%E8%A3%85DV/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Loki's Blog

将 Dolby Vision Profile 7 转换为 Profile 8

Created2023-02-16|Updated2023-02-16|Notes
Word count:2.2k|Reading time:10min|Post View:1

关于 Dolby Vision

Dolby Vision 是一种 HDR 标准,能提供比 HDR10 更高的动态范围,高达 12bit 的色彩,动态元数据等等。Dolby Vision 内部本身又分为许多标准,比如流媒体广泛使用的 Profile 5,蓝光原盘中的 Profile 7,流媒体或 Apple 设备拍摄的 Profile 8。

Profile 7

Profile 7 是双层杜比视界,一般存在于蓝光原盘中,解析 Profile 7 需要播放机的SoC中存在相关授权,直接劝退了 infuse 等一众移动端播放器,解析不了 Profile 7 则只能按 HDR10 的标准解析,效果会有折扣。

Profile 7 中一般分为 BL,RPU,EL,

BL :基础层,一个基本的视频,一个基本的“原料”。通常是一个YUV420 10bit HDR10视频,普通的设备播放【Dolby Vision】视频无法激活【Dolby Vision】,原因就是播放器只能读取到这个BL层。这也是为什么【Dolby Vision】兼容HDR10的原因,即在普通播放机上播放杜比视界的视频时,会以兼容模式HDR10输出。

RPU:杜比视界中必须要有的一个部分,通常里面包含着各类的“指导”数据,用于明确告知电视,如何转换颜色空间,HDR的动态映射(tone mapping)等等。用比喻的说法PRU是一个视频处理条例,明确写着BL层的视频应该如何渲染及显示。

EL:简单的理解这是一个扩展层。一个标准的杜比视界,会把YUV420 10bit 的BL层视频,合成YUV422 12bit 视频。这个EL层就是扩展数据,用于扩展BL的视频有效信息。 让最终成品有更多的色阶,更多的颜色,更大的对比度范围。

所以称为双层杜比视界。

我们可以使用 mediainfo 或 BDinfo 查看是否含有 DV Profile 7:

如上图红色框内则是含有 RPU+EL 的额外视频轨道,播放时会解析这条轨道并叠加到上面的 BL层,一般称为双轨双层。

比较新的原盘 REMUX 也会含有 DV Profile 7,我们用 mediainfo 查看:

可以发现只有一条视频轨道,整合了 Profile 7 和 HDR 10 的信息,这种一般称为单轨双层。

Profile 5 和 Profile 8

Profile 5 和 Profile 8 都是单层杜比视界,就是 BL+RPU的组合,也称为Dolby Vision MEL - 迷你杜比视界层。这种杜比视界只有“指导”数据,大部分的内容是HDR的动态映射(tone mapping)的dynamic metadata。与原盘的双层杜比视界相比缺少了 12bit 的色彩数据,一般用于流媒体以及手机拍摄的杜比视界视频,虽然效果不如 Profile 7,但是却可以被 infuse 解析,效果也比一般的 HDR 10 要好。

将 Profile 7 转换为 Profile 8

为了能在便携设备观看杜比视界,转为 Profile 8 是首选,在不支持杜比视界的播放器上 Profile 5 会出现严重的偏色,而 Profile 8 则能以 HDR10 的格式播放。

dovi_tool 是一个开源的杜比视界编辑工具,可以提取,合并,转换杜比视界数据。

如果目标文件为蓝光原盘,则用 tsMuxer 解压出两条视频轨道,分别为 BL 和 EL,得到两个 hevc文件后使用 dovi_tool 合并并转换为 Profile 8:

dovi_tool -m 2 mux --bl BL.hevc --el EL.hevc --discard

这样会得到一个 BL_RPU.hevc,这个文件已经包含了原本的 BL 层以及 Profile 8 的 RPU

如果目标文件为包含杜比视界的原盘 REMUX,则用 mkvextra 解压出视频轨道,并用 dovi_tool 转换:

dovi_tool -m 2 convert --discard file.hevc

这样也会得到一个 BL_RPU.hevc , 和上面得到的一样,接着再用 mkvtoolnix 将 BL_RPU.hevc 封装进 mkv,此时的 mkv 要在标题增加 37 个下划线,因为直接封装会导致无法识别杜比视界,我们需要用一个脚本修复一下,输出 mkv 后,用脚本处理:

from bitstring import BitArray
from collections import OrderedDict
import sys

class Crc32Base(object):
	def __init__(self):
		super(Crc32Base, self).__init__()
		self.REFLECT_BIT_ORDER_TABLE = (
			0x00, 0x80, 0x40, 0xC0, 0x20, 0xA0, 0x60, 0xE0,
			0x10, 0x90, 0x50, 0xD0, 0x30, 0xB0, 0x70, 0xF0,
			0x08, 0x88, 0x48, 0xC8, 0x28, 0xA8, 0x68, 0xE8,
			0x18, 0x98, 0x58, 0xD8, 0x38, 0xB8, 0x78, 0xF8,
			0x04, 0x84, 0x44, 0xC4, 0x24, 0xA4, 0x64, 0xE4,
			0x14, 0x94, 0x54, 0xD4, 0x34, 0xB4, 0x74, 0xF4,
			0x0C, 0x8C, 0x4C, 0xCC, 0x2C, 0xAC, 0x6C, 0xEC,
			0x1C, 0x9C, 0x5C, 0xDC, 0x3C, 0xBC, 0x7C, 0xFC,
			0x02, 0x82, 0x42, 0xC2, 0x22, 0xA2, 0x62, 0xE2,
			0x12, 0x92, 0x52, 0xD2, 0x32, 0xB2, 0x72, 0xF2,
			0x0A, 0x8A, 0x4A, 0xCA, 0x2A, 0xAA, 0x6A, 0xEA,
			0x1A, 0x9A, 0x5A, 0xDA, 0x3A, 0xBA, 0x7A, 0xFA,
			0x06, 0x86, 0x46, 0xC6, 0x26, 0xA6, 0x66, 0xE6,
			0x16, 0x96, 0x56, 0xD6, 0x36, 0xB6, 0x76, 0xF6,
			0x0E, 0x8E, 0x4E, 0xCE, 0x2E, 0xAE, 0x6E, 0xEE,
			0x1E, 0x9E, 0x5E, 0xDE, 0x3E, 0xBE, 0x7E, 0xFE,
			0x01, 0x81, 0x41, 0xC1, 0x21, 0xA1, 0x61, 0xE1,
			0x11, 0x91, 0x51, 0xD1, 0x31, 0xB1, 0x71, 0xF1,
			0x09, 0x89, 0x49, 0xC9, 0x29, 0xA9, 0x69, 0xE9,
			0x19, 0x99, 0x59, 0xD9, 0x39, 0xB9, 0x79, 0xF9,
			0x05, 0x85, 0x45, 0xC5, 0x25, 0xA5, 0x65, 0xE5,
			0x15, 0x95, 0x55, 0xD5, 0x35, 0xB5, 0x75, 0xF5,
			0x0D, 0x8D, 0x4D, 0xCD, 0x2D, 0xAD, 0x6D, 0xED,
			0x1D, 0x9D, 0x5D, 0xDD, 0x3D, 0xBD, 0x7D, 0xFD,
			0x03, 0x83, 0x43, 0xC3, 0x23, 0xA3, 0x63, 0xE3,
			0x13, 0x93, 0x53, 0xD3, 0x33, 0xB3, 0x73, 0xF3,
			0x0B, 0x8B, 0x4B, 0xCB, 0x2B, 0xAB, 0x6B, 0xEB,
			0x1B, 0x9B, 0x5B, 0xDB, 0x3B, 0xBB, 0x7B, 0xFB,
			0x07, 0x87, 0x47, 0xC7, 0x27, 0xA7, 0x67, 0xE7,
			0x17, 0x97, 0x57, 0xD7, 0x37, 0xB7, 0x77, 0xF7,
			0x0F, 0x8F, 0x4F, 0xCF, 0x2F, 0xAF, 0x6F, 0xEF,
			0x1F, 0x9F, 0x5F, 0xDF, 0x3F, 0xBF, 0x7F, 0xFF,
		)
		self._width = 0
		self._poly = 0x00
		self._initvalue = 0x00
		self._reflect_input = False
		self._reflect_output = False
		self._xor_output = 0x00

	def reflectbitorder(self, width, value):
		binstr = ("0" * width + bin(value)[2:])[-width:]
		return int(binstr[::-1], 2)

	def calc(self, data):
		crc = self._initvalue

		for byte in data:
			if self._reflect_input:
				byte = self.REFLECT_BIT_ORDER_TABLE[byte]
			crc ^= (byte << 24)
			for i in range(0, 8):
				if crc & 0x80000000:
					crc = (crc << 1) ^ self._poly
				else:
					crc = (crc << 1)
			crc &= 0xFFFFFFFF
		if self._reflect_output:
			crc = self.reflectbitorder(self._width, crc)
		crc ^= self._xor_output
		return crc


class Crc32Mpeg2(Crc32Base):
	def __init__(self):
		super(Crc32Mpeg2, self).__init__()
		self._width = 32
		self._poly = 0x04C11DB7
		self._initvalue = 0xFFFFFFFF
		self._reflect_input = False
		self._reflect_output = False
		self._xor_output = 0x00000000


class Crc32(Crc32Base):
	def __init__(self):
		super(Crc32, self).__init__()
		self._width = 32
		self._poly = 0x04C11DB7
		self._initvalue = 0xFFFFFFFF
		self._reflect_input = True
		self._reflect_output = True
		self._xor_output = 0xFFFFFFFF


class MKV_Patcher(object):
	def __init__(self,mkv_data):
		super(MKV_Patcher, self).__init__()
		self.mkv_data = mkv_data
		self.elements = OrderedDict([
			("SEEK_HEAD",BitArray("0x114D9B74")),
			# ("SEEK_HEAD_CRC",BitArray("0xBF")),
			("SEEK_POSITION",BitArray("0x53AC")),
			("VOID",BitArray("0xEC")),
			("INFO",BitArray("0x1549A966")),
			# ("INFO_CRC",BitArray("0xBF")),
			("TITLE",BitArray("0x7BA9")),
			# ("MUXING_APP",BitArray("0x4D80")),
			# ("WRITING_APP",BitArray("0x5741")),
			("TRACKS",BitArray("0x1654AE6B")),
			# ("TRACKS_CRC",BitArray("0xBF")),
			("TRACK_ENTRY",BitArray("0xAE")),
			("TRACK_NUMBER",BitArray("0xD7")),
		])
		self.parsed_elements = OrderedDict()
		self.buffer_pos = 0

	def get_len_mark(self, length):
		return BitArray(uint=1 << (8 - int(length / 8)),length=8)

	def var_int_len(self, first_byte):
		for i in range(8, 64 + 8):
			if (self.get_len_mark(i) & first_byte[:8]):
				return i
		return 0

	def read_variable_size_int(self):
		pos = self.buffer_pos
		length = self.var_int_len(self.mkv_data[pos:pos + 8])
		parsedValue = BitArray(uint=(self.mkv_data[pos:pos + 8] & (~self.get_len_mark(length))).uint,length=length)
		for i in range(8,length,8):
			parsedValue = (parsedValue << 8) | BitArray(uint=self.mkv_data[pos + i: pos + i + 8].uint,length=length)
		return dict(value=parsedValue.uint * 8,octet_length=length)

	def generate_variable_size_int(self, el,uint_value, length):
		new_data = BitArray(uint=int(uint_value / 8), length=length)
		lenMark = BitArray(uint=self.get_len_mark(length).uint, length=length) << length - 8
		return new_data | lenMark

	def init_elements(self):
		counter = 0
		for element in self.elements.keys():
			stop = False
			while not stop:
				el_property = dict()
				el_property["id"] = self.elements[element]
				offset = self.mkv_data.find(el_property["id"],start=self.buffer_pos,bytealigned=True)
				if offset:
					el_property["offset"] = offset[0]
					self.buffer_pos = offset[0] + el_property["id"].len
					el_property["size"] = self.read_variable_size_int()
					if "SEEK_POSITION" in element:
						parsed_element = "{}_{}".format(element,counter)
						counter += 1
						stop = False
					else:
						parsed_element = element
						stop = True
					self.parsed_elements[parsed_element] = el_property
				else:
					stop = True

	def get_element_properties(self,el):
		el_property = self.parsed_elements[el]
		el_size = el_property["size"]["value"]
		el_octet_length = el_property["size"]["octet_length"]
		el_size_pos = el_property["offset"] + el_property["id"].len
		el_data_pos = el_size_pos + el_octet_length
		return (el_size,el_octet_length,el_size_pos,el_data_pos)

	def patch_elements(self,injected_data_length):
		crc32 = Crc32()
		elements = [x for x in self.parsed_elements.keys() if "SEEK_POSITION" in x]
		# elements += ["TRACK_ENTRY","TRACKS","TRACKS_CRC","INFO","TITLE","INFO_CRC","SEEK_HEAD_CRC"]  # FFMPEG
		elements += ["TRACK_ENTRY","TRACKS","INFO","TITLE"]  # MKVTOOLNIX
		for el in elements:
			new_size = None
			size,octet_length,size_pos,data_pos = self.get_element_properties(el)
			if el == "TITLE":
				new_size = size - injected_data_length
				del self.mkv_data[data_pos + size - injected_data_length:data_pos + size]
			elif el == "INFO":
				new_size = size - injected_data_length
			elif "SEEK_POSITION" in el:
				offset = size_pos - self.parsed_elements[el]["id"].len
				seek_id = self.mkv_data[offset - 32:offset]
				if seek_id == self.elements["TRACKS"]:
					old_data = self.mkv_data[data_pos:data_pos + size]
					new_data = BitArray(uint=int(old_data.uint - (injected_data_length / 8)), length=old_data.len)
					self.mkv_data.overwrite(new_data, data_pos)
			elif "CRC" in el:
				parent_element = el.replace("_CRC","")
				parent_size,_,_,parent_data_pos = self.get_element_properties(parent_element)
				if "TRACKS" in parent_element:
					parent_size += injected_data_length
				if "INFO" in parent_element:
					parent_size -= injected_data_length
				parent_data = self.mkv_data[parent_data_pos + 48:parent_data_pos + parent_size]
				crc = BitArray(uintle=crc32.calc(bytearray(parent_data.bytes)),length=32)
				self.mkv_data.overwrite(crc, data_pos)
			else:
				new_size = size + injected_data_length

			if new_size is not None:
				new_variable_size = self.generate_variable_size_int(el,new_size,octet_length)
				self.mkv_data.overwrite(new_variable_size, size_pos)

	def inject_dolby_vision(self,dv_data):
		self.init_elements()
		size,_,_,data_pos = self.get_element_properties("TRACK_NUMBER")
		inject_pos = data_pos + size
		self.mkv_data.insert(dv_data, inject_pos)
		self.patch_elements(dv_data.len)
		return self.mkv_data


if __name__ == '__main__':

	in_file = sys.argv[1]
	dolby_vision_data = BitArray("0x41E4A241E7846476764341ED98010010351000000000000000000000000000000000000000")

	with open(in_file, 'r+b') as f:
		mkv_data = BitArray(bytes=f.read(1024 * 20))
		mkv_patcher = MKV_Patcher(mkv_data)
		patched_data = mkv_patcher.inject_dolby_vision(dolby_vision_data)
		f.seek(0)
		f.write(patched_data.bytes)
	print("\nFile patched correctly ... at least I hope so :)")

修复完成后 mkv 的标题应该是被去除了,此时可以对该 mkv 添加音轨以及字幕。

最终我们可以得到一个 包含 DV Profile 8 的原盘 REMUX,用 mediafinfo 可以看到:

在 infuse 中顺利点亮 DV:

这里讨论的是制作包含杜比视界的原盘 REMUX,如果需要压制视频,猜测需要提取RPU.bin,压制完成后再进行合并。

在 ChatGPT 的帮助下,我构建了一个将原盘 remux 自动化处理的 powershell 脚本:

param(
    [string]$fileA
)

$extension = [System.IO.Path]::GetExtension($fileA)

if ($extension -eq ".mkv") {
    & "C:\Software\mkvtoolnix\mkvextract.exe" tracks "$fileA" 0:video.hevc
    & dovi_tool -m 2 convert --discard video.hevc
    & "C:\Software\mkvtoolnix\mkvmerge.exe" -o "a_____________________________________.mkv" --title "a_____________________________________" "BL_RPU.hevc"
    & "python-3.7.6.amd64\python.exe" MKV_patcher8.py "a_____________________________________.mkv"
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK