Files
Video-Processing-Scripts/Get-LastFrame.ps1
T

116 lines
4.3 KiB
PowerShell

<#
.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)
}