<# .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 Offset") Write-Host ("-" * 21) $previousOffset = $null foreach ($frame in ($croppingOffsets.Keys | Sort-Object)) { $currentOffset = $croppingOffsets[$frame] if ($currentOffset -ne $previousOffset) { Write-Host ("{0,-10}{1}" -f $frame, $currentOffset) $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 }