614 lines
23 KiB
PowerShell
614 lines
23 KiB
PowerShell
<#
|
|
.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.
|
|
.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.
|
|
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,
|
|
|
|
[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
|
|
}
|
|
|
|
$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
|
|
|
|
# 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 ($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)
|
|
}
|