Created
January 25, 2026 14:10
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .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