Skip to content

Instantly share code, notes, and snippets.

@mmthomas
Last active March 20, 2026 09:30
Show Gist options
  • Select an option

  • Save mmthomas/63549845111462cdbe6cba12c990b2d6 to your computer and use it in GitHub Desktop.

Select an option

Save mmthomas/63549845111462cdbe6cba12c990b2d6 to your computer and use it in GitHub Desktop.
PowerShell script to convert Dolby Vision Profile 5 to Profile 8.1 (fixes Plex color issues)
#Requires -Version 7.4
<#
.SYNOPSIS
Converts Dolby Vision Profile 5 to Profile 8.1 for correct Plex playback.
.DESCRIPTION
DV Profile 5 uses the IPTPQc2 color space, which the Plex Windows app can't
decode correctly (shows green/magenta/purple color shifts). This script
re-encodes the video from IPTPQc2 to BT.2020+PQ (HDR10) using ffmpeg with
libplacebo (Vulkan GPU acceleration) and NVIDIA NVENC, then injects a
converted Profile 8.1 RPU so DV-capable devices get enhanced metadata.
Non-DV players see correct HDR10 colors. DV players get Profile 8.1 enhancement.
.PARAMETER Path
Path to one or more MKV files. Accepts wildcards and pipeline input.
.PARAMETER OutputDir
Output directory for converted files. Defaults to the same directory as the input.
.PARAMETER Force
Overwrite existing output files without prompting.
.PARAMETER OutputFormat
Output container format: 'mkv' (default) or 'mp4'.
.PARAMETER Quality
Constant quality value for hevc_nvenc (1-51). Lower = better quality, larger file.
Default: 20.
.EXAMPLE
.\Convert-DoviProfile5To8.ps1 "C:\Movies\film.mkv"
.EXAMPLE
Get-ChildItem "D:\Movies" -Filter *.mkv | .\Convert-DoviProfile5To8.ps1 -Quality 18
.PARAMETER CompareQuality
Run a VMAF quality comparison after conversion. Compares a 30-second segment
of the re-encoded output against the original (converted losslessly via
libplacebo as reference). Scores above 95 are perceptually transparent.
.EXAMPLE
.\Convert-DoviProfile5To8.ps1 *.mkv -OutputFormat mp4
.EXAMPLE
.\Convert-DoviProfile5To8.ps1 "C:\Movies\film.mkv" -Quality 20 -CompareQuality
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('FullName')]
[string[]] $Path,
[Parameter()]
[string] $OutputDir,
[Parameter()]
[switch] $Force,
[Parameter()]
[ValidateSet('mkv', 'mp4')]
[string] $OutputFormat = 'mkv',
[Parameter()]
[ValidateRange(1, 51)]
[int] $Quality = 18,
[Parameter()]
[switch] $CompareQuality
)
begin {
# ── Tool availability checks ─────────────────────────────────────────────
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue) -or
-not (Get-Command ffprobe -ErrorAction SilentlyContinue)) {
Write-Error "ffmpeg/ffprobe not found on PATH. Install: winget install Gyan.FFmpeg"
exit 1
}
# Find dovi_tool: PATH first, then script directory fallback
$doviToolPath = (Get-Command dovi_tool -ErrorAction SilentlyContinue).Source
if (-not $doviToolPath -and (Test-Path "$PSScriptRoot\dovi_tool.exe")) {
$doviToolPath = "$PSScriptRoot\dovi_tool.exe"
}
if (-not $doviToolPath) {
Write-Error "dovi_tool not found. Add to PATH or place dovi_tool.exe next to this script.`nDownload from: https://github.com/quietvoid/dovi_tool/releases"
exit 1
}
# Find mkvmerge/mkvpropedit: PATH first, then script directory fallback
$mkvmergePath = (Get-Command mkvmerge -ErrorAction SilentlyContinue).Source
if (-not $mkvmergePath -and (Test-Path "$PSScriptRoot\mkvtoolnix\mkvmerge.exe")) {
$mkvmergePath = "$PSScriptRoot\mkvtoolnix\mkvmerge.exe"
}
$mkvpropeditPath = (Get-Command mkvpropedit -ErrorAction SilentlyContinue).Source
if (-not $mkvpropeditPath -and (Test-Path "$PSScriptRoot\mkvtoolnix\mkvpropedit.exe")) {
$mkvpropeditPath = "$PSScriptRoot\mkvtoolnix\mkvpropedit.exe"
}
if ($OutputFormat -eq 'mkv' -and -not $mkvmergePath) {
Write-Error "mkvmerge not found. Add to PATH or place mkvtoolnix/ folder next to this script.`nInstall: winget install MoritzBunkus.MKVToolNix"
exit 1
}
Write-Host "Tools: dovi_tool=$doviToolPath" -ForegroundColor DarkGray
if ($mkvmergePath) { Write-Host " mkvmerge=$mkvmergePath" -ForegroundColor DarkGray }
# Subtitle codecs compatible with MP4 container
$compatibleSubtitleCodecs = @('mov_text', 'tx3g', 'dvd_subtitle')
function Get-VideoInfo {
param([string] $FilePath)
$json = ffprobe -v quiet -print_format json -show_streams -select_streams v:0 "$FilePath" 2>&1 | Out-String
$data = $json | ConvertFrom-Json -ErrorAction SilentlyContinue
if (-not $data -or -not $data.streams -or $data.streams.Count -eq 0) { return $null }
$video = $data.streams[0]
$result = @{ DoviProfile = $null; FrameRate = $null; CompatibilityId = $null }
if ($video.r_frame_rate) { $result.FrameRate = $video.r_frame_rate }
if ($video.side_data_list) {
$dovi = $video.side_data_list | Where-Object { $_.side_data_type -eq 'DOVI configuration record' }
if ($dovi) {
$result.DoviProfile = [int] $dovi.dv_profile
$result.CompatibilityId = [int] $dovi.dv_bl_signal_compatibility_id
}
}
return $result
}
function Get-L6Metadata {
param([string] $RpuPath)
$raw = & $doviToolPath info -i $RpuPath -f 0 2>$null | Out-String
# dovi_tool may output status text before JSON — extract the JSON object
$jsonStart = $raw.IndexOf('{')
if ($jsonStart -lt 0) { return $null }
$json = $raw.Substring($jsonStart)
$data = $json | ConvertFrom-Json -ErrorAction SilentlyContinue
if (-not $data) { return $null }
$l6 = $data.vdr_dm_data.cmv29_metadata.ext_metadata_blocks |
Where-Object { $_.Level6 } |
Select-Object -First 1
if ($l6) { return $l6.Level6 }
return $null
}
}
process {
foreach ($inputPath in $Path) {
$resolvedPaths = Get-Item -Path $inputPath -ErrorAction SilentlyContinue
if (-not $resolvedPaths) {
Write-Warning "File not found: $inputPath — skipping."
continue
}
foreach ($resolved in $resolvedPaths) {
if ($resolved.Extension -ne '.mkv') {
Write-Warning "Not an MKV file: $($resolved.Name) — skipping."
continue
}
# Determine output path
$outDir = if ($OutputDir) { $OutputDir } else { $resolved.DirectoryName }
if (-not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null }
$outFile = Join-Path $outDir ($resolved.BaseName + ".$OutputFormat")
if ([System.IO.Path]::GetFullPath($outFile) -eq $resolved.FullName) {
$outFile = Join-Path $outDir ($resolved.BaseName + ".dv8.$OutputFormat")
}
if ((Test-Path $outFile) -and -not $Force) {
$overwrite = Read-Host "Output already exists: $outFile`nOverwrite? [Y/N]"
if ($overwrite -notmatch '^[Yy]') {
Write-Host "Skipping $($resolved.Name)." -ForegroundColor Yellow
continue
}
}
Write-Host "`nProcessing: $($resolved.Name)" -ForegroundColor Cyan
# ── Step 1: Detect DV Profile 5 ──────────────────────────────────
$videoInfo = Get-VideoInfo -FilePath $resolved.FullName
if ($null -eq $videoInfo -or $null -eq $videoInfo.DoviProfile) {
Write-Warning " No Dolby Vision metadata found — skipping."
continue
}
if ($videoInfo.DoviProfile -ne 5) {
Write-Warning " Dolby Vision Profile $($videoInfo.DoviProfile) (not Profile 5) — skipping."
continue
}
Write-Host " Detected DV Profile 5 — will re-encode to HDR10 + DV Profile 8.1" -ForegroundColor DarkGray
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "dovi_convert_$([System.IO.Path]::GetRandomFileName())"
New-Item -ItemType Directory -Path $tempDir | Out-Null
$rpuBin = Join-Path $tempDir 'rpu_p81.bin'
$encodedHevc = Join-Path $tempDir 'encoded.hevc'
$finalHevc = Join-Path $tempDir 'final.hevc'
try {
# ── Step 2: Extract RPU and convert to Profile 8.1 ───────────
Write-Host " Extracting and converting RPU (Profile 5 → 8.1)..." -ForegroundColor DarkGray
& ffmpeg -i $resolved.FullName -c:v copy -bsf:v hevc_mp4toannexb -f hevc pipe:1 2>$null |
& $doviToolPath -m 3 extract-rpu - -o $rpuBin 2>&1 | Out-String | Write-Verbose
if (-not (Test-Path $rpuBin) -or (Get-Item $rpuBin).Length -eq 0) {
Write-Warning " RPU extraction/conversion failed — skipping."
continue
}
$rpuSize = [math]::Round((Get-Item $rpuBin).Length / 1MB, 1)
Write-Host " RPU extracted ($rpuSize MB)." -ForegroundColor DarkGray
# ── Step 3: Get L6 metadata for HDR10 signaling ──────────────
$l6 = Get-L6Metadata -RpuPath $rpuBin
if ($l6) {
$maxCll = $l6.max_content_light_level
$maxFall = $l6.max_frame_average_light_level
$maxMdl = $l6.max_display_mastering_luminance
$minMdl = $l6.min_display_mastering_luminance
Write-Host " L6 metadata: MaxCLL=$maxCll, MaxFALL=$maxFall, MDL=$maxMdl/$minMdl nits" -ForegroundColor DarkGray
} else {
# Sensible defaults
$maxCll = 1000; $maxFall = 400; $maxMdl = 1000; $minMdl = 1
Write-Host " No L6 metadata found — using defaults: MaxCLL=$maxCll, MaxFALL=$maxFall" -ForegroundColor Yellow
}
# Build mastering display string for x265/hevc_metadata
# DCI-P3 D65 primaries (standard for DV streaming content)
# Format: G(x,y)B(x,y)R(x,y)WP(x,y)L(max,min) — values in 0.00002 units for xy, 0.0001 cd/m² for L
$masterDisplay = "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L($($maxMdl * 10000),$($minMdl * 10))"
# ── Step 4: Re-encode video (IPTPQc2 → HDR10 via libplacebo) ─
Write-Host " Re-encoding video (libplacebo + hevc_nvenc, CQ $Quality)..." -ForegroundColor DarkGray
Write-Host " This will take a while for long files." -ForegroundColor DarkGray
$encodeArgs = @(
'-init_hw_device', 'vulkan',
'-i', $resolved.FullName,
'-vf', 'hwupload,libplacebo=colorspace=bt2020nc:color_primaries=bt2020:color_trc=smpte2084:format=yuv420p10le,hwdownload,format=yuv420p10le',
'-c:v', 'hevc_nvenc',
'-preset', 'p7',
'-tune', 'hq',
'-rc', 'vbr',
'-cq', $Quality,
'-b:v', '0',
'-maxrate', '80M',
'-bufsize', '160M',
'-spatial-aq', '1',
'-pix_fmt', 'p010le',
'-color_primaries', 'bt2020',
'-color_trc', 'smpte2084',
'-colorspace', 'bt2020nc',
'-color_range', 'tv',
'-an', '-sn',
'-y',
$encodedHevc
)
$encodeStart = Get-Date
& ffmpeg @encodeArgs
if ($LASTEXITCODE -ne 0) {
Write-Warning " Encoding failed (exit code $LASTEXITCODE) — skipping."
continue
}
$encodeDuration = (Get-Date) - $encodeStart
$encSize = [math]::Round((Get-Item $encodedHevc).Length / 1GB, 2)
Write-Host " Encoding done in $([math]::Round($encodeDuration.TotalMinutes, 1)) min ($encSize GB)." -ForegroundColor DarkGray
# ── Step 5: Inject converted RPU into re-encoded HEVC ────────
Write-Host " Injecting Profile 8.1 RPU..." -ForegroundColor DarkGray
$rpuHevc = Join-Path $tempDir 'rpu_injected.hevc'
& $doviToolPath inject-rpu -i $encodedHevc --rpu-in $rpuBin -o $rpuHevc 2>&1 | Out-String | Write-Verbose
if (-not (Test-Path $rpuHevc) -or (Get-Item $rpuHevc).Length -eq 0) {
Write-Warning " RPU injection failed — skipping."
continue
}
# Use RPU-injected HEVC as final
Copy-Item $rpuHevc $finalHevc -Force
# ── Step 7: Mux with audio/subs ──────────────────────────────
Write-Host " Muxing to $OutputFormat..." -ForegroundColor DarkGray
if ($OutputFormat -eq 'mkv') {
& $mkvmergePath -o $outFile $finalHevc -D $resolved.FullName
$muxExitCode = $LASTEXITCODE
if ($muxExitCode -gt 1) {
Write-Warning " mkvmerge failed (exit code $muxExitCode) — skipping."
continue
}
} else {
# MP4: handle incompatible subtitles
$subProbeJson = ffprobe -v quiet -print_format json -show_streams -select_streams s "$($resolved.FullName)" 2>&1 | Out-String
$subProbeData = $subProbeJson | ConvertFrom-Json -ErrorAction SilentlyContinue
$subtitleStreams = @($subProbeData.streams | Where-Object { $_ })
$incompatibleSubs = @()
$compatibleSubs = @()
foreach ($stream in $subtitleStreams) {
if ($compatibleSubtitleCodecs -contains $stream.codec_name) {
$compatibleSubs += $stream
} else {
$incompatibleSubs += $stream
Write-Warning " Subtitle stream $($stream.index) ($($stream.codec_name)) not supported in MP4 — dropping."
}
}
if ($subtitleStreams.Count -eq 0 -or $incompatibleSubs.Count -eq $subtitleStreams.Count) {
$subArgs = @('-sn')
} elseif ($incompatibleSubs.Count -eq 0) {
$subArgs = @('-map', '1:s', '-c:s', 'copy')
} else {
$subArgs = @()
foreach ($s in $compatibleSubs) { $subArgs += @('-map', "1:$($s.index)") }
$subArgs += @('-c:s', 'copy')
}
$frameRate = if ($videoInfo.FrameRate) { $videoInfo.FrameRate } else { '24000/1001' }
$muxArgs = @(
'-r', $frameRate,
'-i', $finalHevc,
'-i', $resolved.FullName,
'-map', '0:v:0',
'-map', '1:a'
) + $subArgs + @(
'-map_chapters', '1',
'-c:v', 'copy',
'-c:a', 'copy',
'-strict', 'unofficial',
'-y',
$outFile
)
& ffmpeg @muxArgs
if ($LASTEXITCODE -ne 0) {
Write-Warning " ffmpeg mux failed (exit code $LASTEXITCODE) — skipping."
continue
}
}
# ── Step 8: Set HDR10 container metadata via mkvpropedit ─────
if ($OutputFormat -eq 'mkv' -and $mkvpropeditPath) {
Write-Host " Setting HDR10 container metadata..." -ForegroundColor DarkGray
& $mkvpropeditPath $outFile `
--edit track:v1 `
--set color-matrix-coefficients=9 `
--set color-primaries=9 `
--set color-transfer-characteristics=16 `
--set color-range=1 `
--set max-content-light=$maxCll `
--set max-frame-light=$maxFall `
--set max-luminance=$([double]$maxMdl) `
--set min-luminance=$([double]$minMdl / 1000) 2>&1 | Out-String | Write-Verbose
}
# ── Step 9: Verify output ────────────────────────────────────
$outInfo = Get-VideoInfo -FilePath $outFile
$outSize = [math]::Round((Get-Item $outFile).Length / 1GB, 2)
if ($outInfo.DoviProfile -eq 8) {
Write-Host " Verified DV Profile 8 (compat=$($outInfo.CompatibilityId)) — $outFile ($outSize GB)" -ForegroundColor Green
} elseif ($null -eq $outInfo.DoviProfile) {
# HDR10 only (no DV detected) — still valid, just no DV layer
Write-Host " HDR10 output (no DV RPU detected by ffprobe) — $outFile ($outSize GB)" -ForegroundColor Yellow
} else {
Write-Warning " Unexpected DV Profile $($outInfo.DoviProfile) — file kept at: $outFile"
}
# ── Step 10: VMAF quality comparison ─────────────────────────
if ($CompareQuality) {
Write-Host " Running VMAF quality comparison (30s sample at 1080p)..." -ForegroundColor DarkGray
# Pick a segment at 1/3 into the file to avoid intros/credits
$durationJson = ffprobe -v quiet -print_format json -show_format "$($resolved.FullName)" 2>&1 | Out-String
$totalDuration = ($durationJson | ConvertFrom-Json).format.duration
$sampleStart = [math]::Floor([double]$totalDuration / 3)
# Reference = original decoded through libplacebo (lossless color conversion)
# Distorted = the re-encoded output
# Both scaled to 1080p for VMAF model accuracy (vmaf_v0.6.1 trained at 1080p)
$vmafLogFile = $outFile -replace '\.mkv$|\.mp4$', '.vmaf.json'
# Escape for ffmpeg filter parser: forward slashes + escape colon
$vmafLogEsc = $vmafLogFile -replace '\\', '/' -replace ':', '\:'
$vmafFilter = "[0:v]hwupload,libplacebo=colorspace=bt2020nc:color_primaries=bt2020:color_trc=smpte2084:format=yuv420p10le,hwdownload,format=yuv420p10le,scale=1920:-2:flags=bicubic[ref];[1:v]scale=1920:-2:flags=bicubic,format=yuv420p10le[dist];[dist][ref]libvmaf=model=version=vmaf_v0.6.1:log_fmt=json:log_path='${vmafLogEsc}':n_threads=8"
$vmafOutput = & ffmpeg -hide_banner -init_hw_device vulkan -ss $sampleStart -t 30 -i $resolved.FullName -ss $sampleStart -t 30 -i $outFile `
-filter_complex $vmafFilter `
-f null - 2>&1 | Out-String
# Try parsing score from stderr, fall back to JSON log
$vmafScore = $null
$vmafMatch = [regex]::Match($vmafOutput, 'VMAF score:\s*([\d.]+)')
if ($vmafMatch.Success) {
$vmafScore = [math]::Round([double]$vmafMatch.Groups[1].Value, 2)
} elseif (Test-Path $vmafLogFile) {
$vmafData = Get-Content $vmafLogFile -Raw | ConvertFrom-Json
$vmafScore = [math]::Round($vmafData.pooled_metrics.vmaf.mean, 2)
}
if ($null -ne $vmafScore) {
$vmafColor = if ($vmafScore -ge 95) { 'Green' } elseif ($vmafScore -ge 90) { 'Yellow' } else { 'Red' }
Write-Host " VMAF score: $vmafScore / 100 (30s sample at ${sampleStart}s)" -ForegroundColor $vmafColor
} else {
Write-Warning " VMAF comparison failed — could not parse score."
Write-Verbose $vmafOutput
}
}
} finally {
# ── Cleanup temp files ───────────────────────────────────────
if (Test-Path $tempDir) {
Remove-Item -Recurse -Force $tempDir
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment