380 lines
13 KiB
PowerShell
380 lines
13 KiB
PowerShell
<#
|
|
.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)
|
|
}
|