Skip to content

Instantly share code, notes, and snippets.

@brianly
Last active September 15, 2026 16:17
Show Gist options
  • Select an option

  • Save brianly/c0efeb705b951b43c6a8af010a1d76f1 to your computer and use it in GitHub Desktop.

Select an option

Save brianly/c0efeb705b951b43c6a8af010a1d76f1 to your computer and use it in GitHub Desktop.
Compares the size of files before and after encoding across the input and output directories specified. Use it to identify files that Handbrake or ffmeg failed to encode to a smaller file size. This tool is most useful when you have an arbitrary list of input files with different formats and dimensions and aren't choosing settings appropriate fo…
<#
.SYNOPSIS
Compares files in a source directory against files in a target directory,
matching them by the source file's base name (the part before the first
period), even when the target file name has an extra suffix like
"_720p" or "-1080p".
.DESCRIPTION
For every file in the source directory, the script looks for files in the
target directory whose name starts with the source file's base name
followed by a separator (underscore, hyphen, period, or space) or nothing
at all. This handles cases like:
Source: myfile.mp4
Target: myfile_720p.mp4
Target: myfile-1080p.mp4
If more than one target file matches a single source file, each match is
numbered (1st, 2nd, 3rd, ...) in the output.
For every match, the script reports the file size of both files and the
percentage difference (target vs. source), rounded to the nearest whole
number, with a +/- sign to indicate whether the target is larger or
smaller than the source.
.PARAMETER SourceDirectory
Path to the source directory (e.g. contains "myfile.mp4").
.PARAMETER TargetDirectory
Path to the target directory (e.g. contains "myfile_720p.mp4").
.PARAMETER PassThru
Instead of printing a formatted table, emit the raw result objects to
the pipeline so they can be sorted, filtered, or exported (e.g. to CSV).
.EXAMPLE
.\Compare-Directories.ps1 -SourceDirectory "C:\Originals" -TargetDirectory "C:\Encodes"
.EXAMPLE
.\Compare-Directories.ps1 "C:\Originals" "C:\Encodes"
.EXAMPLE
.\Compare-Directories.ps1 "C:\Originals" "C:\Encodes" -PassThru |
Export-Csv "comparison.csv" -NoTypeInformation
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[AllowEmptyString()]
[string]$SourceDirectory,
[Parameter(Mandatory = $true, Position = 1)]
[AllowEmptyString()]
[string]$TargetDirectory,
[Parameter()]
[switch]$PassThru
)
# --- Input validation ---
# Handled manually (rather than with [ValidateScript]) so we can give clear,
# specific error messages instead of a generic parameter-binding exception.
function Test-DirectoryArgument {
param(
[string]$PathValue,
[string]$ArgumentName
)
if ([string]::IsNullOrWhiteSpace($PathValue)) {
Write-Error "$ArgumentName is empty. Please supply a directory path."
return $null
}
if (-not (Test-Path -LiteralPath $PathValue)) {
Write-Error "$ArgumentName does not exist: '$PathValue'"
return $null
}
$item = Get-Item -LiteralPath $PathValue
if (-not $item.PSIsContainer) {
Write-Error "$ArgumentName is not a directory (it looks like a file): '$PathValue'"
return $null
}
return (Resolve-Path -LiteralPath $PathValue).Path
}
$resolvedSource = Test-DirectoryArgument -PathValue $SourceDirectory -ArgumentName "SourceDirectory"
$resolvedTarget = Test-DirectoryArgument -PathValue $TargetDirectory -ArgumentName "TargetDirectory"
if (-not $resolvedSource -or -not $resolvedTarget) {
exit 1
}
$SourceDirectory = $resolvedSource
$TargetDirectory = $resolvedTarget
if ($SourceDirectory -eq $TargetDirectory) {
Write-Error "SourceDirectory and TargetDirectory resolve to the same folder: '$SourceDirectory'"
exit 1
}
function Get-BaseName {
<#
Returns everything before the FIRST period in the file name, so that
"myfile.backup.mp4" still yields "myfile" (matches the requirement
that the part before the period in the source name is reliable).
#>
param([string]$FileName)
$dotIndex = $FileName.IndexOf('.')
if ($dotIndex -ge 0) {
return $FileName.Substring(0, $dotIndex)
}
return $FileName
}
function Format-Size {
param([long]$Bytes)
if ($Bytes -ge 1GB) { return "{0:N2} GB" -f ($Bytes / 1GB) }
if ($Bytes -ge 1MB) { return "{0:N2} MB" -f ($Bytes / 1MB) }
if ($Bytes -ge 1KB) { return "{0:N2} KB" -f ($Bytes / 1KB) }
return "$Bytes bytes"
}
function Get-MatchRank {
<#
Returns a compact ordinal string like "1st of 2" so the reader can
see both which match this is and how many total matches the source
file had, without needing a separate lookup.
#>
param(
[int]$Number,
[int]$Total
)
$suffix = switch ($Number % 100) {
{ $_ -in 11..13 } { "th"; break }
default {
switch ($Number % 10) {
1 { "st" }
2 { "nd" }
3 { "rd" }
default { "th" }
}
}
}
return "$Number$suffix of $Total"
}
# Gather files once up front. @() ensures a single-item result from
# Get-ChildItem is still treated as an array so .Count works reliably.
$sourceFiles = @(Get-ChildItem -LiteralPath $SourceDirectory -File)
$targetFiles = @(Get-ChildItem -LiteralPath $TargetDirectory -File)
if ($sourceFiles.Count -eq 0) {
Write-Warning "No files found in source directory: $SourceDirectory"
exit 0
}
if ($targetFiles.Count -eq 0) {
Write-Warning "No files found in target directory: $TargetDirectory"
exit 0
}
$results = New-Object System.Collections.Generic.List[object]
$unmatchedSource = New-Object System.Collections.Generic.List[object]
foreach ($sourceFile in $sourceFiles) {
$baseName = Get-BaseName -FileName $sourceFile.Name
$escapedBase = [regex]::Escape($baseName)
# Match target files whose name starts with the base name, provided the
# base name isn't immediately glued to another letter/digit. That still
# rejects "myfile2.mp4" (an unrelated file) while accepting virtually
# any separator or descriptive text right after the base name, e.g.:
# myfile_720p.mp4
# myfile-1080p.mp4
# myfile there are spaces_720p.mp4
# myfile, remastered (2024).mp4
$pattern = "^$escapedBase(?![a-zA-Z0-9])"
$matchingFiles = @($targetFiles | Where-Object { $_.Name -match $pattern } |
Sort-Object Name)
if ($matchingFiles.Count -eq 0) {
$unmatchedSource.Add($sourceFile.Name) | Out-Null
continue
}
$matchIndex = 0
foreach ($targetFile in $matchingFiles) {
$matchIndex++
$sourceSize = $sourceFile.Length
$targetSize = $targetFile.Length
if ($sourceSize -eq 0) {
$percentText = "N/A"
}
else {
$percentValue = [math]::Round((($targetSize - $sourceSize) / $sourceSize) * 100)
$sign = if ($percentValue -gt 0) { "+" } else { "" }
$percentText = "$sign$percentValue%"
}
$resultObject = [PSCustomObject]@{
PSTypeName = 'FileCompare.MatchResult'
SourceFile = $sourceFile.Name
Match = Get-MatchRank -Number $matchIndex -Total $matchingFiles.Count
TargetFile = $targetFile.Name
SourceSize = Format-Size $sourceSize
TargetSize = Format-Size $targetSize
Diff = $percentText
}
$results.Add($resultObject) | Out-Null
}
}
# --- Output ---
# By default, results are displayed as a formatted table (Format-Table),
# which is the standard, readable way PowerShell presents tabular data.
#
# Pass -PassThru to instead receive the raw result objects on the pipeline,
# so they can be sorted, filtered, or exported, e.g.:
# .\Compare-Directories.ps1 .\src .\dst -PassThru | Export-Csv results.csv -NoTypeInformation
# .\Compare-Directories.ps1 .\src .\dst -PassThru | Where-Object { $_.Diff -like '-*' }
# (Format-Table's output is display-only and isn't suitable for piping, so
# the two modes are mutually exclusive.)
Write-Host ""
Write-Host "Comparing '$SourceDirectory' -> '$TargetDirectory'"
Write-Host ""
if ($PassThru) {
$results
}
elseif ($results.Count -eq 0) {
Write-Warning "No matches found."
}
else {
# Piped through Out-String -Width so the table renders correctly even
# when there's no real console attached (redirected output, scheduled
# tasks, CI, etc.), where Format-Table -AutoSize alone can otherwise
# produce no visible output.
$results |
Format-Table -Property SourceFile, Match, TargetFile, SourceSize, TargetSize, Diff -AutoSize |
Out-String -Width 200 |
Write-Host
}
if ($unmatchedSource.Count -gt 0 -and -not $PassThru) {
Write-Host "Source files with no match in target directory:"
$unmatchedSource |
ForEach-Object { [PSCustomObject]@{ SourceFile = $_ } } |
Format-Table -Property SourceFile -AutoSize |
Out-String -Width 200 |
Write-Host
}
if (-not $PassThru) {
Write-Host "Summary: $($results.Count) match(es) found across $($sourceFiles.Count) source file(s) ($($unmatchedSource.Count) unmatched)."
Write-Host ""
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment