Files
Video-Processing-Scripts/Clone-Video.ps1
T

468 lines
17 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".
.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
# 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
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
$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 non-MP4-compatible codec
# (e.g. Opus from a YouTube/DASH download) is transcoded to AAC instead
# of being stream-copied into the MP4 container.
$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) {
$externalAudioCodec = ($ffprobeExtAudioOutput | Select-Object -First 1).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
}
$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
}
}
# Probe source video and audio codecs. The MP4 container only supports a limited set
# of codecs: when the source is VP9/VP8/AV1 video or Opus/Vorbis/etc. audio (typical
# YouTube/Google DASH downloads), we must transcode rather than stream-copy.
$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) {
$sourceVideoCodec = ($ffprobeVideoCodecOutput | Select-Object -First 1).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) {
$sourceAudioCodec = ($ffprobeAudioCodecOutput | Select-Object -First 1).Trim().ToLowerInvariant()
}
# A non-H.264 source forces a video re-encode regardless of trimming/scaling/FPS changes.
$videoCodecMismatch = ($sourceVideoCodec -and $sourceVideoCodec -ne 'h264')
# MP4-container-compatible audio codecs. Anything else (opus, vorbis, flac, ...) must
# be transcoded to AAC when stream-copying audio.
$mp4AudioCodecs = @('aac', 'mp3', 'ac3', 'eac3', 'alac')
$audioCodecMismatch = ($sourceAudioCodec -and ($mp4AudioCodecs -notcontains $sourceAudioCodec))
$externalAudioCodecMismatch = ($externalAudioCodec -and ($mp4AudioCodecs -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)
if ($sourceVideoCodec) {
Write-Host (' Source codec: video={0}{1} audio={2}{3}' -f `
$sourceVideoCodec, $(if ($videoCodecMismatch) { ' (will transcode to h264)' } else { '' }), `
$(if ($sourceAudioCodec) { $sourceAudioCodec } else { 'none' }), `
$(if ($audioCodecMismatch) { ' (will transcode to aac)' } else { '' }))
}
if ($AudioPath -and $externalAudioCodec) {
Write-Host (' External audio codec: {0}{1}' -f `
$externalAudioCodec, $(if ($externalAudioCodecMismatch) { ' (will transcode to aac)' } 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)
}
Write-Host (' Target: {0}' -f $TargetPath)
$ffmpegArgs = @(
'-y'
'-i', $SourcePath
)
if ($AudioPath) {
$ffmpegArgs += @('-i', $AudioPath)
}
$needsReencode = $scaleWidth -gt 0 -or $FPS -gt 0 -or $videoCodecMismatch
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'
'-c:v', 'libx264'
'-pix_fmt', 'yuv420p'
'-preset', 'slow'
)
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)
}
if ($filterParts.Count -gt 0) {
$videoFilter = $filterParts -join ','
$ffmpegArgs += @('-vf', $videoFilter)
}
$ffmpegArgs += @(
'-c:v', 'libx264'
'-pix_fmt', 'yuv420p'
'-preset', 'slow'
)
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'
)
} elseif ($externalAudioCodecMismatch) {
# External audio (e.g. Opus from a YouTube/DASH WebM) cannot be stream-copied into MP4.
$ffmpegArgs += @(
'-map', '0:v'
'-map', '1:a'
'-c:a', 'aac'
'-b:a', '192k'
'-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)
if ($audioCodecMismatch) {
# ffmpeg will already re-encode here (no -c:a copy), but force AAC explicitly
# so the output is MP4-compatible regardless of source codec.
$ffmpegArgs += @('-c:a', 'aac', '-b:a', '192k')
}
} elseif ($audioCodecMismatch) {
# Source audio (e.g. Opus from a YouTube/DASH WebM) cannot be stream-copied into MP4.
$ffmpegArgs += @('-c:a', 'aac', '-b:a', '192k')
} 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)
}