<# .SYNOPSIS Clones (copies) a video file using ffmpeg. Supports MP4 (H.264), WebM and Matroska (MKV) sources, including YouTube/Google DASH downloads encoded as VP9 + Opus/AAC. Non-H.264 video and non-MP4-compatible audio are automatically transcoded to H.264 / AAC so the target is always a standard MP4. 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. If the path has no extension, .mp4, .webm and .mkv are tried in turn. .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". Forces a video re-encode, since scaling requires re-encoding. .PARAMETER FPS Frames per second for the output video. Uses the input video's FPS if not specified. Forces a video re-encode, since changing frame rate requires re-encoding. .PARAMETER NoAudio When present, excludes audio from the cloned video. Forces a video re-encode, since removing the audio stream requires re-muxing the output file. .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. .PARAMETER Reverse When present, re-encodes the video so it plays backward (from the last selected frame to the first). Honours all other parameters (StartFrame/EndFrame range, Size, FPS, CRF, container choice, etc.). Audio is NOT reversed; it is processed exactly as it would be without -Reverse (trimmed/replaced/copied as applicable) and plays forward alongside the reversed video. Forces a video re-encode, since the reverse filter cannot be applied to a stream-copied video. .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, [Parameter(Mandatory = $false)] [switch]$Reverse ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $SourcePath = $Source # Resolve source path: accept .mp4, .webm or .mkv. If the path doesn't exist as-is, # try appending each supported extension in turn (preserving the original .mp4-default # behaviour while adding WebM/MKV support for YouTube/Google DASH downloads). $supportedSourceExts = @('.mp4', '.webm', '.mkv') if (-not (Test-Path -LiteralPath $SourcePath)) { $resolved = $null foreach ($ext in $supportedSourceExts) { $candidate = "$SourcePath$ext" if (Test-Path -LiteralPath $candidate) { $resolved = $candidate break } } if ($resolved) { $SourcePath = $resolved } } if (-not (Test-Path -LiteralPath $SourcePath)) { Write-Error ('Source video file not found: {0}' -f $Source) exit 1 } $TargetPath = $Target # Determine output container from the target extension. # Supported: .mp4 (H.264/AAC), .webm (VP9/Opus), .mkv (H.264/AAC, permissive copy). # If no recognised extension is provided, default to .mp4 (preserves prior behaviour). $supportedTargetExts = @('.mp4', '.webm', '.mkv') $currentTargetExt = [System.IO.Path]::GetExtension($TargetPath).ToLowerInvariant() if ($supportedTargetExts -notcontains $currentTargetExt) { $TargetPath += '.mp4' $currentTargetExt = '.mp4' } $targetContainer = $currentTargetExt.TrimStart('.') # Output-container profile: target codecs, ffmpeg encode args, and the list of # source codecs that can be stream-copied directly into the container without # re-encoding. switch ($targetContainer) { 'webm' { $targetVideoCodec = 'libvpx-vp9' $targetVideoEncodeArgs = @('-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', '-deadline', 'good', '-cpu-used', '2', '-row-mt', '1') $targetAudioCodec = 'libopus' $targetAudioBitrate = '160k' $videoCopyCodecs = @('vp8', 'vp9', 'av1') $audioCopyCodecs = @('opus', 'vorbis') } 'mkv' { $targetVideoCodec = 'libx264' $targetVideoEncodeArgs = @('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'slow') $targetAudioCodec = 'aac' $targetAudioBitrate = '192k' # MKV is permissive: stream-copy almost any common codec. $videoCopyCodecs = @('h264', 'hevc', 'vp8', 'vp9', 'av1', 'mpeg4') $audioCopyCodecs = @('aac', 'mp3', 'ac3', 'eac3', 'alac', 'flac', 'opus', 'vorbis') } default { # 'mp4' fallback $targetVideoCodec = 'libx264' $targetVideoEncodeArgs = @('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'slow') $targetAudioCodec = 'aac' $targetAudioBitrate = '192k' $videoCopyCodecs = @('h264') $audioCopyCodecs = @('aac', 'mp3', 'ac3', 'eac3', 'alac') } } $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 $externalAudioCodec = '' $externalAudioCodecMismatch = $false if (-not [string]::IsNullOrWhiteSpace($Audio)) { $AudioPath = $Audio if (-not (Test-Path -LiteralPath $AudioPath)) { Write-Error ('Audio file not found: {0}' -f $AudioPath) exit 1 } # Probe the external audio file's codec so a codec that's not compatible with # the target container (e.g. Opus into MP4, or AAC into WebM) is transcoded # rather than stream-copied. $ffprobeExtAudioOutput = & ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $AudioPath 2>&1 if ($LASTEXITCODE -eq 0) { $extAudioLine = $ffprobeExtAudioOutput | Select-Object -First 1 if ($extAudioLine) { $externalAudioCodec = ([string]$extAudioLine).Trim().ToLowerInvariant() } } } $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 } $fpsRatioLine = $ffprobeFpsOutput | Select-Object -First 1 $fpsRatio = if ($fpsRatioLine) { ([string]$fpsRatioLine).Trim() } else { '' } 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 } $totalFramesLine = $ffprobeFramesOutput | Select-Object -First 1 $totalFrames = if ($totalFramesLine) { ([string]$totalFramesLine).Trim() } else { '' } 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) { $bitrateLine = $ffprobeBitrateOutput | Select-Object -First 1 if ($bitrateLine) { $bitrateValue = ([string]$bitrateLine).Trim() if ($bitrateValue -and $bitrateValue -match '^\d+$') { $videoBitrate = $bitrateValue } } } # Probe source video and audio codecs. When the source codec isn't on the # target container's stream-copy list, the script will transcode to the # container's chosen codec (see container profile above). $ffprobeVideoCodecOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1 $sourceVideoCodec = '' if ($LASTEXITCODE -eq 0) { $vCodecLine = $ffprobeVideoCodecOutput | Select-Object -First 1 if ($vCodecLine) { $sourceVideoCodec = ([string]$vCodecLine).Trim().ToLowerInvariant() } } $ffprobeAudioCodecOutput = & ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1 $sourceAudioCodec = '' if ($LASTEXITCODE -eq 0) { $aCodecLine = $ffprobeAudioCodecOutput | Select-Object -First 1 if ($aCodecLine) { $sourceAudioCodec = ([string]$aCodecLine).Trim().ToLowerInvariant() } } # A source codec not on the target container's stream-copy list forces a re-encode # regardless of trimming/scaling/FPS changes. $videoCodecMismatch = ($sourceVideoCodec -and ($videoCopyCodecs -notcontains $sourceVideoCodec)) $audioCodecMismatch = ($sourceAudioCodec -and ($audioCopyCodecs -notcontains $sourceAudioCodec)) $externalAudioCodecMismatch = ($externalAudioCodec -and ($audioCopyCodecs -notcontains $externalAudioCodec)) 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 $SourcePath) Write-Host (' Target container: {0} (video={1} audio={2})' -f $targetContainer, $targetVideoCodec, $targetAudioCodec) if ($sourceVideoCodec) { Write-Host (' Source codec: video={0}{1} audio={2}{3}' -f ` $sourceVideoCodec, $(if ($videoCodecMismatch) { " (will transcode to $targetVideoCodec)" } else { '' }), ` $(if ($sourceAudioCodec) { $sourceAudioCodec } else { 'none' }), ` $(if ($audioCodecMismatch) { " (will transcode to $targetAudioCodec)" } else { '' })) } if ($AudioPath -and $externalAudioCodec) { Write-Host (' External audio codec: {0}{1}' -f ` $externalAudioCodec, $(if ($externalAudioCodecMismatch) { " (will transcode to $targetAudioCodec)" } else { '' })) } 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) } if ($Reverse) { Write-Host ' Reverse: video reversed, audio unchanged' } Write-Host (' Target: {0}' -f $TargetPath) $ffmpegArgs = @( '-y' '-i', $SourcePath ) if ($AudioPath) { $ffmpegArgs += @('-i', $AudioPath) } $needsReencode = $scaleWidth -gt 0 -or $FPS -gt 0 -or $CRF -ge 0 -or $videoCodecMismatch -or $Reverse -or $NoAudio 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) } if ($Reverse) { $filterParts += 'reverse' } $videoFilter = $filterParts -join ',' $ffmpegArgs += @( '-vf', $videoFilter '-vsync', 'vfr' ) $ffmpegArgs += $targetVideoEncodeArgs if ($CRF -ge 0) { $ffmpegArgs += @('-crf', $CRF.ToString()) # libvpx-vp9 ignores -crf unless -b:v 0 is also set (constant-quality mode). if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') } } elseif ($videoBitrate) { $ffmpegArgs += @('-b:v', $videoBitrate) } else { $ffmpegArgs += @('-crf', '14') if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') } } } elseif ($needsReencode) { $filterParts = @() if ($scaleWidth -gt 0) { $filterParts += ('scale={0}:{1}' -f $scaleWidth, $scaleHeight) } if ($FPS -gt 0) { $filterParts += ('fps={0}' -f $FPS) } if ($Reverse) { $filterParts += 'reverse' } if ($filterParts.Count -gt 0) { $videoFilter = $filterParts -join ',' $ffmpegArgs += @('-vf', $videoFilter) } $ffmpegArgs += $targetVideoEncodeArgs if ($CRF -ge 0) { $ffmpegArgs += @('-crf', $CRF.ToString()) if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') } } elseif ($videoBitrate) { $ffmpegArgs += @('-b:v', $videoBitrate) } else { $ffmpegArgs += @('-crf', '14') if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') } } } 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', $targetAudioCodec '-b:a', $targetAudioBitrate ) } elseif ($externalAudioCodecMismatch) { # External audio cannot be stream-copied into the target container. $ffmpegArgs += @( '-map', '0:v' '-map', '1:a' '-c:a', $targetAudioCodec '-b:a', $targetAudioBitrate '-shortest' ) } 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) # The atrim filter forces an audio re-encode; explicitly set the target # container's audio codec so the result is always container-appropriate # (e.g. Opus for WebM, AAC for MP4/MKV) rather than ffmpeg's default. $ffmpegArgs += @('-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate) } elseif ($audioCodecMismatch) { # Source audio is not on the target container's stream-copy list. $ffmpegArgs += @('-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate) } 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) }