<# .SYNOPSIS Clones (copies) a video file using ffmpeg. When Sequence is provided and EndFrame is -1: - First, the last frame of the video is saved as a PNG image (using the Sequence value as filename). - After, the video is cloned (copied) from StartFrame to the second-to-last frame (excluding the last frame). When Sequence is provided and EndFrame is not -1: - First, the frame at EndFrame index is saved as a PNG image (using the Sequence value as filename). - After, the video is cloned (copied) from StartFrame to EndFrame-1 (excluding the extracted frame). .PARAMETER Source Path to the input video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided. .PARAMETER Target Path to the output video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided. .PARAMETER StartFrame 0-based frame index to start cloning from. .PARAMETER EndFrame 0-based frame index to end cloning at. Use -1 to clone to the end (default). .PARAMETER Sequence Path to PNG file for extracted frame. Can be absolute or relative to current directory. .png extension is added if not provided. If EndFrame is -1, extracts the last frame and excludes it from the cloned video. If EndFrame is not -1, extracts the frame at EndFrame index and excludes it from the cloned video. .PARAMETER Size Size of the output video. Uses the input video's size if not specified. Possible values: 480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height". .PARAMETER FPS Frames per second for the output video. Uses the input video's FPS if not specified. .PARAMETER NoAudio When present, excludes audio from the cloned video. .PARAMETER Audio Path to an alternate audio file to use for the target video. Can be absolute or relative to current directory. When specified, this audio replaces the source video's audio. .PARAMETER CRF Constant Rate Factor for video encoding (0-51, lower is better quality). When specified, overrides both the video bitrate and the default -crf 8 value. Only used when re-encoding is required. .PARAMETER Metadata When present, copies available JSON metadata from the source video file to the target video file. .DESCRIPTION This script uses ffmpeg to clone (copy) a video file with optional frame range and audio exclusion. When Sequence is provided, it extracts a specific frame to PNG and then clones the video accordingly. Supports -WhatIf and -Confirm for safe execution. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory = $true)] [string]$Source, [Parameter(Mandatory = $true)] [string]$Target, [Parameter(Mandatory = $false)] [int]$StartFrame = 0, [Parameter(Mandatory = $false)] [int]$EndFrame = -1, [Parameter(Mandatory = $false)] [string]$Sequence = $null, [Parameter(Mandatory = $false)] [string]$Size = $null, [Parameter(Mandatory = $false)] [double]$FPS = 0, [Parameter(Mandatory = $false)] [switch]$NoAudio, [Parameter(Mandatory = $false)] [string]$Audio = $null, [Parameter(Mandatory = $false)] [int]$CRF = -1, [Parameter(Mandatory = $false)] [switch]$Metadata ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $SourcePath = $Source if (-not $SourcePath.EndsWith('.mp4', [StringComparison]::OrdinalIgnoreCase)) { $SourcePath += '.mp4' } if (-not (Test-Path -LiteralPath $SourcePath)) { Write-Error ('Source video file not found: {0}' -f $SourcePath) exit 1 } $TargetPath = $Target if (-not $TargetPath.EndsWith('.mp4', [StringComparison]::OrdinalIgnoreCase)) { $TargetPath += '.mp4' } $TargetDir = Split-Path -Parent $TargetPath if ($TargetDir -and -not (Test-Path -LiteralPath $TargetDir)) { Write-Error ('Target directory does not exist: {0}' -f $TargetDir) exit 1 } if ($NoAudio -and -not [string]::IsNullOrWhiteSpace($Audio)) { Write-Error 'Cannot specify both -NoAudio and -Audio parameters' exit 1 } $AudioPath = $null if (-not [string]::IsNullOrWhiteSpace($Audio)) { $AudioPath = $Audio if (-not (Test-Path -LiteralPath $AudioPath)) { Write-Error ('Audio file not found: {0}' -f $AudioPath) exit 1 } } $sizeMap = @{ '480p' = @{ Width = 854; Height = 480 } '720p' = @{ Width = 1280; Height = 720 } '1080p' = @{ Width = 1920; Height = 1080 } 'HD' = @{ Width = 1920; Height = 1080 } '1440p' = @{ Width = 2560; Height = 1440 } '2K' = @{ Width = 2560; Height = 1440 } '2160p' = @{ Width = 3840; Height = 2160 } '4K' = @{ Width = 3840; Height = 2160 } } $scaleWidth = 0 $scaleHeight = 0 if (-not [string]::IsNullOrWhiteSpace($Size)) { if ($sizeMap.ContainsKey($Size)) { $scaleWidth = $sizeMap[$Size].Width $scaleHeight = $sizeMap[$Size].Height } elseif ($Size -match '^(\d+):(\d+)$') { $scaleWidth = [int]$Matches[1] $scaleHeight = [int]$Matches[2] } else { Write-Error ('Invalid Size value: {0}. Possible values: 480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height".' -f $Size) exit 1 } } $PSNativeCommandUseErrorActionPreference = $false $ffprobeFpsOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1 $ffprobeExit = $LASTEXITCODE if ($ffprobeExit -ne 0) { $ffprobeFpsOutput | ForEach-Object { Write-Error $_ } Write-Error ('ffprobe failed to read frame rate from: {0}' -f $SourcePath) exit 1 } $fpsRatio = ($ffprobeFpsOutput | Select-Object -First 1).Trim() if (-not $fpsRatio -or $fpsRatio -notmatch '^\d+/\d+$') { Write-Error ('Could not determine frame rate for: {0}' -f $SourcePath) exit 1 } $fpsNumerator = [double]($fpsRatio -split '/')[0] $fpsDenominator = [double]($fpsRatio -split '/')[1] $videoFPS = $fpsNumerator / $fpsDenominator $ffprobeFramesOutput = & ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1 $ffprobeExit = $LASTEXITCODE if ($ffprobeExit -ne 0) { $ffprobeFramesOutput | ForEach-Object { Write-Error $_ } Write-Error ('ffprobe failed to read frame count from: {0}' -f $SourcePath) exit 1 } $totalFrames = ($ffprobeFramesOutput | Select-Object -First 1).Trim() if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') { Write-Error ('Could not determine frame count for: {0}' -f $SourcePath) exit 1 } $totalFramesInt = [int]$totalFrames $maxFrameIndex = $totalFramesInt - 1 $ffprobeBitrateOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1 $ffprobeExit = $LASTEXITCODE $videoBitrate = $null if ($ffprobeExit -eq 0) { $bitrateValue = ($ffprobeBitrateOutput | Select-Object -First 1).Trim() if ($bitrateValue -and $bitrateValue -match '^\d+$') { $videoBitrate = $bitrateValue } } if ($StartFrame -lt 0 -or $StartFrame -gt $maxFrameIndex) { Write-Error ('StartFrame {0} is out of range. Valid range: 0 to {1}' -f $StartFrame, $maxFrameIndex) exit 1 } $actualEndFrame = if ($EndFrame -eq -1) { $maxFrameIndex } else { if ($EndFrame -lt $StartFrame -or $EndFrame -gt $maxFrameIndex) { Write-Error ('EndFrame {0} is out of range. Valid range: {1} to {2}' -f $EndFrame, $StartFrame, $maxFrameIndex) exit 1 } $EndFrame } if (-not [string]::IsNullOrWhiteSpace($Sequence)) { $SequencePath = $Sequence if (-not $SequencePath.EndsWith('.png', [StringComparison]::OrdinalIgnoreCase)) { $SequencePath += '.png' } $SequenceDir = Split-Path -Parent $SequencePath if ($SequenceDir -and -not (Test-Path -LiteralPath $SequenceDir)) { Write-Error ('Sequence output directory does not exist: {0}' -f $SequenceDir) exit 1 } $frameToExtract = $actualEndFrame $videoFilter = 'select=eq(n\,{0})' -f $frameToExtract $ffmpegArgsFrame = @( '-y' '-i', $SourcePath '-vf', $videoFilter '-vsync', 'vfr' '-frames:v', '1' '-q:v', '1' '--', $SequencePath ) if ($PSCmdlet.ShouldProcess($SequencePath, 'Extract frame to PNG')) { Write-Host ('Extracting frame {0} to: {1}' -f $frameToExtract, $SequencePath) $ffmpegOutput = & ffmpeg @ffmpegArgsFrame 2>&1 $ffmpegExit = $LASTEXITCODE if ($ffmpegExit -ne 0) { $ffmpegOutput | ForEach-Object { Write-Error $_ } Write-Error ('ffmpeg failed to extract frame') exit 1 } Write-Host ('Frame extracted successfully') } if ($EndFrame -eq -1) { $actualEndFrame = $maxFrameIndex - 1 Write-Host ('Adjusting EndFrame to {0} to exclude the extracted last frame from cloning' -f $actualEndFrame) } else { $actualEndFrame = $EndFrame - 1 Write-Host ('Adjusting EndFrame to {0} to exclude the extracted frame from cloning' -f $actualEndFrame) } } $frameCount = $actualEndFrame - $StartFrame + 1 Write-Host ('Cloning video: {0}' -f $Source) Write-Host (' Frame range: {0} to {1} ({2} frames)' -f $StartFrame, $actualEndFrame, $frameCount) if ($scaleWidth -gt 0) { Write-Host (' Size: {0}x{1}' -f $scaleWidth, $scaleHeight) } if ($FPS -gt 0) { Write-Host (' FPS: {0}' -f $FPS) } Write-Host (' Target: {0}' -f $TargetPath) $ffmpegArgs = @( '-y' '-i', $SourcePath ) if ($AudioPath) { $ffmpegArgs += @('-i', $AudioPath) } $needsReencode = $scaleWidth -gt 0 -or $FPS -gt 0 if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) { $filterParts = @('select=between(n\,{0}\,{1})' -f $StartFrame, $actualEndFrame) $filterParts += 'setpts=PTS-STARTPTS' if ($scaleWidth -gt 0) { $filterParts += ('scale={0}:{1}' -f $scaleWidth, $scaleHeight) } if ($FPS -gt 0) { $filterParts += ('fps={0}' -f $FPS) } $videoFilter = $filterParts -join ',' $ffmpegArgs += @( '-vf', $videoFilter '-vsync', 'vfr' ) if ($CRF -ge 0) { $ffmpegArgs += @('-crf', $CRF.ToString()) } elseif ($videoBitrate) { $ffmpegArgs += @('-b:v', $videoBitrate) } else { $ffmpegArgs += @('-crf', '14') } } elseif ($needsReencode) { $filterParts = @() if ($scaleWidth -gt 0) { $filterParts += ('scale={0}:{1}' -f $scaleWidth, $scaleHeight) } if ($FPS -gt 0) { $filterParts += ('fps={0}' -f $FPS) } $videoFilter = $filterParts -join ',' $ffmpegArgs += @('-vf', $videoFilter) if ($CRF -ge 0) { $ffmpegArgs += @('-crf', $CRF.ToString()) } elseif ($videoBitrate) { $ffmpegArgs += @('-b:v', $videoBitrate) } else { $ffmpegArgs += @('-crf', '14') } } else { $ffmpegArgs += @('-c:v', 'copy') } if ($NoAudio) { $ffmpegArgs += @('-an') } elseif ($AudioPath) { if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) { $startTime = $StartFrame / $videoFPS $endTime = ($actualEndFrame + 1) / $videoFPS $duration = $endTime - $startTime $durationStr = $duration.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture) $ffmpegArgs += @( '-map', '0:v' '-map', '1:a' '-t', $durationStr '-c:a', 'aac' ) } else { $ffmpegArgs += @( '-map', '0:v' '-map', '1:a' '-c:a', 'copy' '-shortest' ) } } else { if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) { $startTime = $StartFrame / $videoFPS $endTime = ($actualEndFrame + 1) / $videoFPS $startTimeStr = $startTime.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture) $endTimeStr = $endTime.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture) $audioFilter = "atrim=start=${startTimeStr}:end=${endTimeStr},asetpts=PTS-STARTPTS" $ffmpegArgs += @('-af', $audioFilter) } else { $ffmpegArgs += @('-c:a', 'copy') } } if ($Metadata) { $ffmpegArgs += @('-map_metadata', '0') } else { $ffmpegArgs += @('-map_metadata', '-1') } $ffmpegArgs += @('--', $TargetPath) if ($PSCmdlet.ShouldProcess($TargetPath, 'Clone video file')) { $ffmpegOutput = & ffmpeg @ffmpegArgs 2>&1 $ffmpegExit = $LASTEXITCODE if ($ffmpegExit -ne 0) { Write-Host 'ffmpeg error output:' -ForegroundColor Red $ffmpegOutput | ForEach-Object { Write-Host $_ } Write-Error ('ffmpeg failed to clone video with exit code: {0}' -f $ffmpegExit) exit 1 } Write-Host ('Video cloned successfully to: {0}' -f $TargetPath) }