Inital push

This commit is contained in:
2026-05-19 14:55:09 -04:00
commit 7f2f0754b3
29 changed files with 3190 additions and 0 deletions
+379
View File
@@ -0,0 +1,379 @@
<#
.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)
}