734 lines
29 KiB
PowerShell
734 lines
29 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Creates a short clip video file from a 16:9 video file using the specified plan.
|
|
.PARAMETER Video
|
|
Path to the input video file. Can be absolute or relative to current directory.
|
|
.PARAMETER Images
|
|
Path to the output PNG images file pattern from which the short clip video will be created. Can be absolute or relative to current directory.
|
|
If not provided, the output files will be in subdirectory 'Frames' and named as the video file with a frame number suffix padded with zeros to 4 digits.
|
|
.PARAMETER Plan
|
|
Path to the plan file in INI format.
|
|
The file contains sections and key=value entries that define how the short clip video is created.
|
|
.PARAMETER Keep
|
|
If specified, keeps the temporary directory containing extracted frames with padding applied.
|
|
.PARAMETER Render
|
|
If specified, skips frame extraction and cropping, and only creates the output video from existing frames in the Images path.
|
|
.PARAMETER Color
|
|
Color name or RGB value to use for padding. Overrides the Color value from the INI plan file if specified. Default: black.
|
|
.PARAMETER Overlay
|
|
Path to an optional PNG file with transparency to overlay on each cropped frame. Overrides the Overlay value from the INI plan file if specified. Can be absolute or relative to current directory.
|
|
.PARAMETER Smoothing
|
|
Mode for adjusting cropping transition length to control speed. Overrides the Smoothing value from the INI plan file if specified.
|
|
Valid values: None, Up, Center, Down.
|
|
.PARAMETER Speed
|
|
Maximum cropping transition speed in pixels per frame (minimum: 1). Overrides the Speed value from the INI plan file if specified.
|
|
.DESCRIPTION
|
|
Creates a short clip video file from a 16:9 video file using the specified plan.
|
|
See Create-ShortClip.ini for details about the INI format and supported entries for the plan file.
|
|
Supports -WhatIf and -Confirm for safe execution.
|
|
#>
|
|
[CmdletBinding(SupportsShouldProcess)]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Video,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[string]$Images = '',
|
|
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Plan = '',
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[switch]$Keep,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[switch]$Render,
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[string]$Color = '',
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[string]$Overlay = '',
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[string]$Smoothing = '',
|
|
|
|
[Parameter(Mandatory = $false)]
|
|
[int]$Speed = $null
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function Read-IniFile {
|
|
param([string]$Path)
|
|
|
|
if (-not (Test-Path $Path)) {
|
|
throw "Plan file not found: $Path"
|
|
}
|
|
|
|
$ini = @{}
|
|
$currentSection = $null
|
|
|
|
foreach ($line in Get-Content $Path) {
|
|
$line = $line.Trim()
|
|
|
|
if ($line -match '^\s*#' -or $line -eq '') {
|
|
continue
|
|
}
|
|
|
|
if ($line -match '^\[(.+)\]$') {
|
|
$currentSection = $matches[1]
|
|
$ini[$currentSection] = @{}
|
|
}
|
|
elseif ($line -match '^([^=]+)=(.*)$' -and $currentSection) {
|
|
$key = $matches[1].Trim()
|
|
$value = $matches[2].Trim()
|
|
$ini[$currentSection][$key] = $value
|
|
}
|
|
}
|
|
|
|
return $ini
|
|
}
|
|
|
|
function Get-VideoInfo {
|
|
param([string]$VideoPath)
|
|
|
|
$widthOutput = ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 $VideoPath 2>&1
|
|
$heightOutput = ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 $VideoPath 2>&1
|
|
$frameCountOutput = ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 $VideoPath 2>&1
|
|
$fpsOutput = ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 $VideoPath 2>&1
|
|
$bitrateOutput = ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of csv=p=0 $VideoPath 2>&1
|
|
$codecOutput = ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 $VideoPath 2>&1
|
|
$pixFmtOutput = ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt -of csv=p=0 $VideoPath 2>&1
|
|
$audioCodecOutput = ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 $VideoPath 2>&1
|
|
$audioBitrateOutput = ffprobe -v error -select_streams a:0 -show_entries stream=bit_rate -of csv=p=0 $VideoPath 2>&1
|
|
|
|
if ($widthOutput -match '^\d+$') {
|
|
$width = [int]$widthOutput
|
|
}
|
|
else {
|
|
throw "Failed to get video width"
|
|
}
|
|
|
|
if ($heightOutput -match '^\d+$') {
|
|
$height = [int]$heightOutput
|
|
}
|
|
else {
|
|
throw "Failed to get video height"
|
|
}
|
|
|
|
if ($frameCountOutput -match '^\d+$') {
|
|
$frameCount = [int]$frameCountOutput
|
|
}
|
|
else {
|
|
throw "Failed to get frame count"
|
|
}
|
|
|
|
if ($fpsOutput -match '^(\d+)/(\d+)$') {
|
|
$fps = [double]$matches[1] / [double]$matches[2]
|
|
}
|
|
elseif ($fpsOutput -match '^\d+(\.\d+)?$') {
|
|
$fps = [double]$fpsOutput
|
|
}
|
|
else {
|
|
throw "Failed to get frame rate"
|
|
}
|
|
|
|
$bitrate = if ($bitrateOutput -match '^\d+$') { $bitrateOutput } else { $null }
|
|
$codec = if ($codecOutput -and $codecOutput -ne 'N/A') { $codecOutput.Trim() } else { 'h264' }
|
|
$pixFmt = if ($pixFmtOutput -and $pixFmtOutput -ne 'N/A') { $pixFmtOutput.Trim() } else { 'yuv420p' }
|
|
$audioCodec = if ($audioCodecOutput -and $audioCodecOutput -ne 'N/A') { $audioCodecOutput.Trim() } else { $null }
|
|
$audioBitrate = if ($audioBitrateOutput -match '^\d+$') { [int]$audioBitrateOutput } else { $null }
|
|
|
|
return @{
|
|
Width = $width
|
|
Height = $height
|
|
FrameCount = $frameCount
|
|
FPS = $fps
|
|
Bitrate = $bitrate
|
|
Codec = $codec
|
|
PixelFormat = $pixFmt
|
|
AudioCodec = $audioCodec
|
|
AudioBitrate = $audioBitrate
|
|
}
|
|
}
|
|
|
|
function ConvertTo-CroppingOffset {
|
|
param(
|
|
[string]$Value,
|
|
[int]$InputWidth,
|
|
[int]$OutputWidth,
|
|
[string]$Position = 'Center'
|
|
)
|
|
|
|
switch ($Value) {
|
|
'Left' { return 0 }
|
|
'Center' {
|
|
$offset = [int](($InputWidth - $OutputWidth) / 2)
|
|
return [Math]::Max(0, $offset)
|
|
}
|
|
'Right' {
|
|
return [Math]::Max(0, $InputWidth - $OutputWidth)
|
|
}
|
|
default {
|
|
if ($Value -match '^\d+$') {
|
|
$numValue = [int]$Value
|
|
|
|
switch ($Position) {
|
|
'Left' {
|
|
$adjustedValue = $numValue
|
|
}
|
|
'Center' {
|
|
$adjustedValue = $numValue - [int]($OutputWidth / 2)
|
|
}
|
|
'Right' {
|
|
$adjustedValue = $numValue - $OutputWidth
|
|
}
|
|
}
|
|
|
|
$maxRight = [Math]::Max(0, $InputWidth - $OutputWidth)
|
|
return [Math]::Max(0, [Math]::Min($adjustedValue, $maxRight))
|
|
}
|
|
throw "Invalid cropping value: $Value"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-CroppingOffsets {
|
|
param(
|
|
[hashtable]$CroppingSection,
|
|
[int]$TotalFrames,
|
|
[int]$InputWidth,
|
|
[int]$OutputWidth,
|
|
[string]$Position = 'Center',
|
|
[string]$Smoothing = 'None',
|
|
[int]$Speed = 10
|
|
)
|
|
|
|
$offsets = @{}
|
|
|
|
if ($null -eq $CroppingSection -or $CroppingSection.Count -eq 0) {
|
|
$defaultOffset = ConvertTo-CroppingOffset -Value 'Center' -InputWidth $InputWidth -OutputWidth $OutputWidth -Position $Position
|
|
for ($i = 0; $i -lt $TotalFrames; $i++) {
|
|
$offsets[$i] = $defaultOffset
|
|
}
|
|
return $offsets
|
|
}
|
|
|
|
$keyFrames = @()
|
|
foreach ($key in $CroppingSection.Keys) {
|
|
if ($key -match '^\d+$') {
|
|
$frameNum = [int]$key
|
|
$value = $CroppingSection[$key]
|
|
$offset = ConvertTo-CroppingOffset -Value $value -InputWidth $InputWidth -OutputWidth $OutputWidth -Position $Position
|
|
$keyFrames += [PSCustomObject]@{ Frame = $frameNum; Offset = $offset }
|
|
}
|
|
}
|
|
|
|
$keyFrames = @($keyFrames | Sort-Object -Property Frame)
|
|
|
|
for ($i = 1; $i -lt $keyFrames.Count; $i++) {
|
|
if ($keyFrames[$i].Frame -le $keyFrames[$i - 1].Frame) {
|
|
throw "Frame numbers in Cropping section must be in ascending order"
|
|
}
|
|
}
|
|
|
|
if ($keyFrames.Count -eq 0) {
|
|
$defaultOffset = ConvertTo-CroppingOffset -Value 'Center' -InputWidth $InputWidth -OutputWidth $OutputWidth -Position $Position
|
|
for ($i = 0; $i -lt $TotalFrames; $i++) {
|
|
$offsets[$i] = $defaultOffset
|
|
}
|
|
return $offsets
|
|
}
|
|
|
|
for ($i = 0; $i -lt $keyFrames.Count; $i++) {
|
|
$currentFrame = $keyFrames[$i].Frame
|
|
$currentOffset = $keyFrames[$i].Offset
|
|
|
|
$offsets[$currentFrame] = $currentOffset
|
|
|
|
if ($i -lt $keyFrames.Count - 1) {
|
|
$nextFrame = $keyFrames[$i + 1].Frame
|
|
$nextOffset = $keyFrames[$i + 1].Offset
|
|
|
|
$frameDiff = $nextFrame - $currentFrame
|
|
if ($frameDiff -gt 1) {
|
|
$centerFrame = [Math]::Round(($currentFrame + $nextFrame) / 2)
|
|
$offsetDiff = $nextOffset - $currentOffset
|
|
$step = [double]$offsetDiff / [double]$frameDiff
|
|
|
|
if ($Smoothing -ne 'None' -and $offsetDiff -ne 0) {
|
|
$computedSpeed = [Math]::Abs($step)
|
|
$startFrame = $currentFrame
|
|
$endFrame = $nextFrame
|
|
|
|
if ($computedSpeed -gt $Speed) {
|
|
Write-Host "Smoothing required between frames $startFrame and $endFrame at speed $computedSpeed (Max: $Speed)"
|
|
|
|
while ($computedSpeed -gt $Speed) {
|
|
$mode = $Smoothing
|
|
|
|
if ($mode -eq 'Center') {
|
|
if (($endFrame - $centerFrame) -le ($centerFrame - $startFrame)) {
|
|
$mode = 'Up'
|
|
}
|
|
else {
|
|
$mode = 'Down'
|
|
}
|
|
}
|
|
|
|
if ($mode -eq 'Up') {
|
|
if (($endFrame + 1) -lt $TotalFrames) {
|
|
$endFrame++
|
|
}
|
|
elseif (($startFrame - 1) -ge 0) {
|
|
$startFrame--
|
|
}
|
|
else {
|
|
break
|
|
}
|
|
}
|
|
elseif ($mode -eq 'Down') {
|
|
if (($startFrame - 1) -ge 0) {
|
|
$startFrame--
|
|
}
|
|
elseif (($endFrame + 1) -lt $TotalFrames) {
|
|
$endFrame++
|
|
}
|
|
else {
|
|
break
|
|
}
|
|
}
|
|
else {
|
|
throw "Internal error for mode value: $mode. Must be 'Up', or 'Down'"
|
|
}
|
|
|
|
$frameDiff = $endFrame - $startFrame
|
|
$step = [double]$offsetDiff / [double]$frameDiff
|
|
$computedSpeed = [Math]::Abs($step)
|
|
|
|
if ($computedSpeed -gt $Speed) {
|
|
$currentFrame = $startFrame
|
|
$nextFrame = $endFrame
|
|
}
|
|
}
|
|
|
|
Write-Host "Smoothing completed with frames now between $currentFrame and $nextFrame"
|
|
}
|
|
}
|
|
|
|
for ($f = $currentFrame + 1; $f -le $nextFrame; $f++) {
|
|
$interpolatedOffset = $currentOffset + ($step * ($f - $currentFrame))
|
|
$offsets[$f] = [Math]::Round($interpolatedOffset)
|
|
}
|
|
|
|
if (($i + 1) -lt $keyFrames.Count) {
|
|
$keyFrames[$i + 1].Frame = $nextFrame + 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$lastKeyFrame = $keyFrames[-1]
|
|
for ($f = $lastKeyFrame.Frame + 1; $f -lt $TotalFrames; $f++) {
|
|
$offsets[$f] = $lastKeyFrame.Offset
|
|
}
|
|
|
|
for ($f = 0; $f -lt $keyFrames[0].Frame; $f++) {
|
|
if (-not $offsets.ContainsKey($f)) {
|
|
$offsets[$f] = $keyFrames[0].Offset
|
|
}
|
|
}
|
|
|
|
return $offsets
|
|
}
|
|
|
|
function Test-IniContent {
|
|
param([hashtable]$Ini)
|
|
|
|
if (-not $Ini.ContainsKey('Clip')) {
|
|
throw "INI file must contain a [Clip] section"
|
|
}
|
|
|
|
$clip = $Ini['Clip']
|
|
|
|
if ($clip.ContainsKey('Format')) {
|
|
if ($clip['Format'] -notin @('Portrait', 'Square')) {
|
|
throw "Invalid Format value: $($clip['Format']). Must be 'Portrait' or 'Square'"
|
|
}
|
|
}
|
|
|
|
if ($clip.ContainsKey('Height')) {
|
|
if ($clip['Height'] -notmatch '^\d+$') {
|
|
throw "Invalid Height value: $($clip['Height']). Must be a positive integer"
|
|
}
|
|
}
|
|
|
|
if ($clip.ContainsKey('Padding')) {
|
|
if ($clip['Padding'] -notin @('Top', 'Bottom', 'Center', 'None')) {
|
|
throw "Invalid Padding value: $($clip['Padding']). Must be 'Top', 'Bottom', 'Center', or 'None'"
|
|
}
|
|
}
|
|
|
|
if ($clip.ContainsKey('Audio')) {
|
|
if ($clip['Audio'] -notin @('Yes', 'No')) {
|
|
throw "Invalid Audio value: $($clip['Audio']). Must be 'Yes' or 'No'"
|
|
}
|
|
}
|
|
|
|
if ($clip.ContainsKey('Position')) {
|
|
if ($clip['Position'] -notin @('Left', 'Center', 'Right')) {
|
|
throw "Invalid Position value: $($clip['Position']). Must be 'Left', 'Center', or 'Right'"
|
|
}
|
|
}
|
|
|
|
if ($clip.ContainsKey('Smoothing')) {
|
|
if ($clip['Smoothing'] -notin @('None', 'Center', 'Up', 'Down')) {
|
|
throw "Invalid Smoothing value: $($clip['Smoothing']). Must be 'None', 'Center', 'Up', or 'Down'"
|
|
}
|
|
}
|
|
|
|
if ($clip.ContainsKey('Speed')) {
|
|
if ($clip['Speed'] -notmatch '^\d+$') {
|
|
throw "Invalid Speed value: $($clip['Speed']). Must be a positive integer"
|
|
}
|
|
elseif ([int]$clip['Speed'] -lt 1) {
|
|
throw "Invalid Speed value: $($clip['Speed']). Must be greater than or equal to 1"
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (-not (Test-Path $Video)) {
|
|
throw "Video file not found: $Video"
|
|
}
|
|
|
|
# Overlay validation will be done after reading INI file
|
|
|
|
Write-Host "Reading plan file: $Plan"
|
|
$ini = Read-IniFile -Path $Plan
|
|
|
|
Write-Host "Validating plan file content"
|
|
Test-IniContent -Ini $ini
|
|
|
|
$clip = $ini['Clip']
|
|
$format = if ($clip.ContainsKey('Format')) { $clip['Format'] } else { 'Portrait' }
|
|
$outputHeight = if ($clip.ContainsKey('Height')) { [int]$clip['Height'] } else { 1920 }
|
|
$padding = if ($clip.ContainsKey('Padding')) { $clip['Padding'] } else { 'Center' }
|
|
$audio = if ($clip.ContainsKey('Audio')) { $clip['Audio'] } else { 'Yes' }
|
|
$position = if ($clip.ContainsKey('Position')) { $clip['Position'] } else { 'Center' }
|
|
|
|
if ($Smoothing -ne '') {
|
|
$smoothing = $Smoothing
|
|
}
|
|
elseif ($clip.ContainsKey('Smoothing')) {
|
|
$smoothing = $clip['Smoothing']
|
|
}
|
|
else {
|
|
$smoothing = 'None'
|
|
}
|
|
|
|
if ($Speed -gt 0) {
|
|
$speed = $Speed
|
|
}
|
|
elseif ($clip.ContainsKey('Speed')) {
|
|
$speed = [int]$clip['Speed']
|
|
}
|
|
else {
|
|
$speed = 10
|
|
}
|
|
|
|
# Color: Parameter overrides INI, defaults to 'black'
|
|
if ($Color -ne '') {
|
|
$color = $Color
|
|
}
|
|
elseif ($clip.ContainsKey('Color')) {
|
|
$color = $clip['Color']
|
|
}
|
|
else {
|
|
$color = 'black'
|
|
}
|
|
|
|
# Overlay: Parameter overrides INI, defaults to empty
|
|
if ($Overlay -ne '') {
|
|
$overlay = $Overlay
|
|
}
|
|
elseif ($clip.ContainsKey('Overlay')) {
|
|
$overlay = $clip['Overlay']
|
|
}
|
|
else {
|
|
$overlay = ''
|
|
}
|
|
|
|
# Convert overlay to absolute path if specified
|
|
if ($overlay -ne '') {
|
|
if (-not [System.IO.Path]::IsPathRooted($overlay)) {
|
|
$overlay = Join-Path (Get-Location).Path $overlay
|
|
}
|
|
}
|
|
|
|
# Validate overlay file exists if specified
|
|
if ($overlay -ne '' -and -not (Test-Path $overlay)) {
|
|
throw "Overlay file not found: $overlay"
|
|
}
|
|
|
|
$outputWidth = if ($format -eq 'Portrait') { [int]($outputHeight * 9 / 16) } else { $outputHeight }
|
|
|
|
Write-Host "Getting video information"
|
|
$videoInfo = Get-VideoInfo -VideoPath $Video
|
|
|
|
Write-Host "Input video: $($videoInfo.Width)x$($videoInfo.Height), $($videoInfo.FrameCount) frames, $($videoInfo.FPS) fps"
|
|
Write-Host "Output clip: ${outputWidth}x${outputHeight}, Format: $format"
|
|
|
|
# Determine if we need to work at input resolution then scale down
|
|
$needsDownscale = $videoInfo.Height -gt $outputHeight
|
|
if ($needsDownscale) {
|
|
# Calculate intermediate dimensions at input resolution scale
|
|
$intermediateHeight = $videoInfo.Height
|
|
$intermediateWidth = if ($format -eq 'Portrait') { [int]($intermediateHeight * 9 / 16) } else { $intermediateHeight }
|
|
Write-Host "Working at input resolution: ${intermediateWidth}x${intermediateHeight}, then scaling to ${outputWidth}x${outputHeight}"
|
|
} else {
|
|
$intermediateHeight = $outputHeight
|
|
$intermediateWidth = $outputWidth
|
|
}
|
|
|
|
if ($Images -eq '') {
|
|
$videoBaseName = [System.IO.Path]::GetFileNameWithoutExtension($Video)
|
|
$videoDir = [System.IO.Path]::GetDirectoryName($Video)
|
|
if ($videoDir -eq '') { $videoDir = '.' }
|
|
$framesDir = Join-Path $videoDir 'Frames'
|
|
if (Test-Path $framesDir) {
|
|
if ($PSCmdlet.ShouldProcess($framesDir, 'Clear existing frames directory')) {
|
|
Get-ChildItem -Path $framesDir -File | Remove-Item -Force
|
|
}
|
|
}
|
|
else {
|
|
if ($PSCmdlet.ShouldProcess($framesDir, 'Create frames directory')) {
|
|
New-Item -ItemType Directory -Path $framesDir | Out-Null
|
|
}
|
|
}
|
|
$Images = Join-Path $framesDir "${videoBaseName}-%04d.png"
|
|
}
|
|
|
|
$imagesDir = [System.IO.Path]::GetDirectoryName($Images)
|
|
if ($imagesDir -and -not (Test-Path $imagesDir)) {
|
|
if ($PSCmdlet.ShouldProcess($imagesDir, 'Create output images directory')) {
|
|
New-Item -ItemType Directory -Path $imagesDir | Out-Null
|
|
}
|
|
}
|
|
|
|
$paddingColor = $color
|
|
if ($color -match '^#([0-9A-Fa-f]{6})$') {
|
|
$paddingColor = "0x$($matches[1])"
|
|
}
|
|
|
|
if ($Render) {
|
|
Write-Host "Render mode: Skipping frame extraction and cropping"
|
|
|
|
$imagesPattern = $Images -replace '%\d+d', '*'
|
|
$existingFrames = Get-ChildItem -Path $imagesPattern -ErrorAction SilentlyContinue
|
|
|
|
if (-not $existingFrames -or $existingFrames.Count -eq 0) {
|
|
throw "No existing frames found at: $Images. Cannot render without frames."
|
|
}
|
|
|
|
Write-Host "Found $($existingFrames.Count) existing frames to render"
|
|
}
|
|
else {
|
|
Write-Host "Calculating cropping offsets"
|
|
$croppingSection = if ($ini.ContainsKey('Cropping')) { $ini['Cropping'] } else { $null }
|
|
# Use intermediate dimensions for cropping calculation
|
|
$croppingOffsets = Get-CroppingOffsets -CroppingSection $croppingSection -TotalFrames $videoInfo.FrameCount -InputWidth $videoInfo.Width -OutputWidth $intermediateWidth -Position $position -Smoothing $smoothing -Speed $speed
|
|
|
|
Write-Host "`nCropping Offsets:"
|
|
Write-Host ("Frame".PadRight(10) + "Left".PadRight(10) + "Center".PadRight(10) + "Right")
|
|
Write-Host ("-" * 40)
|
|
$previousOffset = $null
|
|
$halfWidth = [int]($intermediateWidth / 2)
|
|
foreach ($frame in ($croppingOffsets.Keys | Sort-Object)) {
|
|
$currentOffset = $croppingOffsets[$frame]
|
|
if ($currentOffset -ne $previousOffset) {
|
|
$centerOffset = $currentOffset + $halfWidth
|
|
$rightOffset = $currentOffset + $intermediateWidth
|
|
Write-Host ("{0,-10}{1,-10}{2,-10}{3}" -f $frame, $currentOffset, $centerOffset, $rightOffset)
|
|
$previousOffset = $currentOffset
|
|
}
|
|
}
|
|
Write-Host ""
|
|
|
|
Write-Host "Extracting and processing frames"
|
|
|
|
$paddingFilter = ''
|
|
if ($padding -eq 'None') {
|
|
# Scale to fill intermediate height only; horizontal cropping will be applied per-frame
|
|
$paddingFilter = "scale=-2:${intermediateHeight}"
|
|
}
|
|
elseif ($videoInfo.Height -lt $intermediateHeight) {
|
|
$padHeight = $intermediateHeight - $videoInfo.Height
|
|
switch ($padding) {
|
|
'Top' { $paddingFilter = "pad=$($videoInfo.Width):${intermediateHeight}:0:${padHeight}:${paddingColor}" }
|
|
'Bottom' { $paddingFilter = "pad=$($videoInfo.Width):${intermediateHeight}:0:0:${paddingColor}" }
|
|
'Center' {
|
|
$padTop = [int]($padHeight / 2)
|
|
$paddingFilter = "pad=$($videoInfo.Width):${intermediateHeight}:0:${padTop}:${paddingColor}"
|
|
}
|
|
}
|
|
}
|
|
|
|
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "ShortClip_$(Get-Random)"
|
|
New-Item -ItemType Directory -Path $tempDir | Out-Null
|
|
|
|
try {
|
|
$extractPattern = Join-Path $tempDir "frame-%04d.png"
|
|
|
|
if ($PSCmdlet.ShouldProcess($tempDir, 'Extract frames with padding to temporary directory')) {
|
|
Write-Host "Extracting frames with padding to temporary directory"
|
|
if ($paddingFilter -ne '') {
|
|
ffmpeg -i $Video -vf $paddingFilter $extractPattern -y -loglevel error
|
|
}
|
|
else {
|
|
ffmpeg -i $Video $extractPattern -y -loglevel error
|
|
}
|
|
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "ffmpeg failed to extract frames with exit code $LASTEXITCODE"
|
|
}
|
|
|
|
$extractedFrames = Get-ChildItem -Path $tempDir -Filter "frame-*.png" | Sort-Object Name
|
|
|
|
if ($extractedFrames.Count -eq 0) {
|
|
throw "No frames were extracted"
|
|
}
|
|
|
|
Write-Host "Applying cropping to $($extractedFrames.Count) frames"
|
|
|
|
$imagesPattern = $Images -replace '%\d+d', '*'
|
|
$existingOutputFrames = Get-ChildItem -Path $imagesPattern -ErrorAction SilentlyContinue
|
|
if (($existingOutputFrames) -and ($PSCmdlet.ShouldProcess($imagesPattern, 'Remove existing output frames'))) {
|
|
Write-Host "Removing existing output frames"
|
|
$existingOutputFrames | Remove-Item -Force
|
|
}
|
|
|
|
for ($i = 0; $i -lt $extractedFrames.Count; $i++) {
|
|
$frameFile = $extractedFrames[$i]
|
|
$frameNumber = $i
|
|
$cropOffset = $croppingOffsets[$frameNumber]
|
|
|
|
$outputFramePath = $Images -replace '%04d', $frameNumber.ToString('0000')
|
|
|
|
if ($PSCmdlet.ShouldProcess($outputFramePath, 'Crop and save frame')) {
|
|
# Crop at intermediate resolution, then scale down if needed
|
|
if ($needsDownscale) {
|
|
if ($overlay -ne '') {
|
|
# Crop, scale, then overlay
|
|
$cropFilter = "[0:v]crop=${intermediateWidth}:${intermediateHeight}:${cropOffset}:0,scale=${outputWidth}:${outputHeight}[scaled];[scaled][1:v]overlay=0:0"
|
|
ffmpeg -i $frameFile.FullName -i $overlay -filter_complex $cropFilter $outputFramePath -y -loglevel error
|
|
}
|
|
else {
|
|
$cropFilter = "crop=${intermediateWidth}:${intermediateHeight}:${cropOffset}:0,scale=${outputWidth}:${outputHeight}"
|
|
ffmpeg -i $frameFile.FullName -vf $cropFilter $outputFramePath -y -loglevel error
|
|
}
|
|
}
|
|
else {
|
|
if ($overlay -ne '') {
|
|
# Use overlay as second input to avoid path escaping issues
|
|
$cropFilter = "[0:v]crop=${outputWidth}:${outputHeight}:${cropOffset}:0[cropped];[cropped][1:v]overlay=0:0"
|
|
ffmpeg -i $frameFile.FullName -i $overlay -filter_complex $cropFilter $outputFramePath -y -loglevel error
|
|
}
|
|
else {
|
|
$cropFilter = "crop=${outputWidth}:${outputHeight}:${cropOffset}:0"
|
|
ffmpeg -i $frameFile.FullName -vf $cropFilter $outputFramePath -y -loglevel error
|
|
}
|
|
}
|
|
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "ffmpeg failed to crop frame $frameNumber with exit code $LASTEXITCODE"
|
|
}
|
|
}
|
|
|
|
if (($i + 1) % 10 -eq 0 -or ($i + 1) -eq $extractedFrames.Count) {
|
|
Write-Host "Processed $($i + 1) / $($extractedFrames.Count) frames"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally {
|
|
if (Test-Path $tempDir) {
|
|
Remove-Item -Path $tempDir -Recurse -Force
|
|
Write-Host "Temporary directory removed: $tempDir"
|
|
}
|
|
}
|
|
}
|
|
|
|
Write-Host "Creating output video file"
|
|
$videoExt = [System.IO.Path]::GetExtension($Video)
|
|
$videoBaseName = [System.IO.Path]::GetFileNameWithoutExtension($Video)
|
|
$videoDir = [System.IO.Path]::GetDirectoryName($Video)
|
|
if ($videoDir -eq '') { $videoDir = '.' }
|
|
$outputVideo = Join-Path $videoDir "${videoBaseName}-ShortClip${videoExt}"
|
|
|
|
$inputPattern = $Images
|
|
|
|
$videoCodec = if ($videoInfo.Codec -eq 'h264') { 'libx264' } elseif ($videoInfo.Codec -eq 'hevc') { 'libx265' } else { 'libx264' }
|
|
$pixFmt = $videoInfo.PixelFormat
|
|
|
|
$encodingArgs = @('-c:v', $videoCodec, '-pix_fmt', $pixFmt, '-preset', 'slow')
|
|
|
|
if ($videoInfo.Bitrate) {
|
|
$encodingArgs += @('-b:v', $videoInfo.Bitrate)
|
|
}
|
|
else {
|
|
$encodingArgs += @('-crf', '8')
|
|
}
|
|
|
|
if ($PSCmdlet.ShouldProcess($outputVideo, 'Create output video file')) {
|
|
if ($audio -eq 'Yes') {
|
|
Write-Host "Creating video with audio"
|
|
$mp4CompatibleAudioCodecs = @('aac', 'ac3', 'eac3', 'mp3')
|
|
if ($videoInfo.AudioCodec -and $mp4CompatibleAudioCodecs -contains $videoInfo.AudioCodec) {
|
|
$audioArgs = @('-c:a', 'copy')
|
|
}
|
|
elseif ($videoInfo.AudioBitrate) {
|
|
$audioBitrateK = [Math]::Max(320, [int]($videoInfo.AudioBitrate / 1000))
|
|
$audioArgs = @('-c:a', 'aac', '-b:a', "${audioBitrateK}k")
|
|
}
|
|
else {
|
|
$audioArgs = @('-c:a', 'aac', '-b:a', '320k')
|
|
}
|
|
Write-Host "Audio args: $($audioArgs -join ' ')"
|
|
ffmpeg -framerate $videoInfo.FPS -i $inputPattern -i $Video -map 0:v:0 -map 1:a:0? @encodingArgs @audioArgs $outputVideo -y
|
|
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "ffmpeg failed to create output video with exit code $LASTEXITCODE"
|
|
}
|
|
}
|
|
else {
|
|
Write-Host "Creating video without audio"
|
|
ffmpeg -framerate $videoInfo.FPS -i $inputPattern @encodingArgs $outputVideo -y
|
|
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "ffmpeg failed to create output video with exit code $LASTEXITCODE"
|
|
}
|
|
}
|
|
|
|
if (-not (Test-Path $outputVideo)) {
|
|
throw "Output video file was not created: $outputVideo"
|
|
}
|
|
|
|
Write-Host "Output video created: $outputVideo" -ForegroundColor Green
|
|
}
|
|
|
|
# Clean up frames directory after video is successfully created
|
|
if ((-not $Keep) -and (Test-Path $framesDir) -and ($PSCmdlet.ShouldProcess($framesDir, 'Remove frames directory'))) {
|
|
Remove-Item -Path $framesDir -Recurse -Force
|
|
Write-Host "Frames directory removed: $framesDir"
|
|
}
|
|
elseif ($Keep -and (Test-Path $framesDir)) {
|
|
Write-Host "Frames directory preserved: $framesDir"
|
|
}
|
|
}
|
|
catch {
|
|
Write-Error "Error: $_"
|
|
exit 1
|
|
}
|