Last active
March 20, 2026 09:30
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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