<# .SYNOPSIS Extracts a specific frame or the last frame of a video file to a PNG image using ffmpeg. .PARAMETER Video Path to the input video file. Can be absolute or relative to current directory. .PARAMETER Image Path to the output PNG image file. Can be absolute or relative to current directory. Supports a printf-style numbering token (e.g. frame_%04d.png or C:\Output\frame_%04d.png), which is replaced with the extracted 0-based frame index, zero-padded to the width given in the token (%d = no padding, %04d = 4 digits). Without a token the path is used literally. The output directory is created when it does not exist. .PARAMETER Frame 0-based frame index to extract. Negative values count from the end: -1 extracts the last frame (default), -2 extracts second-to-last, etc. The resulting frame index is clamped to the valid range (0 to total frames - 1): a positive value past the last frame gives the last frame, and a negative value before the first frame gives frame 0. .DESCRIPTION Extracts a specific frame of a video file to a PNG image using ffmpeg. Supports negative frame indices to count from the end of the video. Supports a printf-style numbering token in -Image, like Get-FramesRange.ps1. Supports -WhatIf and -Confirm for safe execution. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory = $true)] [string]$Video, [Parameter(Mandatory = $true)] [string]$Image, [Parameter(Mandatory = $false)] [int]$Frame = -1 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $VideoPath = $Video if (-not (Test-Path -LiteralPath $VideoPath)) { Write-Error ('Video file not found: {0}' -f $VideoPath) exit 1 } $PSNativeCommandUseErrorActionPreference = $false $totalFrames = & ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 -- $VideoPath 2>&1 | Select-Object -First 1 if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') { Write-Error ('Could not determine frame count for: {0}' -f $VideoPath) exit 1 } $totalFramesInt = [int]$totalFrames if ($totalFramesInt -lt 1) { Write-Error ('Video has no frames: {0}' -f $VideoPath) exit 1 } $maxFrameIndex = $totalFramesInt - 1 # Negative values count from the end; the result is clamped to [0, maxFrameIndex] $requestedFrame = if ($Frame -lt 0) { $totalFramesInt + $Frame } else { $Frame } $frameToExtract = [Math]::Max(0, [Math]::Min($requestedFrame, $maxFrameIndex)) Write-Host ('Total frames: {0} - extracting frame index {1}' -f $totalFramesInt, $frameToExtract) # Resolve any printf-style numbering token (%d, %04d) in -Image to the extracted frame # index. The substitution is done here rather than left to ffmpeg: ffmpeg would treat the # token as an image2 sequence counter starting at 1 instead of the actual video frame index. $ImagePath = $Image $tokenPattern = [regex]'%(\d*)d' while ($ImagePath -match $tokenPattern) { $padWidth = if ($Matches[1]) { [int]$Matches[1] } else { 0 } $ImagePath = $tokenPattern.Replace($ImagePath, ([string]$frameToExtract).PadLeft($padWidth, '0'), 1) } if ($ImagePath -ne $Image) { Write-Host ('Output pattern resolved to: {0}' -f $ImagePath) } $outputDir = Split-Path -Parent $ImagePath if ($outputDir -and -not (Test-Path -LiteralPath $outputDir)) { if ($PSCmdlet.ShouldProcess($outputDir, 'Create output directory')) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null Write-Host ('Created output directory: {0}' -f $outputDir) } } if ($PSCmdlet.ShouldProcess($ImagePath, ('Extract frame {0} to PNG' -f $frameToExtract))) { $videoFilter = 'select=eq(n\,{0})' -f $frameToExtract $ffmpegArgs = @( '-y' '-i', $VideoPath '-vf', $videoFilter '-fps_mode', 'vfr' '-frames:v', '1' '-q:v', '1' '--', $ImagePath ) $ffmpegStderr = & ffmpeg @ffmpegArgs 2>&1 if ($LASTEXITCODE -ne 0) { Write-Error ('ffmpeg failed to extract frame {0}: {1}' -f $frameToExtract, (($ffmpegStderr | Out-String).Trim())) exit 1 } if (-not (Test-Path -LiteralPath $ImagePath)) { Write-Error ('ffmpeg reported success but no image was written for frame {0}: {1}' -f $frameToExtract, $ImagePath) exit 1 } Write-Host ('Frame saved to: {0}' -f $ImagePath) }