Add WebM/MKV support with automatic transcoding to H.264/AAC for MP4 compatibility

This commit is contained in:
2026-05-30 14:06:57 -04:00
parent 1e78cc67ae
commit d9c0eb3303
2 changed files with 116 additions and 20 deletions
+94 -6
View File
@@ -1,6 +1,8 @@
<# <#
.SYNOPSIS .SYNOPSIS
Clones (copies) a video file using ffmpeg. 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: 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). - 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). - 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). - 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). - After, the video is cloned (copied) from StartFrame to EndFrame-1 (excluding the extracted frame).
.PARAMETER Source .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 .PARAMETER Target
Path to the output video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided. Path to the output video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided.
.PARAMETER StartFrame .PARAMETER StartFrame
@@ -76,12 +79,26 @@ $ErrorActionPreference = 'Stop'
$SourcePath = $Source $SourcePath = $Source
if (-not $SourcePath.EndsWith('.mp4', [StringComparison]::OrdinalIgnoreCase)) { # Resolve source path: accept .mp4, .webm or .mkv. If the path doesn't exist as-is,
$SourcePath += '.mp4' # 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)) { 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 exit 1
} }
@@ -103,12 +120,22 @@ if ($NoAudio -and -not [string]::IsNullOrWhiteSpace($Audio)) {
} }
$AudioPath = $null $AudioPath = $null
$externalAudioCodec = ''
$externalAudioCodecMismatch = $false
if (-not [string]::IsNullOrWhiteSpace($Audio)) { if (-not [string]::IsNullOrWhiteSpace($Audio)) {
$AudioPath = $Audio $AudioPath = $Audio
if (-not (Test-Path -LiteralPath $AudioPath)) { if (-not (Test-Path -LiteralPath $AudioPath)) {
Write-Error ('Audio file not found: {0}' -f $AudioPath) Write-Error ('Audio file not found: {0}' -f $AudioPath)
exit 1 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 = @{ $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) { if ($StartFrame -lt 0 -or $StartFrame -gt $maxFrameIndex) {
Write-Error ('StartFrame {0} is out of range. Valid range: 0 to {1}' -f $StartFrame, $maxFrameIndex) Write-Error ('StartFrame {0} is out of range. Valid range: 0 to {1}' -f $StartFrame, $maxFrameIndex)
exit 1 exit 1
@@ -258,7 +309,17 @@ if (-not [string]::IsNullOrWhiteSpace($Sequence)) {
$frameCount = $actualEndFrame - $StartFrame + 1 $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) Write-Host (' Frame range: {0} to {1} ({2} frames)' -f $StartFrame, $actualEndFrame, $frameCount)
if ($scaleWidth -gt 0) { if ($scaleWidth -gt 0) {
Write-Host (' Size: {0}x{1}' -f $scaleWidth, $scaleHeight) Write-Host (' Size: {0}x{1}' -f $scaleWidth, $scaleHeight)
@@ -277,7 +338,7 @@ if ($AudioPath) {
$ffmpegArgs += @('-i', $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) { if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
$filterParts = @('select=between(n\,{0}\,{1})' -f $StartFrame, $actualEndFrame) $filterParts = @('select=between(n\,{0}\,{1})' -f $StartFrame, $actualEndFrame)
@@ -292,6 +353,9 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
$ffmpegArgs += @( $ffmpegArgs += @(
'-vf', $videoFilter '-vf', $videoFilter
'-vsync', 'vfr' '-vsync', 'vfr'
'-c:v', 'libx264'
'-pix_fmt', 'yuv420p'
'-preset', 'slow'
) )
if ($CRF -ge 0) { if ($CRF -ge 0) {
$ffmpegArgs += @('-crf', $CRF.ToString()) $ffmpegArgs += @('-crf', $CRF.ToString())
@@ -308,8 +372,15 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
if ($FPS -gt 0) { if ($FPS -gt 0) {
$filterParts += ('fps={0}' -f $FPS) $filterParts += ('fps={0}' -f $FPS)
} }
if ($filterParts.Count -gt 0) {
$videoFilter = $filterParts -join ',' $videoFilter = $filterParts -join ','
$ffmpegArgs += @('-vf', $videoFilter) $ffmpegArgs += @('-vf', $videoFilter)
}
$ffmpegArgs += @(
'-c:v', 'libx264'
'-pix_fmt', 'yuv420p'
'-preset', 'slow'
)
if ($CRF -ge 0) { if ($CRF -ge 0) {
$ffmpegArgs += @('-crf', $CRF.ToString()) $ffmpegArgs += @('-crf', $CRF.ToString())
} elseif ($videoBitrate) { } elseif ($videoBitrate) {
@@ -335,6 +406,15 @@ if ($NoAudio) {
'-t', $durationStr '-t', $durationStr
'-c:a', 'aac' '-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 { } else {
$ffmpegArgs += @( $ffmpegArgs += @(
'-map', '0:v' '-map', '0:v'
@@ -351,6 +431,14 @@ if ($NoAudio) {
$endTimeStr = $endTime.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture) $endTimeStr = $endTime.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture)
$audioFilter = "atrim=start=${startTimeStr}:end=${endTimeStr},asetpts=PTS-STARTPTS" $audioFilter = "atrim=start=${startTimeStr}:end=${endTimeStr},asetpts=PTS-STARTPTS"
$ffmpegArgs += @('-af', $audioFilter) $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 { } else {
$ffmpegArgs += @('-c:a', 'copy') $ffmpegArgs += @('-c:a', 'copy')
} }
+20 -12
View File
@@ -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'." 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 # Pick the smallest standard 16:9 preset whose longer side (width) is at least
$minWidthFromHeight = [int][math]::Ceiling($SourceHeight * 16.0 / 9.0) # the source's longer side. The foreground is scaled down with
$minHeightFromWidth = [int][math]::Ceiling($SourceWidth * 9.0 / 16.0) # '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 $unique = $standards | Sort-Object { $_.Width } -Unique
foreach ($s in $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 } return [pscustomobject]@{ Width = $s.Width; Height = $s.Height }
} }
} }
# Source larger than any standard preset: use exact computed dimensions (rounded to even) # Source larger than any standard preset: build a 16:9 frame whose width
$w = $neededWidth + ($neededWidth % 2) # matches the source's longer side (rounded to even).
$h = $neededHeight + ($neededHeight % 2) $w = $longSide + ($longSide % 2)
$h = [int][math]::Ceiling($w * 9.0 / 16.0)
if ($h % 2) { $h++ }
return [pscustomobject]@{ Width = $w; Height = $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 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 ' ')" Write-Host "ffmpeg $($ffArgs -join ' ')"
throw "ffmpeg failed while creating pillar-boxed video: $($inInfo.FileName)" throw "ffmpeg failed while creating pillar-boxed video: $($inInfo.FileName)"
} }