Add WebM/MKV support with automatic transcoding to H.264/AAC for MP4 compatibility
This commit is contained in:
+94
-6
@@ -1,6 +1,8 @@
|
||||
<#
|
||||
.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).
|
||||
@@ -8,7 +10,8 @@
|
||||
- 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.
|
||||
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
|
||||
@@ -76,12 +79,26 @@ $ErrorActionPreference = 'Stop'
|
||||
|
||||
$SourcePath = $Source
|
||||
|
||||
if (-not $SourcePath.EndsWith('.mp4', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$SourcePath += '.mp4'
|
||||
# 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 $SourcePath)
|
||||
Write-Error ('Source video file not found: {0}' -f $Source)
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -103,12 +120,22 @@ if ($NoAudio -and -not [string]::IsNullOrWhiteSpace($Audio)) {
|
||||
}
|
||||
|
||||
$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 = @{
|
||||
@@ -190,6 +217,30 @@ if ($ffprobeExit -eq 0) {
|
||||
}
|
||||
}
|
||||
|
||||
# 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
|
||||
@@ -258,7 +309,17 @@ if (-not [string]::IsNullOrWhiteSpace($Sequence)) {
|
||||
|
||||
$frameCount = $actualEndFrame - $StartFrame + 1
|
||||
|
||||
Write-Host ('Cloning video: {0}' -f $Source)
|
||||
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)
|
||||
@@ -277,7 +338,7 @@ if ($AudioPath) {
|
||||
$ffmpegArgs += @('-i', $AudioPath)
|
||||
}
|
||||
|
||||
$needsReencode = $scaleWidth -gt 0 -or $FPS -gt 0
|
||||
$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)
|
||||
@@ -292,6 +353,9 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
|
||||
$ffmpegArgs += @(
|
||||
'-vf', $videoFilter
|
||||
'-vsync', 'vfr'
|
||||
'-c:v', 'libx264'
|
||||
'-pix_fmt', 'yuv420p'
|
||||
'-preset', 'slow'
|
||||
)
|
||||
if ($CRF -ge 0) {
|
||||
$ffmpegArgs += @('-crf', $CRF.ToString())
|
||||
@@ -308,8 +372,15 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
|
||||
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) {
|
||||
@@ -335,6 +406,15 @@ if ($NoAudio) {
|
||||
'-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'
|
||||
@@ -351,6 +431,14 @@ if ($NoAudio) {
|
||||
$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')
|
||||
}
|
||||
|
||||
+20
-12
@@ -158,24 +158,28 @@ function Resolve-Size {
|
||||
throw "Invalid -Size value: $Value. Use a named preset (480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K) or 'width:height'."
|
||||
}
|
||||
|
||||
# Compute the minimum 16:9 frame that fully contains the source
|
||||
$minWidthFromHeight = [int][math]::Ceiling($SourceHeight * 16.0 / 9.0)
|
||||
$minHeightFromWidth = [int][math]::Ceiling($SourceWidth * 9.0 / 16.0)
|
||||
# Pick the smallest standard 16:9 preset whose longer side (width) is at least
|
||||
# the source's longer side. The foreground is scaled down with
|
||||
# 'force_original_aspect_ratio=decrease', so the source is allowed to shrink
|
||||
# to fit; we only need the preset to be "big enough" relative to the source's
|
||||
# largest dimension. This produces the documented behavior:
|
||||
# 1080x1920 portrait -> 1920x1080 (vertical bars on either side)
|
||||
# 720x1280 portrait -> 1280x720
|
||||
# 2560x1080 landscape -> 2560x1440 (horizontal bars top/bottom)
|
||||
$longSide = [math]::Max($SourceWidth, $SourceHeight)
|
||||
|
||||
$neededWidth = [math]::Max($SourceWidth, $minWidthFromHeight)
|
||||
$neededHeight = [math]::Max($SourceHeight, $minHeightFromWidth)
|
||||
|
||||
# Pick the smallest standard 16:9 size that contains the source
|
||||
$unique = $standards | Sort-Object { $_.Width } -Unique
|
||||
foreach ($s in $unique) {
|
||||
if ($s.Width -ge $neededWidth -and $s.Height -ge $neededHeight) {
|
||||
if ($s.Width -ge $longSide) {
|
||||
return [pscustomobject]@{ Width = $s.Width; Height = $s.Height }
|
||||
}
|
||||
}
|
||||
|
||||
# Source larger than any standard preset: use exact computed dimensions (rounded to even)
|
||||
$w = $neededWidth + ($neededWidth % 2)
|
||||
$h = $neededHeight + ($neededHeight % 2)
|
||||
# Source larger than any standard preset: build a 16:9 frame whose width
|
||||
# matches the source's longer side (rounded to even).
|
||||
$w = $longSide + ($longSide % 2)
|
||||
$h = [int][math]::Ceiling($w * 9.0 / 16.0)
|
||||
if ($h % 2) { $h++ }
|
||||
return [pscustomobject]@{ Width = $w; Height = $h }
|
||||
}
|
||||
|
||||
@@ -349,7 +353,11 @@ if ($PSCmdlet.ShouldProcess($targetFull, 'Create pillar-boxed video with ffmpeg'
|
||||
Remove-Item -LiteralPath $targetFull -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$ffmpegOutput | ForEach-Object { Write-Error $_ }
|
||||
# Use Write-Host (not Write-Error) so the full ffmpeg output is shown;
|
||||
# under $ErrorActionPreference = 'Stop' a piped Write-Error would terminate
|
||||
# on the first line, swallowing the rest of the diagnostic output and the
|
||||
# final 'throw' below.
|
||||
$ffmpegOutput | ForEach-Object { Write-Host $_ }
|
||||
Write-Host "ffmpeg $($ffArgs -join ' ')"
|
||||
throw "ffmpeg failed while creating pillar-boxed video: $($inInfo.FileName)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user