Compute MaxFALL and MaxCLL w/ FFmpeg batch script

Hi community,

Here’s a batch script to calculate MaxFALL and MaxCLL using FFmpeg, allowing you to inject them into a perfect HDR10 container.

I’m currently testing the full version of my script and would love to get your feedback on this FFmpeg-only approach.

Even though DaVinci Resolve handles this well, having it integrated directly into an FFmpeg workflow is much more convenient.

The calculated values should reflect the actual MaxFALL and MaxCLL, which are typically higher than legacy studio metadata.

Tested on TVAI 7.0.2 output files.

@echo off
chcp 65001 >nul
setlocal EnableExtensions EnableDelayedExpansion

set "ffmpeg=C:\FFmpeg\bin\ffmpeg.exe"
set "tvai_path=%userprofile%\Videos"
set "tvai_file=h265_hdr10.mkv"
set "mastering_nits=2000"

for %%F in ("%tvai_file%") do ( set "tvai_name=%%~nF" & set "tvai_ext=%%~xF" )

rem Range: 0=limited/tv, 1=full/pc
set "in_range=0" & set "max_cll=0" & set "max_fall=0"

call :read_hdr_max_lights "%tvai_path%" "%tvai_name%" "%tvai_ext%" %in_range% %mastering_nits%

echo mastering_nits=%mastering_nits%
echo MaxCLL : %max_cll% nits, MaxFALL: %max_fall% nits

exit /b 0

:: ===========================================================================================================================================================
:: read_hdr_max_lights  %1=path  %2=name  %3=ext  %4=range (0=limited, 1=full) %5=base mastering nits
:: Converts PQ to linear via zscale then measures YMAX/YAVG per frame with signalstats (16-bit)
:: Sets globals: max_cll, max_fall (integer nits)
:: ===========================================================================================================================================================
:read_hdr_max_lights
setlocal EnableDelayedExpansion

set "_input_path=%~1" & set "_input_name=%~2" & set "_input_ext=%~3"
set "_input=%_input_path%\%_input_name%%_input_ext%"
set "_color_range=%~4"
set "_ymax=0" & set "_yavg_max=0"
set "_mastering_nits=%~5"
set "_color_range_ffmpeg=tv"

if !_color_range! equ 1 set "_color_range_ffmpeg=pc"

rem Stats file in current dir to avoid C:\ colon breaking ffmpeg filter parser
set "_stats=%_input_name%_stats.tmp"

rem Zscale PQ→linear (per pixel), then signalstats on 16-bit gives YMAX/YAVG in 0..65535
set "_vf=zscale=rin=!_color_range_ffmpeg!:r=pc:t=linear:npl=!_mastering_nits!,format=yuv444p16le,signalstats,metadata=mode=print:file=!_stats!"

"!ffmpeg!" -hide_banner -nostdin -loglevel error -stats -i "!_input!" -map 0:v:0 -an -sn -dn -vf "!_vf!" -f null -

if exist "!_stats!" (
    rem Max YMAX across all frames = MaxCLL (brightest pixel)
    for /f "tokens=2 delims==" %%A in ('findstr /i "lavfi.signalstats.YMAX" "!_stats!"') do (
    for /f "tokens=1 delims=." %%B in ("%%A") do if %%B gtr !_ymax! set "_ymax=%%B"
)

rem Max YAVG across all frames = MaxFALL (brightest frame average)
for /f "tokens=2 delims==" %%A in ('findstr /i "lavfi.signalstats.YAVG" "!_stats!"') do (
    for /f "tokens=1 delims=." %%B in ("%%A") do if %%B gtr !_yavg_max! set      "_yavg_max=%%B"
    )
    del /q "!_stats!" >nul 2>&1
)

rem MaxCLL: brightest pixel → nits, MaxFALL: brightest frame average → nits
set /a _cll=(_ymax*!_mastering_nits!+32767)/65535 & set /a _fall=(_yavg_max*!_mastering_nits!+32767)/65535

endlocal & set "max_cll=%_cll%" & set "max_fall=%_fall%"
goto :eof
1 Like

After some local trys, it appears MaxFALL and MaxCLL are gigantic after an Hyperion upgrade.
MaxCLL ≈ 2262 nits

MaxFALL ≈ 1139 nits

from a typical dark video with some bulb lights

Trying another algo to focus if my values are wrong or if Hyperion push WP to very hight light level.

Since the vast majority of displays cannot exceed 2000 nits, it is impossible to validate or invalidate these results.

In parallel, I will also be conducting cross-tests with DaVinci Resolve.

I found an interesting hysteresis curve when looking more closely at the distribution. It seems like Hyperion boosts a handful of pixels to stratospheric levels; could this be a model bias?

With only 1 frame ksipping we dropped to 50% peak lvl.
The distribution between the 92nd and 99th percentiles is highly homogenous.

Analysing HDR peak luminance...
Source : C:\Users\Nicolas\Videos\Test_8k_prores422_prob4_iris2_hyp1_p2_20Mbps_80pct_crf16.mkv
Scan : 60s
Color range : tv
Nominal Peak Luminance (NPL) : 10000 nits

Frames analysed : 1440

┌─────────┬──────┬───────┬───────┬──────────────┐
│ Perc    │ Rank │ YMAX  │  Nits │   Skipped    │
├─────────┼──────┼───────┼───────┼──────────────┤
│ 92%     │ ~116 │ 10886 │ ~1661 │ 115 frames   │
│ 96%     │  ~58 │ 11554 │ ~1763 │  57 frames   │
│ 98%     │  ~29 │ 12213 │ ~1864 │  28 frames   │
│ 99%     │  ~15 │ 12741 │ ~1944 │  14 frames   │
│ 99.5%   │   ~8 │ 14140 │ ~2158 │   7 frames   │
│ 99.9%   │   ~2 │ 18786 │ ~2867 │   1 frames   │
│ 99.95%  │   ~1 │ 35470 │ ~5412 │   0 frames   │
│ 99.99%  │   ~1 │ 35470 │ ~5412 │   0 frames   │
└─────────┴──────┴───────┴───────┴──────────────┘

MaxCLL absolute  : 5412 nits  (YMAX16=35470)
MaxFALL absolute : 350 nits  (YAVG16=2293)

Below is the new code to achieve this tradeoff.

@echo off
setlocal EnableExtensions EnableDelayedExpansion
chcp 65001 >nul 2>&1
cls

:: ===========================================================================================================================================================
:: HDR MaxCLL / MaxFALL scanner with percentile analysis
:: Description: Converts PQ signal to linear luminance via zscale, measures per-frame YMAX/YAVG with signalstats
::              on 16-bit precision, then computes absolute MaxCLL/MaxFALL and percentile distribution of peak values.
::              Percentiles help identify whether a high MaxCLL is sustained or caused by isolated specular frames.
:: Dependencies: FFmpeg (with zscale, signalstats filters)
:: ===========================================================================================================================================================

:: --- Tool paths ---
set "ffmpeg=C:\FFmpeg\bin\ffmpeg.exe"

:: --- Input ---
set "input_path=%userprofile%\Videos"
set "input_file=Test_8k_prores422_prob4_iris2_hyp1_p2_20Mbps_80pct_crf16.mkv"
set "input=%input_path%\%input_file%"

:: --- Scan parameters ---
set "scan_sec=180"
set "npl=10000"
set "color_range_ffmpeg=tv"
set "top_count=300"

:: --- Temp files (derived from source name) ---
for %%F in ("%input_file%") do set "input_name=%%~nF"
set "stats_file=%input_name%_stats.tmp"
set "padded_tmp=%input_name%_padded.tmp"
set "log_file=%input_name%_ffmpeg.log"

:: --- Cleanup previous run ---
del /q "%stats_file%" "%padded_tmp%" "%log_file%" 2>nul

echo Analysing HDR peak luminance...
echo Source : %input%
echo Scan : %scan_sec%s           Color range : %color_range_ffmpeg%
echo Nominal Peak Luminance ^(NPL^) : %npl% nits
echo.

:: ===========================================================================================================================================================
:: Stage 1 : FFmpeg signalstats on PQ-to-linear converted frames
:: Description: zscale linearises the PQ EOTF (ST 2084) into scene-referred light with npl as the normalisation peak.
::              signalstats then measures YMAX (brightest pixel) and YAVG (frame average) on yuv444p16le (0..65535).
::              Results are dumped frame-by-frame to stats_file via metadata=mode=print.
:: ===========================================================================================================================================================

set "_vf=zscale=rin=%color_range_ffmpeg%:tin=smpte2084:pin=bt2020:min=bt2020nc:r=pc:t=linear:npl=%npl%,format=yuv444p16le,signalstats,metadata=mode=print:file=%stats_file%"

"%ffmpeg%" -threads 0 -filter_threads 0 -hide_banner -nostdin ^
    -i "%input%" -t %scan_sec% -map 0:v:0 -an -sn -dn ^
    -vf "%_vf%" ^
    -f null NUL >nul 2>"%log_file%"

if not exist "%stats_file%" (
    echo Error: stats file not generated. FFmpeg log:
    type "%log_file%"
    pause & exit /b 1
)

:: ===========================================================================================================================================================
:: Stage 2 : Parse YMAX / YAVG from signalstats output
:: Description: Iterates all frames to find the absolute YMAX (MaxCLL candidate) and the maximum YAVG (MaxFALL).
::              Also counts total analysed frames for percentile rank computation.
:: ===========================================================================================================================================================

set "total_frames=0"
set "ymax=0"
set "yavg_max=0"

for /f "tokens=2 delims==" %%A in ('findstr /i "YMAX" "%stats_file%"') do (
    set /a total_frames+=1
    for /f "tokens=1 delims=." %%B in ("%%A") do (
        if %%B gtr !ymax! set "ymax=%%B"
    )
)

for /f "tokens=2 delims==" %%A in ('findstr /i "YAVG" "%stats_file%"') do (
    for /f "tokens=1 delims=." %%B in ("%%A") do (
        if %%B gtr !yavg_max! set "yavg_max=%%B"
    )
)

echo Frames analysed : %total_frames%
echo.

:: ===========================================================================================================================================================
:: Stage 3 : Collect and sort top YMAX values for percentile analysis
:: Description: All per-frame YMAX values are zero-padded to 6 digits so that sort /r (lexicographic descending)
::              behaves as a numeric sort. We keep the top_count highest values for percentile rank lookup.
::              Depadding uses the 1NNNNNN - 1000000 trick to avoid octal interpretation of leading zeros.
:: ===========================================================================================================================================================

del /q "%padded_tmp%" 2>nul

for /f "tokens=2 delims==" %%A in ('findstr /i "YMAX" "%stats_file%"') do (
    for /f "tokens=1 delims=." %%B in ("%%A") do (
        set "_v=000000%%B"
        echo !_v:~-6!>>"%padded_tmp%"
    )
)

set "sorted_count=0"
if exist "%padded_tmp%" (
    for /f %%L in ('sort /r "%padded_tmp%"') do (
        set /a "n=1%%L - 1000000"
        set /a sorted_count+=1
        set "ymax_sorted_!sorted_count!=!n!"
        if !sorted_count! geq %top_count% goto :percentiles
    )
)

:: ===========================================================================================================================================================
:: Stage 4 : Percentile table
:: Description: For each percentile P, we compute rank = total_frames * (1 - P/100) + 1 which gives us the
::              YMAX value at that percentile boundary. Percentiles use basis points (x100) to stay in integer math.
::              92-98%% show where the bulk of bright frames sit, 99.9+%% isolate specular outliers.
::              Conversion to nits: nits = YMAX_16bit * npl / 65535 (with rounding).
::              Values are padded and aligned into a box-drawing table for readability.
:: ===========================================================================================================================================================

:percentiles
del /q "%padded_tmp%" 2>nul

:: Table header
echo ┌─────────┬──────┬───────┬───────┬──────────────┐
echo │ Perc    │ Rank │ YMAX  │  Nits │   Skipped    │
echo ├─────────┼──────┼───────┼───────┼──────────────┤

:: Pairs "label,basis_points" — basis = percentile * 100 to avoid decimals in set /a
for %%P in ("92,9200" "96,9600" "98,9800" "99,9900" "99.5,9950" "99.9,9990" "99.95,9995" "99.99,9999") do (
    for /f "tokens=1,2 delims=," %%X in (%%P) do (
        set /a "ignored=total_frames * (10000 - %%Y) / 10000"
        set /a "rank=ignored + 1"

        if !rank! LEQ %top_count% (
            call set "y_rank=%%ymax_sorted_!rank!%%"
            set /a "nits=(y_rank * %npl% + 32767) / 65535"

            :: Left-align percentile label to 7 chars
            set "_perc=%%X%%"
            set "_p=!_perc!       "
            set "_perc_f=!_p:~0,7!"

            :: Right-align rank to 4 chars (with ~ prefix)
            set "_r=    ~!rank!"
            set "_rank_f=!_r:~-4!"

            :: Right-align YMAX16 to 5 chars
            set "_y=     !y_rank!"
            set "_ymax_f=!_y:~-5!"

            :: Right-align nits to 5 chars (with ~ prefix)
            set "_n=     ~!nits!"
            set "_nits_f=!_n:~-5!"

            :: Right-align skipped count to 3 chars + " frames" = 10 chars
            set "_s=   !ignored!"
            set "_skip_f=!_s:~-3! frames  "

            echo │ !_perc_f! │ !_rank_f! │ !_ymax_f! │ !_nits_f! │ !_skip_f! │
        ) else (
            set "_perc=%%X%%"
            set "_p=!_perc!       "
            set "_perc_f=!_p:~0,7!"
            echo │ !_perc_f! │  n/a │   n/a │   n/a │     n/a      │
        )
    )
)

:: Table footer
echo └─────────┴──────┴───────┴───────┴──────────────┘

:: ===========================================================================================================================================================
:: Stage 5 : Final MaxCLL / MaxFALL computation
:: Description: MaxCLL = absolute brightest pixel across all frames (global YMAX)
::              MaxFALL = brightest frame-average across all frames (global YAVG max)
::              Conversion: nits = Y16 * npl / 65535, with +32767 for rounding to nearest integer.
:: ===========================================================================================================================================================

set /a "max_cll=(ymax * %npl% + 32767) / 65535"
set /a "max_fall=(yavg_max * %npl% + 32767) / 65535"

echo.
echo MaxCLL absolute  : %max_cll% nits  ^(YMAX16=%ymax%^)
echo MaxFALL absolute : %max_fall% nits  ^(YAVG16=%yavg_max%^)
echo.

:: --- Cleanup ---
del /q "%stats_file%" "%log_file%" 2>nul

endlocal
exit /b 0

Powershell version to bypass top_count var limitation in batch script

#Requires -Version 5.1
<#
===========================================================================================================================================================
 HDR MaxCLL / MaxFALL scanner with percentile analysis
 Description: Converts PQ signal to linear luminance via zscale, measures per-frame YMAX/YAVG with signalstats
              on 16-bit precision, then computes absolute MaxCLL/MaxFALL and percentile distribution of peak values.
              Percentiles help identify whether a high MaxCLL is sustained or caused by isolated specular frames.
 Dependencies: FFmpeg (with zscale, signalstats filters)
===========================================================================================================================================================
#>

# --- Force UTF-8 console output for box-drawing characters ---
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

# --- Tool paths ---
$ffmpeg = "C:\FFmpeg\bin\ffmpeg.exe"

# --- Input ---
$input_path = "$env:USERPROFILE\Videos"
$input_file = "Test_8k_prores422_prob4_iris2_hyp1_p2_20Mbps_80pct_crf16.mkv"
$input      = Join-Path $input_path $input_file

# --- Scan parameters ---
$scan_sec           = 60
$npl                = 10000
$color_range_ffmpeg = "tv"

# --- Percentile tiers to report ---
$percentiles = @(92, 96, 98, 99, 99.5, 99.9, 99.95, 99.99)

# --- Temp files (derived from source name) ---
$input_name = [System.IO.Path]::GetFileNameWithoutExtension($input_file)
$stats_file = "${input_name}_stats.tmp"
$log_file   = "${input_name}_ffmpeg.log"

# --- Cleanup previous run ---
Remove-Item $stats_file, $log_file -ErrorAction SilentlyContinue

if (-not (Test-Path $input)) {
    Write-Host "Error: input file not found: $input" -ForegroundColor Red
    Read-Host "Press Enter to exit"; exit 1
}

Write-Host "Analysing HDR peak luminance..."
Write-Host "Source : $input"
Write-Host "Scan   : ${scan_sec}s, npl=$npl, range=$color_range_ffmpeg"
Write-Host ""

# ===========================================================================================================================================================
# Stage 1 : FFmpeg signalstats on PQ-to-linear converted frames
# Description: zscale linearises the PQ EOTF (ST 2084) into scene-referred light with npl as the normalisation peak.
#              signalstats then measures YMAX (brightest pixel) and YAVG (frame average) on yuv444p16le (0..65535).
#              Results are dumped frame-by-frame to stats_file via metadata=mode=print.
# ===========================================================================================================================================================

$vf = "zscale=rin=${color_range_ffmpeg}:tin=smpte2084:pin=bt2020:min=bt2020nc:r=pc:t=linear:npl=${npl}," +
      "format=yuv444p16le,signalstats,metadata=mode=print:file=$stats_file"

$ffmpeg_args = @(
    "-threads", "0", "-filter_threads", "0", "-hide_banner", "-nostdin",
    "-i", $input, "-t", $scan_sec, "-map", "0:v:0", "-an", "-sn", "-dn",
    "-vf", $vf,
    "-f", "null", "NUL"
)

& $ffmpeg @ffmpeg_args *> $log_file

if (-not (Test-Path $stats_file)) {
    Write-Host "Error: stats file not generated. FFmpeg log:" -ForegroundColor Red
    Get-Content $log_file | Write-Host
    Read-Host "Press Enter to exit"; exit 1
}

# ===========================================================================================================================================================
# Stage 2 : Parse YMAX / YAVG from signalstats output
# Description: Reads all signalstats metadata lines, extracts integer part of each YMAX and YAVG value.
#              Collects all per-frame YMAX into an array for sorting, tracks global max for YAVG.
# ===========================================================================================================================================================

$ymax_values = [System.Collections.Generic.List[int]]::new()
$yavg_max    = 0

switch -Regex -File $stats_file {
    'lavfi\.signalstats\.YMAX=(\d+)' {
        $ymax_values.Add([int]$Matches[1])
    }
    'lavfi\.signalstats\.YAVG=(\d+)' {
        $val = [int]$Matches[1]
        if ($val -gt $yavg_max) { $yavg_max = $val }
    }
}

$total_frames = $ymax_values.Count
$ymax_abs     = ($ymax_values | Measure-Object -Maximum).Maximum

Write-Host "Frames analysed : $total_frames"
Write-Host ""

if ($total_frames -eq 0) {
    Write-Host "Error: no frames parsed from stats file." -ForegroundColor Red
    Read-Host "Press Enter to exit"; exit 1
}

# ===========================================================================================================================================================
# Stage 3 : Sort YMAX values descending for percentile lookup
# Description: Native numeric sort replaces the batch padding/depadding hack.
#              Sorted array is indexed directly by rank for O(1) percentile access.
# ===========================================================================================================================================================

$ymax_sorted = $ymax_values | Sort-Object -Descending

# ===========================================================================================================================================================
# Stage 4 : Percentile table
# Description: For each percentile P, rank = floor(total_frames * (1 - P/100)) + 1 gives the index into the
#              sorted YMAX array. Conversion to nits: nits = YMAX_16bit * npl / 65535.
#              Box-drawing table with aligned columns for clean console output.
# ===========================================================================================================================================================

# Box-drawing characters
$h = [char]0x2500  # horizontal line
$v = [char]0x2502  # vertical line
$tl = [char]0x250C  # top-left corner
$tr = [char]0x2510  # top-right corner
$bl = [char]0x2514  # bottom-left corner
$br = [char]0x2518  # bottom-right corner
$tj = [char]0x252C  # top junction
$bj = [char]0x2534  # bottom junction
$lj = [char]0x251C  # left junction
$rj = [char]0x2524  # right junction
$xj = [char]0x253C  # cross junction

# Column widths: Perc=7, Rank=4, YMAX=5, Nits=5, Skipped=12
$hl = "$h"
$sep_top = "$tl$($hl*9)$tj$($hl*6)$tj$($hl*7)$tj$($hl*7)$tj$($hl*14)$tr"
$sep_mid = "$lj$($hl*9)$xj$($hl*6)$xj$($hl*7)$xj$($hl*7)$xj$($hl*14)$rj"
$sep_bot = "$bl$($hl*9)$bj$($hl*6)$bj$($hl*7)$bj$($hl*7)$bj$($hl*14)$br"

Write-Host $sep_top
Write-Host "$v Perc    $v Rank $v YMAX  $v  Nits $v   Skipped    $v"
Write-Host $sep_mid

foreach ($perc in $percentiles) {
    $ignored = [Math]::Floor($total_frames * (100 - $perc) / 100)
    $rank    = $ignored + 1

    if ($rank -le $total_frames) {
        $y_rank = $ymax_sorted[$rank - 1]
        $nits   = [Math]::Round($y_rank * $npl / 65535)

        $col_perc = ("{0}%" -f $perc).PadRight(7)
        $col_rank = ("~{0}" -f $rank).PadLeft(4)
        $col_ymax = "$y_rank".PadLeft(5)
        $col_nits = ("~{0}" -f $nits).PadLeft(5)
        $col_skip = ("{0} frames" -f $ignored).PadLeft(12)

        Write-Host "$v $col_perc $v $col_rank $v $col_ymax $v $col_nits $v $col_skip $v"
    } else {
        $col_perc = ("{0}%" -f $perc).PadRight(7)
        Write-Host "$v $col_perc $v  n/a $v   n/a $v   n/a $v          n/a $v"
    }
}

Write-Host $sep_bot

# ===========================================================================================================================================================
# Stage 5 : Final MaxCLL / MaxFALL computation
# Description: MaxCLL = absolute brightest pixel across all frames (global YMAX)
#              MaxFALL = brightest frame-average across all frames (global YAVG max)
#              Conversion: nits = Y16 * npl / 65535, rounded to nearest integer.
# ===========================================================================================================================================================

$max_cll  = [Math]::Round($ymax_abs * $npl / 65535)
$max_fall = [Math]::Round($yavg_max * $npl / 65535)

Write-Host ""
Write-Host "MaxCLL absolute  : $max_cll nits  (YMAX16=$ymax_abs)"
Write-Host "MaxFALL absolute : $max_fall nits  (YAVG16=$yavg_max)"

# --- Cleanup ---
Remove-Item $stats_file, $log_file -ErrorAction SilentlyContinue

Read-Host "Press Enter to exit"
Analysing HDR peak luminance...
Source : C:\Users\Nicolas\Videos\Test_8k_prores422_prob4_iris2_hyp1_p2_20Mbps_80pct_crf16.mkv
Scan   : 7980s, npl=10000, range=tv

Frames analysed : 191330

┌─────────┬────────┬───────┬───────┬──────────────┐
│ Perc    │ Rank   │ YMAX  │  Nits │   Skipped    │
├─────────┼────────┼───────┼───────┼──────────────┤
│ 92%     │ ~15307 │  8207 │ ~1252 │ 15306 frames │
│ 96%     │ ~7654  │  8924 │ ~1362 │  7653 frames │
│ 98%     │ ~3827  │  9699 │ ~1480 │  3826 frames │
│ 99%     │ ~1914  │ 10557 │ ~1611 │  1913 frames │
│ 99,5%   │ ~957   │ 11845 │ ~1807 │   956 frames │
│ 99,9%   │ ~192   │ 20217 │ ~3085 │   191 frames │ <- Start of turning crazy
│ 99,95%  │  ~96   │ 24191 │ ~3691 │    95 frames │
│ 99,99%  │  ~20   │ 36233 │ ~5529 │    19 frames │
└─────────┴────────┴───────┴───────┴──────────────┘

MaxCLL absolute  : 10000 nits  (YMAX16=65535)
MaxFALL absolute : 1150 nits  (YAVG16=7537)