Re-Encode h264/x264 to h265 without noticeable quality loss with PowerShell and FFmpeg

Over the years I’ve gathered a lot of videos from various sources. Some of these are uncompressed and take up various gigabytes of space, each! Even though storage is getting cheaper, I don’t really want to spend €200,- (each) for 16TB spinning disks, so the answer is High Efficiency Video Coding, or HEVC/H265.
Do note that the statement above will not hold up for an uncompressed backup of a UHD Blu-ray disk. There is absolutely no way that a 50GB+ copy will have the same quality as an 1GB compressed file. The statement above is about downloaded x264 content like Youtube clips, Web-Rips, etc.

I put a PowerShell script together below to do the following:

– Extract any .rar or .7z archives that may be in the input folder, prompting the user if they want to extract– Check which types of files are present
– Re-Encode to H265 through FFmpeg with hardware encoding (Nvidia GPU in my case)
– Rename the output file to replace the tag “h264”, “h.264” or “x264” by “x265”
– Log the results to a file

The script is doing some other bits and bobs, but these are the main. It’s #Free, so feel free to use, adapt, distribute as you see fit.

The pre-requisite software which must be installed on your system, is:
– FFMpeg (tested with ffmpeg version 2023-09-07 Gyan build)
– 7-Zip (Latest version)

[CmdletBinding()]
param(
    [parameter(Mandatory=$False)][ValidateSet("mp4","mkv","ts","flv","avi","webm","wmv","mpg","m4v")]
    [string]$InType,
    [parameter(Mandatory=$False)][ValidateSet("slow","medium","fast")]
    [string]$GPUPreset = "slow"
)

#Paths & Locations
$CurrentDirectory = Get-Location
$7Zip_path = "<put your path here>" #enter the location where 7zip is installed, default install location: C:\Program Files\7-Zip\7z.exe
$FFMpeg_Path = "<put your path here>" #enter the location where FFmpeg is installed, default install location: C:\Program Files\fmpeg\bin\ffmpeg.exe
$OutPut_Path = "X:\Output\" #enter the location of the folder where the converted file(s) should be written
$Logfile_Path = "X:\ConversionLogs\" #enter the location of the folder where the logs should be should be stored

####### Un-Rar and Un-Zip functions ####### 

# Check if there are RAR archives in the directory or subdirectories
$RarArchives = Get-ChildItem -Path $CurrentDirectory -Recurse -Filter "*.rar"

# Check if there are any RAR archives found
if ($RarArchives.Count -gt 0) {
    # Prompt the user if they want to unrar the archives
    $choice = Read-Host "RAR archives found. Do you want to unrar them (Y/N)?"

    if ($choice -eq "Y" -or $choice -eq "y") {
        foreach ($rarArchive in $RarArchives) {
            # Unrar the multipart archive using 7-Zip with -y (answer yes) and -aoa (overwrite all)
            $arguments = "x -y -aoa $($rarArchive.FullName)"
            Start-Process -Wait -FilePath "$7Zip_path" -ArgumentList $arguments
        }

        Write-Host "INFO: Unrar process completed."
    } else {
        Write-Host "INFO: Skipping unrar process. Continuing..."
    }
} else {
    Write-Host "No RAR archives found in the directory or subdirectories." -ForegroundColor Black #I don't really care about this, hence it's black
}
# Check if there are 7z archives in the directory or subdirectories
$SevenZArchives = Get-ChildItem -Path $CurrentDirectory -Recurse -Filter "*.7z"

# Check if there are any 7z archives found
if ($SevenZArchives.Count -gt 0) {
    # Prompt the user if they want to unzip the archives
    $choice = Read-Host "7z archives found. Do you want to unzip them (Y/N)?"

    if ($choice -eq "Y" -or $choice -eq "y") {
        foreach ($sevenZArchive in $SevenZArchives) {
            # Unzip the multipart 7z archive using 7-Zip with -y (answer yes) and -aoa (overwrite all)
            $arguments = "x -y -aoa $($sevenZArchive.FullName)"
            Start-Process -Wait -FilePath "$7Zip_path" -ArgumentList $arguments
        }

        Write-Host "INFO: Unzip process completed."
    } else {
        Write-Host "INFO: Skipping unzip process. Continuing..."
    }
} else {
    Write-Host "INFO: No 7z archives found in the directory or subdirectories." -ForegroundColor Black #I don't really care about this, hence it's black
}

####### Setup Conditions for Re-Encode ####### 

# Define the allowed file extensions without the leading period
$AllowedExtensions = @("mp4", "mkv", "ts", "flv", "avi", "webm", "wmv", "mpg", "m4v")

# Get all files in the current directory with allowed extensions
$FilesWithAllowedExtensions = Get-ChildItem -Path $CurrentDirectory -Recurse | Where-Object { $AllowedExtensions -contains $_.Extension.TrimStart('.').ToLower() }

# Check if there are no files with allowed extensions in the directory
if ($FilesWithAllowedExtensions.Count -eq 0) {
    Write-Host "INFO: No files for Re-Encoding found in the current directory." -ForegroundColor Orange

    # Prompt the user for a new path or an option to exit
    $choice = Read-Host "Do you want to specify a different path (Y/N)?"
    
    if ($choice -eq "Y" -or $choice -eq "y") {
        $NewPath = Read-Host "Enter the new path to search for files"
        $FilesWithAllowedExtensions = Get-ChildItem -Path $NewPath | Where-Object { $AllowedExtensions -contains $_.Extension.TrimStart('.').ToLower() }
        
        if ($FilesWithAllowedExtensions.Count -eq 0) {
            Write-Host "INFO: No files for Re-Encoding found in the current directory. Exiting"
            exit
        }
    } else {
        Write-Host "INFO: Exiting..."
        exit
    }
}

# Group files by their extension and count the occurrences
$ExtensionCounts = $FilesWithAllowedExtensions | Group-Object Extension | ForEach-Object { $_.Name.TrimStart('.').ToLower(), $_.Count }

# Check if there is only one allowed extension present
if ($ExtensionCounts.Count -eq 1) {
    $InType = $ExtensionCounts[0]
} else {
    # Display a choice for multiple allowed extensions
    Write-Host "Multiple allowed extensions found:"
    for ($i = 0; $i -lt $ExtensionCounts.Count; $i += 2) {
        Write-Host "$($i/2 + 1). $($ExtensionCounts[$i]) ($($ExtensionCounts[$i + 1]))"
    }
    
    $choice = Read-Host "Choose an extension (1-$($ExtensionCounts.Count / 2))"
    $chosenIndex = [int]$choice - 1
    
    if ($chosenIndex -ge 0 -and $chosenIndex -lt ($ExtensionCounts.Count / 2)) {
        $chosenExtension = $ExtensionCounts[$chosenIndex * 2]
        $InType = $chosenExtension
    } else {
        Write-Host "Invalid choice. Exiting script."
        exit
    }
}

# Display the chosen extensions
$InType = $InType.TrimStart('.')
Write-Host "Chosen extension: $InType"

####### Requirements Check ####### 

# Check if FFMpeg exists in the same folder as the script
		if (-Not (Test-Path -Path "$FFMpeg_Path")) {
		Write-Host "Error: FFmpeg not found. Please edit this Powershell script and add the path to FFMpeg." -ForegroundColor Red
	exit
}

# Check if Output path exists
if (-Not (Test-Path -Path "$OutPut_Path")) {
		Write-Host "Error: Output folder not found. Please edit this Powershell script and add the path to write the re-encoded file to." -ForegroundColor Red
	exit
}
# Check if Logfile path exists
if (-Not (Test-Path -Path "$Logfile_Path")) {
		Write-Host "Error: LogFile folder not found. Please edit this Powershell script and add the path to save the log-files to." -ForegroundColor Red
	exit
}

# Rename and replace illegal characters in the file names of the ORIGINAL files. This means the Original files will be renamed if found to have illegal characters
$files = Get-ChildItem -Path $CurrentDirectory -Recurse

# Loop through each file and check for '[' and ']' characters in the filename
foreach ($file in $files) {
    $newFileName = $file.Name -replace '\[', '(' -replace '\]', ')'
    
    # Check if the filename needs to be changed
    if ($newFileName -ne $file.Name) {
        $newFilePath = Join-Path -Path $CurrentDirectory -ChildPath $newFileName
        Rename-Item -Path $file.FullName -NewName $newFileName
        Write-Host "INFO: Renamed $($file.Name) to $($newFileName)" -ForegroundColor Green
    }
}

	If ($InType -eq "mp4") {
		$OutExt = ".mkv"
} 	else {
		$OutExt = ".mkv"
}

# Script logic using $InType, what videos for $InType can we find

$InExt = "*."+$InType
$oldVideos = Get-ChildItem -Include @($InExt) -Path $CurrentDirectory -Recurse;
$BeginTotalTime = Get-Date

If ($oldVideos.Length -gt 0) {
    # Length is file size if only one file found
    If ($null -eq $oldVideos.LongLength) {
        Write-Host $("INFO: ["+$(Get-Date -Format F)+"] - No. of Videos found: 1")
    } else {
        Write-Host $("INFO: ["+$(Get-Date -Format F)+"] - No. of videos found: $($oldVideos.Length)")
    }
    $i = 0
    # Process Videos found to $OutType
    foreach ($oldVideo in $oldVideos) {
        # New Video name and ext
        $newVideo = Join-Path $OutPut_Path ($oldVideo.BaseName + "$OutExt")

        # Rename the output file if it contains "h264," "x264," or "h.264"
        $newVideo = $newVideo -replace "h264|x264|h\.264", "x265"

        # Declare the command line arguments for ffmpeg
        $ArgumentList = '-hide_banner -loglevel +level -hwaccel_device 0 -hwaccel auto -i "{0}" -vf yadif -map 0:v:0 -c:v:0 hevc_nvenc -preset {2} -crf 22 -level 5.1 -tier high -map 0:a -c:a copy -map 0:s? -c:s copy -map_chapters 0 -map_metadata 0 "{1}"' -f $oldVideo, $newVideo, $GPUPreset

        Write-Host $("INFO: ["+$(Get-Date -Format F)+"] - Started - Processing $oldVideo") -ForegroundColor Blue
        $BeginTime = Get-Date

        # Kick off the ffmpeg process
        Start-Process -RedirectStandardError "$newVideo.txt" -FilePath $FFMpeg_Path -ArgumentList $ArgumentList  -Wait -NoNewWindow

        Write-Host $("INFO: ["+$(Get-Date -Format F)+"] - Finished - Processing $oldVideo") 
        $EndTime = Get-Date
        $TimeTaken = $EndTime - $BeginTime
        Write-Host $("INFO: ["+$(Get-Date -Format F)+"] - Re-Encoding - Processing Completed in a Time of: " + $TimeTaken) -ForegroundColor Green

        $i++
        # Length is file size if only one file found
        If ($null -eq $oldVideos.LongLength) {
            Write-Host $("INFO: ["+$(Get-Date -Format F)+"] - No. of Videos Completed: $i out of 1") -ForegroundColor Yellow
        } else {
            Write-Host $("INFO: ["+$(Get-Date -Format F)+"] - No. of Videos Completed: $i out of $($oldvideos.length)") -ForegroundColor Yellow
        }
    }
} else {
    Write-Host $("Error: ["+$(Get-Date -Format F)+"] - No Files of Type: "+$InType.ToUpper()+" Found.") -ForegroundColor Red
}

# Display the time on screen
$EndTotalTime = Get-Date
$TotalTimeTaken = $EndTotalTime - $BeginTotalTime
Write-Host $("INFO:["+$(Get-Date -Format F)+"] - Finished - All Processing Completed in a Time of: "+ $TotalTimeTaken) -ForegroundColor Blue

# Cleanup: Move log-files to another folder
$LogType = "*.txt"

# Get a list of Log files and initialise counter
$filesToMove = Get-ChildItem -Path $OutPut_Path -Filter $LogType
$filesMoved = 0

# Loop through each file and move it $Logfile_Path folder
foreach ($file in $filesToMove) {
    Move-Item -Path $file.FullName -Destination $Logfile_Path -Force
    $filesMoved++
}

Write-Host "INFO: Log files were cleaned-up. $filesMoved logs moved to $Logfile_Path" -ForegroundColor Green
Exit

Known issue: FFmpeg doesn’t automatically overwrite the output file if a file with the same name already exists. The logfile will pose the question to overwrite y/n, but it’s not displayed on screen. Since I don’t want auto-overwrite, I’m not starting the process with the -y parameter (which would fix this particular problem), but I’d like to have the choice. If this happens, just type y or n respectively in the PowerShell window and the script will continue or exit, depending on the answer.

Leave a Reply

Your email address will not be published. Required fields are marked *