Skip to content

Instantly share code, notes, and snippets.

@h8rt3rmin8r
Created January 25, 2026 14:10
Show Gist options
  • Select an option

  • Save h8rt3rmin8r/5b50b8657781faa430319d128efe884a to your computer and use it in GitHub Desktop.

Select an option

Save h8rt3rmin8r/5b50b8657781faa430319d128efe884a to your computer and use it in GitHub Desktop.
Robust Animated GIF Converter (Hybrid Engine). This script converts various video and animation formats to high-quality GIFs.
<#
.SYNOPSIS
Robust Animated GIF Converter (Hybrid Engine).
Combines ImageMagick (Quality), LibWebP (Rescue), and FFmpeg (Fallback).
.DESCRIPTION
This script converts various video and animation formats to high-quality GIFs.
It employs a "Survival" strategy to handle corrupt files that normally crash standard converters.
STRATEGY PRIORITY:
1. ImageMagick Direct: Best visual quality (Octree quantization + Floyd-Steinberg dithering).
2. LibWebP Rescue: Surgical frame extraction for corrupt WebP files (bypasses broken headers).
3. FFmpeg Fallback: Standard conversion if other methods fail.
KEY FEATURES:
- Smart Pattern Matching: Automatically expands "mp4" to "*.mp4".
- Crash Recovery: Automatically cleans up 0-byte outputs and temp folders on failure.
- Compression: Auto-compresses outputs that exceed the file size limit.
.PARAMETER Directory
The target folder to scan.
.PARAMETER Include
Specifies which file types to process.
This parameter uses "Smart Pattern Matching":
- Input: "mp4" -> Matches: "*.mp4"
- Input: ".webp" -> Matches: "*.webp"
- Input: "vid_*" -> Matches: "vid_*" (Preserves wildcards)
Default: "webp", "webm"
.PARAMETER Exclude
Specifies file types or name patterns to skip.
Supports the same Smart Pattern Matching as -Include.
Example: -Exclude "mov", "*backup*"
.PARAMETER MaxFileSizeMB
If the generated GIF exceeds this size (in MB), it will be automatically compressed
(resized/resampled) to fit. Default: 50 MB.
.PARAMETER Delete
If set, the source file will be PERMANENTLY DELETED after a successful conversion.
Use with caution.
.PARAMETER Recursive
If set, the script will scan all subdirectories of the target folder.
.EXAMPLE
.\Convert-ToGif.ps1 -Directory "C:\Images"
Process all .webp and .webm files in C:\Images.
.EXAMPLE
.\Convert-ToGif.ps1 -Directory "C:\Videos" -Include mp4, mov -Recursive
Process all .mp4 and .mov files in C:\Videos and its subfolders.
.EXAMPLE
.\Convert-ToGif.ps1 -Directory "C:\Memes" -Include "*" -Exclude "mp4"
Process EVERYTHING except .mp4 files.
#>
[CmdletBinding(SupportsShouldProcess=$false, ConfirmImpact='None', DefaultParameterSetName='Default')]
Param (
[Parameter(Position=0, ParameterSetName='Default')]
[string]$Directory,
[Parameter(Mandatory=$false, ParameterSetName='Default')]
[switch]$Recursive,
[Parameter(Mandatory=$false, ParameterSetName='Default')]
[string[]]$Include = @('webp', 'webm'),
[Parameter(Mandatory=$false, ParameterSetName='Default')]
[string[]]$Exclude,
[Parameter(Mandatory=$false, ParameterSetName='Default')]
[int]$MaxFileSizeMB = 50,
[Parameter(Mandatory=$false, ParameterSetName='Default')]
[switch]$Delete,
[Parameter(Mandatory=$true, ParameterSetName='HelpText')]
[Alias("h")]
[switch]$Help
)
$ProgressPreference = 'SilentlyContinue'
# --- 1. Setup & Tools ---
if (($Help) -or ($PSCmdlet.ParameterSetName -eq 'HelpText')) { Get-Help $MyInvocation.MyCommand.Definition -Full; return }
if ([string]::IsNullOrWhiteSpace($Directory)) { Write-Error "Missing -Directory."; return }
if (-not (Test-Path -LiteralPath $Directory)) { Write-Error "Directory not found."; return }
# Tool Detection
$binMagick = if (Get-Command "magick" -ErrorAction SilentlyContinue) { "magick" } elseif (Get-Command "convert" -ErrorAction SilentlyContinue) { "convert" } else { $null }
$binFFmpeg = if (Get-Command "ffmpeg" -ErrorAction SilentlyContinue) { "ffmpeg" } else { $null }
$binMux = if (Get-Command "webpmux" -ErrorAction SilentlyContinue) { "webpmux" } else { $null }
$binDwebp = if (Get-Command "dwebp" -ErrorAction SilentlyContinue) { "dwebp" } else { $null }
# We need at least one engine
if (-not $binMagick -and -not $binFFmpeg) { Write-Error "CRITICAL: Neither ImageMagick nor FFmpeg found."; return }
# --- 2. Helper Functions ---
function Write-Log { param([string]$Msg, [string]$Color="Gray") Write-Host " |-- $Msg" -ForegroundColor $Color }
function Write-Pass { param([string]$Msg) Write-Host " \-- [OK] $Msg" -ForegroundColor Green }
function Write-Fail { param([string]$Msg) Write-Host " \-- [FAILED] $Msg" -ForegroundColor Red }
function Normalize-Pattern {
param([string[]]$Patterns)
$Output = @()
foreach ($p in $Patterns) {
if ([string]::IsNullOrWhiteSpace($p)) { continue }
if ($p -match "\*") { $Output += $p }
elseif ($p.StartsWith(".")) { $Output += "*$p" }
else { $Output += "*.$p" }
}
return $Output
}
function Test-IsWebPAnimatedBinary {
param([string]$Path)
try {
if ((Get-Item -LiteralPath $Path).Length -lt 32) { return $false }
$stream = [System.IO.File]::OpenRead($Path)
$buffer = New-Object byte[] 32
$cnt = $stream.Read($buffer, 0, 32)
$stream.Close(); $stream.Dispose()
if ($buffer[0] -ne 82 -or $buffer[1] -ne 73 -or $buffer[2] -ne 70) { return $false }
if ($buffer[12] -ne 86 -or $buffer[13] -ne 80 -or $buffer[14] -ne 56 -or $buffer[15] -ne 88) { return $false }
if (($buffer[20] -band 0x02) -eq 0x02) { return $true }
return $false
} catch { return $false }
}
function Test-IsVideoAnimated {
param([string]$Path)
if (-not $binFFmpeg) { return $true }
$args = @("-v", "quiet", "-print_format", "json", "-show_streams", "-select_streams", "v:0", $Path)
try {
$json = & "ffprobe" $args 2>$null | Out-String | ConvertFrom-Json
if ($json.streams[0].nb_frames -gt 1) { return $true }
if ($json.streams[0].duration -gt 0) { return $true }
} catch {}
return $false
}
# --- 3. Main Loop ---
$normInclude = Normalize-Pattern -Patterns $Include
$normExclude = if ($Exclude) { Normalize-Pattern -Patterns $Exclude } else { $null }
Write-Host "Scanning '$Directory'..." -ForegroundColor Cyan
Write-Host "Include: $($normInclude -join ', ')" -ForegroundColor DarkCyan
if ($normExclude) { Write-Host "Exclude: $($normExclude -join ', ')" -ForegroundColor DarkCyan }
$searchParams = @{ LiteralPath = $Directory; Include = $normInclude; Exclude = $normExclude; File = $true }
if ($Recursive) { $searchParams.Recurse = $true }
$files = Get-ChildItem @searchParams
if ($files.Count -eq 0) { Write-Warning "No files found matching criteria."; return }
$MaxCount = $files.Count
$Counter = 1
foreach ($file in $files) {
$outName = [System.IO.Path]::ChangeExtension($file.FullName, ".gif")
$prefix = "[{0}/{1}]" -f $Counter, $MaxCount
$Counter++
Write-Host "$prefix $($file.Name)" -ForegroundColor Cyan
if (Test-Path -LiteralPath $outName -PathType Leaf) {
Write-Log "Skipping (Output exists)"
continue
}
# Tracking Variables for Cleanup
$success = $false
$tempWorkDir = $null
try {
# --- Animation Detection ---
$isAnimated = $false
$ext = $file.Extension.ToLower()
if ($ext -eq ".webp") {
$isAnimated = Test-IsWebPAnimatedBinary -Path $file.FullName
} elseif ($binFFmpeg) {
$isAnimated = Test-IsVideoAnimated -Path $file.FullName
} else {
$isAnimated = $true
}
if (-not $isAnimated) {
Write-Log "Skipped (Static)"
continue
}
# --- STRATEGY 1: ImageMagick Direct (Best Quality) ---
if ($binMagick) {
Write-Log "Attempt 1: ImageMagick Direct..."
$argsM = @($file.FullName, "-coalesce", "-layers", "Optimize", "-loop", "0", $outName)
& $binMagick $argsM 2>$null
if ((Test-Path -LiteralPath $outName) -and ((Get-Item -LiteralPath $outName).Length -gt 0)) {
$success = $true
}
}
# --- STRATEGY 2: LibWebP Rescue (Fixes 'Static/Unreadable' WebP) ---
if ((-not $success) -and ($ext -eq ".webp") -and $binMux -and $binDwebp) {
Write-Log "Attempt 2: LibWebP Rescue..." -Color Yellow
$tempWorkDir = Join-Path ([System.IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $tempWorkDir -Force | Out-Null
# 1. Get Frame Count
$info = & $binMux -info $file.FullName 2>&1 | Out-String
$frameCount = 0
if ($info -match "Number of frames: (\d+)") { $frameCount = [int]$matches[1] }
if ($frameCount -gt 1) {
# 2. Extract Frames
for ($i = 1; $i -le $frameCount; $i++) {
$wPath = Join-Path $tempWorkDir "f_$i.webp"
$pPath = Join-Path $tempWorkDir ("frame_{0:D4}.png" -f $i)
& $binMux -get frame $i $file.FullName -o $wPath 2>$null
if (Test-Path $wPath) {
& $binDwebp $wPath -o $pPath -quiet 2>$null
Remove-Item $wPath -Force
}
}
# 3. Assemble
$frames = Get-ChildItem $tempWorkDir -Filter "*.png"
if ($frames.Count -gt 0) {
if ($binMagick) {
$argsAsm = @("-delay", "6", "$tempWorkDir\frame_*.png", "-coalesce", "-layers", "Optimize", "-loop", "0", $outName)
& $binMagick $argsAsm 2>$null
} elseif ($binFFmpeg) {
$argsAsm = @("-y", "-framerate", "16", "-i", "$tempWorkDir\frame_%04d.png", "-vf", "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", $outName)
& $binFFmpeg $argsAsm 2>$null
}
}
}
if ((Test-Path -LiteralPath $outName) -and ((Get-Item -LiteralPath $outName).Length -gt 0)) {
$success = $true
}
}
# --- STRATEGY 3: FFmpeg Direct (Last Resort) ---
if ((-not $success) -and $binFFmpeg) {
Write-Log "Attempt 3: FFmpeg Direct..." -Color Yellow
$argsF = @("-y", "-hide_banner", "-loglevel", "error", "-i", $file.FullName, "-vf", "split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=diff_mode=rectangle", $outName)
& $binFFmpeg $argsF 2>$null
if ((Test-Path -LiteralPath $outName) -and ((Get-Item -LiteralPath $outName).Length -gt 0)) {
$success = $true
}
}
# --- POST-PROCESSING ---
if ($success) {
$sizeMB = (Get-Item -LiteralPath $outName).Length / 1MB
# Compression Check
if ($sizeMB -gt $MaxFileSizeMB) {
Write-Log "Compressing (Size: $("{0:N1}" -f $sizeMB) MB)..."
$tmpC = $outName + ".tmp.gif"
if ($binMagick) {
$argsC = @($outName, "-resize", "480x", "-layers", "Optimize", $tmpC)
& $binMagick $argsC 2>$null
} elseif ($binFFmpeg) {
$argsC = @("-y", "-hide_banner", "-loglevel", "error", "-i", $outName, "-vf", "fps=12,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", $tmpC)
& $binFFmpeg $argsC 2>$null
}
if ((Test-Path $tmpC) -and ((Get-Item $tmpC).Length -gt 0)) {
Move-Item $tmpC $outName -Force
$sizeMB = (Get-Item -LiteralPath $outName).Length / 1MB
}
}
Write-Pass "Size: $(" {0:N1}" -f $sizeMB) MB"
if ($Delete) {
Remove-Item -LiteralPath $file.FullName -Force -ErrorAction SilentlyContinue
Write-Log "Source deleted."
}
} else {
Write-Fail "All methods failed."
}
}
catch {
Write-Fail "Script Error: $_"
}
finally {
# --- CLEANUP SAFETY NET ---
# 1. Clean up Temp Directory (LibWebP Rescue artifacts)
if ($tempWorkDir -and (Test-Path $tempWorkDir)) {
Remove-Item $tempWorkDir -Recurse -Force -ErrorAction SilentlyContinue
}
# 2. Clean up Bad Output (0-byte files or failed runs)
# If success is FALSE, we verify the file is gone.
if (-not $success -and (Test-Path -LiteralPath $outName)) {
Remove-Item -LiteralPath $outName -Force -ErrorAction SilentlyContinue
}
}
}
Write-Host "--- Done ---" -ForegroundColor Cyan
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment