PowerShell

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,- for a 16TB spinning disk, so the answer is High Efficiency Video Coding, or HEVC/H265. 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.

Hyper-V

Force Remove Host from SCVMM 2016/2019 & Hyper-V Console

To remove from SCVMM:

Open Powershell with administrative credentials:

PS C:\> $Credential = Get-Credential
PS C:\> $VMHost = Get-SCVMHost -ComputerName “<Hostname of Server here>”
PS C:\> Remove-SCVMHost -VMHost $VMHost -Credential $Credential

The Get-Credential cmd-let will open a prompt in which you have to supply credentials with the rights to remove the host. In the second line you specify the server. This doesn’t have to be the FQDN, the Netbios name will do.
The last line actually removes the server. This may take a few minutes, depending if the server responds or not. If the server does not respond, Powershell waits for a time-out.

To remove from a Hyper-V console:

Open Powershell with administrative credentials:

PS C:\> Get-VM
PS C:\> Remove-VM -name [“VM Name Here”] -force

Use this to remove VMs that are in the state of SavedCritical. The “Get-VM” command will show a list of VMs registered on this Hyper-V server. The Saved-Critical VMs will list there too. Make sure the -force is added or it won’t do the trick. If your machine name has spaces, use the quotes (without the square brackets of course].

This was tested on Server 2016 and Server 2019. It’s also expected to work on later server releases.