<# .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 Frames is provided, it defines a frame range using a flexible format and is mutually exclusive with StartFrame, EndFrame and Sequence. 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). When Source is a directory containing PNG files with sequence numbers, the script builds a video from the image sequence, ordered alphabetically (or reverse alphabetically if -Reverse is specified). - -Sequence and -Metadata are not allowed in this mode. - Defaults: 24 FPS, CRF 8, no audio (unless -Audio is provided). - -Size, -FPS, -CRF, -Audio, -NoAudio, -StartFrame, -EndFrame, -Frames, -Reverse and -LTX are all honoured. .PARAMETER Source Path to the input video file, or a directory containing PNG files with sequence numbers (e.g. frame_0000.png). For video files: can be absolute or relative to current directory. If the path has no extension, .mp4, .webm and .mkv are tried in turn. For PNG sequence directories: the script builds a video from the PNG files sorted alphabetically; -Sequence and -Metadata are not allowed in this mode. .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 Frames 0-based frames index to extract. Supports the following formats: - start-end: Extract frames from start to end (e.g., "100-200") - positive_number: Extract frames from 0 to that number (e.g., "50") - -negative_number: Extract last N frames (e.g., "-10") - %: Represents the last frame index. Can be used alone or in ranges (e.g., "%", "10-%", "-%") - all: Extract all frames (same as "0-%") .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: 320p, 384p, 480p, 720p, 900p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height". For standard named sizes the output preserves the source's aspect ratio: the reference height is used as the short-side target and the other dimension is computed from the source AR (landscape/square: target height = reference, width computed; portrait: target width = reference, height computed). Custom sizes (width:height) are applied exactly. 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. .PARAMETER LTX When present, overrides the dimensions selected by -Size with the closest LTX-2 (Wan2GP by DeepBeepMeep) canonical resolution for the source's aspect ratio. Only valid when -Size is one of HD, 1080p, 720p or 480p. The closest aspect ratio is picked from the following buckets: - HD / 1080p: 1920x1088 (16:9), 1088x1920 (9:16), 1664x960 (21:9), 960x1664 (9:21) - 720p: 1280x704 (16:9), 704x1280 (9:16), 1088x576 (21:9), 576x1088 (9:21) - 480p: 832x448 (16:9), 448x832 (9:16), 896x512 (4:3), 512x896 (3:4) Forces a video re-encode (same as -Size). .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. When Source is a directory, the script builds a video from the PNG image sequence found inside. 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]$Frames = $null, [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, [Parameter(Mandatory = $false)] [switch]$LTX ) 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 } if (-not [string]::IsNullOrWhiteSpace($Frames)) { if ($PSBoundParameters.ContainsKey('StartFrame') -or $PSBoundParameters.ContainsKey('EndFrame') -or -not [string]::IsNullOrWhiteSpace($Sequence)) { Write-Error '-Frames cannot be used together with -StartFrame, -EndFrame, or -Sequence' 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 = @{ '320p' = @{ Width = 570; Height = 320 } '384p' = @{ Width = 684; Height = 384 } '480p' = @{ Width = 854; Height = 480 } '720p' = @{ Width = 1280; Height = 720 } '900p' = @{ Width = 1600; Height = 900 } '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 $standardSizeHeight = 0 if (-not [string]::IsNullOrWhiteSpace($Size)) { if ($sizeMap.ContainsKey($Size)) { $scaleWidth = $sizeMap[$Size].Width $scaleHeight = $sizeMap[$Size].Height $standardSizeHeight = $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: 320p, 384p, 480p, 720p, 900p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height".' -f $Size) exit 1 } } $PSNativeCommandUseErrorActionPreference = $false # PNG image sequence mode: when $Source is a directory containing PNG files if (Test-Path -LiteralPath $SourcePath -PathType Container) { if (-not [string]::IsNullOrWhiteSpace($Sequence)) { Write-Error '-Sequence cannot be used when -Source is a PNG image directory.' exit 1 } if ($Metadata) { Write-Error '-Metadata cannot be used when -Source is a PNG image directory.' exit 1 } $pngFiles = @(Get-ChildItem -LiteralPath $SourcePath -Filter '*.png' | Sort-Object Name) if ($pngFiles.Count -eq 0) { Write-Error ('No PNG files found in directory: {0}' -f $SourcePath) exit 1 } $maxPngIndex = $pngFiles.Count - 1 if (-not [string]::IsNullOrWhiteSpace($Frames)) { $framesPattern = $Frames -replace '%', $maxPngIndex if ($framesPattern -ieq 'all') { $framesPattern = "0-$maxPngIndex" } if ($framesPattern -match '^(\d+)-(\d+)$') { $StartFrame = [int]$Matches[1] $EndFrame = [int]$Matches[2] } elseif ($framesPattern -match '^-(\d+)$') { $StartFrame = [Math]::Max(0, $maxPngIndex - [int]$Matches[1]) $EndFrame = $maxPngIndex } elseif ($framesPattern -match '^\d+$') { $StartFrame = 0 $EndFrame = [Math]::Min([int]$framesPattern, $maxPngIndex) } else { Write-Error ('Invalid Frames format: {0}. Expected: start-end, positive number, -N for last N frames, % for last frame, or "all"' -f $Frames) exit 1 } } if ($StartFrame -lt 0 -or $StartFrame -gt $maxPngIndex) { Write-Error ('StartFrame {0} is out of range. Valid range: 0 to {1}' -f $StartFrame, $maxPngIndex) exit 1 } $seqEndFrame = if ($EndFrame -eq -1) { $maxPngIndex } else { if ($EndFrame -lt $StartFrame -or $EndFrame -gt $maxPngIndex) { Write-Error ('EndFrame {0} is out of range. Valid range: {1} to {2}' -f $EndFrame, $StartFrame, $maxPngIndex) exit 1 } $EndFrame } $pngFiles = @($pngFiles[$StartFrame..$seqEndFrame]) if ($Reverse) { $pngFiles = @($pngFiles | Sort-Object Name -Descending) } $seqFPS = if ($FPS -gt 0) { $FPS } else { 24 } $seqCRF = if ($CRF -ge 0) { $CRF } else { 8 } $firstPngPath = $pngFiles[0].FullName $seqSrcWidth = 0 $seqSrcHeight = 0 $seqDimOut = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -- $firstPngPath 2>&1 if ($LASTEXITCODE -eq 0) { $seqDimLine = $seqDimOut | Select-Object -First 1 if ($seqDimLine -and ([string]$seqDimLine).Trim() -match '^(\d+)x(\d+)$') { $seqSrcWidth = [int]$Matches[1] $seqSrcHeight = [int]$Matches[2] } } $seqOutWidth = $scaleWidth $seqOutHeight = $scaleHeight if ($standardSizeHeight -gt 0 -and -not $LTX -and $seqSrcWidth -gt 0 -and $seqSrcHeight -gt 0) { if ($seqSrcWidth -ge $seqSrcHeight) { $seqOutHeight = $standardSizeHeight $seqOutWidth = [int][Math]::Round([double]$seqSrcWidth * $standardSizeHeight / $seqSrcHeight) if ($seqOutWidth % 2) { $seqOutWidth++ } } else { $seqOutWidth = $standardSizeHeight $seqOutHeight = [int][Math]::Round([double]$seqSrcHeight * $standardSizeHeight / $seqSrcWidth) if ($seqOutHeight % 2) { $seqOutHeight++ } } } if ($LTX) { $seqLtxKey = $Size if ($seqLtxKey -eq 'HD') { $seqLtxKey = '1080p' } $seqLtxMap = @{ '1080p' = @( @{ W = 1920; H = 1088 }, @{ W = 1088; H = 1920 }, @{ W = 1664; H = 960 }, @{ W = 960; H = 1664 } ) '720p' = @( @{ W = 1280; H = 704 }, @{ W = 704; H = 1280 }, @{ W = 1088; H = 576 }, @{ W = 576; H = 1088 } ) '480p' = @( @{ W = 832; H = 448 }, @{ W = 448; H = 832 }, @{ W = 896; H = 512 }, @{ W = 512; H = 896 } ) } if (-not $seqLtxMap.ContainsKey($seqLtxKey)) { Write-Error '-LTX requires -Size to be one of: 480p, 720p, 1080p, HD' exit 1 } if ($seqSrcWidth -le 0 -or $seqSrcHeight -le 0) { Write-Error 'Could not determine PNG dimensions; -LTX cannot pick an aspect-ratio bucket.' exit 1 } $seqAR = [double]$seqSrcWidth / [double]$seqSrcHeight $seqBest = $null $seqBestDiff = [double]::MaxValue foreach ($seqEntry in $seqLtxMap[$seqLtxKey]) { $seqR = [double]$seqEntry.W / [double]$seqEntry.H $seqD = [Math]::Abs($seqR - $seqAR) if ($seqD -lt $seqBestDiff) { $seqBestDiff = $seqD; $seqBest = $seqEntry } } $seqOutWidth = $seqBest.W $seqOutHeight = $seqBest.H } if ($seqOutWidth -eq 0 -and $seqSrcWidth -gt 0) { $seqOutWidth = $seqSrcWidth $seqOutHeight = $seqSrcHeight } if ($seqOutWidth % 2) { $seqOutWidth++ } if ($seqOutHeight % 2) { $seqOutHeight++ } $concatTempFile = [System.IO.Path]::GetTempFileName() try { $frameDuration = (1.0 / $seqFPS).ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture) $concatLines = @('ffconcat version 1.0') foreach ($pf in $pngFiles) { $pfPath = $pf.FullName.Replace('\', '/') $concatLines += "file '$pfPath'" $concatLines += "duration $frameDuration" } $concatLines += "file '$($pngFiles[-1].FullName.Replace('\', '/'))'" [System.IO.File]::WriteAllLines($concatTempFile, $concatLines, [System.Text.UTF8Encoding]::new($false)) $seqFfmpegArgs = @( '-y' '-f', 'concat' '-safe', '0' '-i', $concatTempFile ) if ($AudioPath) { $seqFfmpegArgs += @('-i', $AudioPath) } $seqFfmpegArgs += @('-r', $seqFPS.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture)) if ($seqOutWidth -gt 0) { $seqFfmpegArgs += @('-vf', ('scale={0}:{1}' -f $seqOutWidth, $seqOutHeight)) } $seqFfmpegArgs += $targetVideoEncodeArgs $seqFfmpegArgs += @('-crf', $seqCRF.ToString()) if ($targetVideoCodec -eq 'libvpx-vp9') { $seqFfmpegArgs += @('-b:v', '0') } $seqExtAudioMismatch = ($externalAudioCodec -and ($audioCopyCodecs -notcontains $externalAudioCodec)) if ($NoAudio -or -not $AudioPath) { $seqFfmpegArgs += @('-an') } elseif ($seqExtAudioMismatch) { $seqFfmpegArgs += @('-map', '0:v', '-map', '1:a', '-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate, '-shortest') } else { $seqFfmpegArgs += @('-map', '0:v', '-map', '1:a', '-c:a', 'copy', '-shortest') } $seqFfmpegArgs += @('-map_metadata', '-1') $seqFfmpegArgs += @('--', $TargetPath) Write-Host ('Building video from PNG sequence: {0}' -f $SourcePath) Write-Host (' Target container: {0} (video={1})' -f $targetContainer, $targetVideoCodec) Write-Host (' PNG files: {0} (first={1} last={2})' -f $pngFiles.Count, $pngFiles[0].Name, $pngFiles[-1].Name) Write-Host (' FPS: {0}' -f $seqFPS) if ($seqOutWidth -gt 0) { if ($LTX) { Write-Host (' Size: {0}x{1} (LTX-2)' -f $seqOutWidth, $seqOutHeight) } else { Write-Host (' Size: {0}x{1}' -f $seqOutWidth, $seqOutHeight) } } Write-Host (' CRF: {0}' -f $seqCRF) if ($Reverse) { Write-Host ' Order: reversed (descending alphabetical)' } if ($AudioPath) { Write-Host (' Audio: {0}' -f $AudioPath) } else { Write-Host ' Audio: none' } Write-Host (' Target: {0}' -f $TargetPath) if ($PSCmdlet.ShouldProcess($TargetPath, 'Build video from PNG sequence')) { $seqOutput = & ffmpeg @seqFfmpegArgs 2>&1 $seqExit = $LASTEXITCODE if ($seqExit -ne 0) { Write-Host 'ffmpeg error output:' -ForegroundColor Red $seqOutput | ForEach-Object { Write-Host $_ } Write-Error ('ffmpeg failed to build video from PNG sequence with exit code: {0}' -f $seqExit) exit 1 } Write-Host ('Video built successfully to: {0}' -f $TargetPath) } } finally { if (Test-Path -LiteralPath $concatTempFile) { Remove-Item -LiteralPath $concatTempFile -Force } } exit 0 } $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 if (-not [string]::IsNullOrWhiteSpace($Frames)) { $framesPattern = $Frames -replace '%', $maxFrameIndex if ($framesPattern -ieq 'all') { $framesPattern = "0-$maxFrameIndex" } if ($framesPattern -match '^(\d+)-(\d+)$') { $StartFrame = [int]$Matches[1] $EndFrame = [int]$Matches[2] } elseif ($framesPattern -match '^-(\d+)$') { $StartFrame = [Math]::Max(0, $maxFrameIndex - [int]$Matches[1]) $EndFrame = $maxFrameIndex } elseif ($framesPattern -match '^\d+$') { $StartFrame = 0 $EndFrame = [Math]::Min([int]$framesPattern, $maxFrameIndex) } else { Write-Error ('Invalid Frames format: {0}. Expected: start-end, positive number, -N for last N frames, % for last frame, or "all"' -f $Frames) exit 1 } } # Probe source video dimensions (used by -LTX to pick the closest aspect-ratio bucket). $ffprobeDimOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -- $SourcePath 2>&1 $sourceWidth = 0 $sourceHeight = 0 if ($LASTEXITCODE -eq 0) { $dimLine = $ffprobeDimOutput | Select-Object -First 1 if ($dimLine) { $dimText = ([string]$dimLine).Trim() if ($dimText -match '^(\d+)x(\d+)$') { $sourceWidth = [int]$Matches[1] $sourceHeight = [int]$Matches[2] } } } if ($standardSizeHeight -gt 0 -and -not $LTX -and $sourceWidth -gt 0 -and $sourceHeight -gt 0) { if ($sourceWidth -ge $sourceHeight) { $scaleHeight = $standardSizeHeight $scaleWidth = [int][Math]::Round([double]$sourceWidth * $standardSizeHeight / $sourceHeight) if ($scaleWidth % 2) { $scaleWidth++ } } else { $scaleWidth = $standardSizeHeight $scaleHeight = [int][Math]::Round([double]$sourceHeight * $standardSizeHeight / $sourceWidth) if ($scaleHeight % 2) { $scaleHeight++ } } } if ($LTX) { $ltxKey = $Size if ($ltxKey -eq 'HD') { $ltxKey = '1080p' } $ltxMap = @{ '1080p' = @( @{ W = 1920; H = 1088 }, @{ W = 1088; H = 1920 }, @{ W = 1664; H = 960 }, @{ W = 960; H = 1664 } ) '720p' = @( @{ W = 1280; H = 704 }, @{ W = 704; H = 1280 }, @{ W = 1088; H = 576 }, @{ W = 576; H = 1088 } ) '480p' = @( @{ W = 832; H = 448 }, @{ W = 448; H = 832 }, @{ W = 896; H = 512 }, @{ W = 512; H = 896 } ) } if (-not $ltxMap.ContainsKey($ltxKey)) { Write-Error '-LTX requires -Size to be one of: 480p, 720p, 1080p, HD' exit 1 } if ($sourceWidth -le 0 -or $sourceHeight -le 0) { Write-Error 'Could not determine source video dimensions; -LTX cannot pick an aspect-ratio bucket.' exit 1 } $srcRatio = [double]$sourceWidth / [double]$sourceHeight $best = $null $bestDiff = [double]::MaxValue foreach ($entry in $ltxMap[$ltxKey]) { $r = [double]$entry.W / [double]$entry.H $d = [Math]::Abs($r - $srcRatio) if ($d -lt $bestDiff) { $bestDiff = $d $best = $entry } } $scaleWidth = $best.W $scaleHeight = $best.H } $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) { if ($LTX) { Write-Host (' Size: {0}x{1} (LTX-2)' -f $scaleWidth, $scaleHeight) } else { 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) }