Add support for WebM and MKV output containers with codec-aware stream copying and transcoding

This commit is contained in:
2026-06-01 01:15:09 -04:00
parent ad31100d64
commit 6b5a67dddd
+73 -37
View File
@@ -104,8 +104,47 @@ if (-not (Test-Path -LiteralPath $SourcePath)) {
$TargetPath = $Target $TargetPath = $Target
if (-not $TargetPath.EndsWith('.mp4', [StringComparison]::OrdinalIgnoreCase)) { # 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' $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 $TargetDir = Split-Path -Parent $TargetPath
@@ -129,9 +168,9 @@ if (-not [string]::IsNullOrWhiteSpace($Audio)) {
exit 1 exit 1
} }
# Probe the external audio file's codec so a non-MP4-compatible codec # Probe the external audio file's codec so a codec that's not compatible with
# (e.g. Opus from a YouTube/DASH download) is transcoded to AAC instead # the target container (e.g. Opus into MP4, or AAC into WebM) is transcoded
# of being stream-copied into the MP4 container. # 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 $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) { if ($LASTEXITCODE -eq 0) {
$externalAudioCodec = ($ffprobeExtAudioOutput | Select-Object -First 1).Trim().ToLowerInvariant() $externalAudioCodec = ($ffprobeExtAudioOutput | Select-Object -First 1).Trim().ToLowerInvariant()
@@ -217,9 +256,9 @@ if ($ffprobeExit -eq 0) {
} }
} }
# Probe source video and audio codecs. The MP4 container only supports a limited set # Probe source video and audio codecs. When the source codec isn't on the
# of codecs: when the source is VP9/VP8/AV1 video or Opus/Vorbis/etc. audio (typical # target container's stream-copy list, the script will transcode to the
# YouTube/Google DASH downloads), we must transcode rather than stream-copy. # 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 $ffprobeVideoCodecOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
$sourceVideoCodec = '' $sourceVideoCodec = ''
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
@@ -232,14 +271,11 @@ if ($LASTEXITCODE -eq 0) {
$sourceAudioCodec = ($ffprobeAudioCodecOutput | Select-Object -First 1).Trim().ToLowerInvariant() $sourceAudioCodec = ($ffprobeAudioCodecOutput | Select-Object -First 1).Trim().ToLowerInvariant()
} }
# A non-H.264 source forces a video re-encode regardless of trimming/scaling/FPS changes. # A source codec not on the target container's stream-copy list forces a re-encode
$videoCodecMismatch = ($sourceVideoCodec -and $sourceVideoCodec -ne 'h264') # regardless of trimming/scaling/FPS changes.
$videoCodecMismatch = ($sourceVideoCodec -and ($videoCopyCodecs -notcontains $sourceVideoCodec))
# MP4-container-compatible audio codecs. Anything else (opus, vorbis, flac, ...) must $audioCodecMismatch = ($sourceAudioCodec -and ($audioCopyCodecs -notcontains $sourceAudioCodec))
# be transcoded to AAC when stream-copying audio. $externalAudioCodecMismatch = ($externalAudioCodec -and ($audioCopyCodecs -notcontains $externalAudioCodec))
$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)
@@ -310,15 +346,16 @@ if (-not [string]::IsNullOrWhiteSpace($Sequence)) {
$frameCount = $actualEndFrame - $StartFrame + 1 $frameCount = $actualEndFrame - $StartFrame + 1
Write-Host ('Cloning video: {0}' -f $SourcePath) Write-Host ('Cloning video: {0}' -f $SourcePath)
Write-Host (' Target container: {0} (video={1} audio={2})' -f $targetContainer, $targetVideoCodec, $targetAudioCodec)
if ($sourceVideoCodec) { if ($sourceVideoCodec) {
Write-Host (' Source codec: video={0}{1} audio={2}{3}' -f ` Write-Host (' Source codec: video={0}{1} audio={2}{3}' -f `
$sourceVideoCodec, $(if ($videoCodecMismatch) { ' (will transcode to h264)' } else { '' }), ` $sourceVideoCodec, $(if ($videoCodecMismatch) { " (will transcode to $targetVideoCodec)" } else { '' }), `
$(if ($sourceAudioCodec) { $sourceAudioCodec } else { 'none' }), ` $(if ($sourceAudioCodec) { $sourceAudioCodec } else { 'none' }), `
$(if ($audioCodecMismatch) { ' (will transcode to aac)' } else { '' })) $(if ($audioCodecMismatch) { " (will transcode to $targetAudioCodec)" } else { '' }))
} }
if ($AudioPath -and $externalAudioCodec) { if ($AudioPath -and $externalAudioCodec) {
Write-Host (' External audio codec: {0}{1}' -f ` Write-Host (' External audio codec: {0}{1}' -f `
$externalAudioCodec, $(if ($externalAudioCodecMismatch) { ' (will transcode to aac)' } else { '' })) $externalAudioCodec, $(if ($externalAudioCodecMismatch) { " (will transcode to $targetAudioCodec)" } 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) {
@@ -353,16 +390,17 @@ 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'
) )
$ffmpegArgs += $targetVideoEncodeArgs
if ($CRF -ge 0) { if ($CRF -ge 0) {
$ffmpegArgs += @('-crf', $CRF.ToString()) $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) { } elseif ($videoBitrate) {
$ffmpegArgs += @('-b:v', $videoBitrate) $ffmpegArgs += @('-b:v', $videoBitrate)
} else { } else {
$ffmpegArgs += @('-crf', '14') $ffmpegArgs += @('-crf', '14')
if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') }
} }
} elseif ($needsReencode) { } elseif ($needsReencode) {
$filterParts = @() $filterParts = @()
@@ -376,17 +414,15 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
$videoFilter = $filterParts -join ',' $videoFilter = $filterParts -join ','
$ffmpegArgs += @('-vf', $videoFilter) $ffmpegArgs += @('-vf', $videoFilter)
} }
$ffmpegArgs += @( $ffmpegArgs += $targetVideoEncodeArgs
'-c:v', 'libx264'
'-pix_fmt', 'yuv420p'
'-preset', 'slow'
)
if ($CRF -ge 0) { if ($CRF -ge 0) {
$ffmpegArgs += @('-crf', $CRF.ToString()) $ffmpegArgs += @('-crf', $CRF.ToString())
if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') }
} elseif ($videoBitrate) { } elseif ($videoBitrate) {
$ffmpegArgs += @('-b:v', $videoBitrate) $ffmpegArgs += @('-b:v', $videoBitrate)
} else { } else {
$ffmpegArgs += @('-crf', '14') $ffmpegArgs += @('-crf', '14')
if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') }
} }
} else { } else {
$ffmpegArgs += @('-c:v', 'copy') $ffmpegArgs += @('-c:v', 'copy')
@@ -404,15 +440,16 @@ if ($NoAudio) {
'-map', '0:v' '-map', '0:v'
'-map', '1:a' '-map', '1:a'
'-t', $durationStr '-t', $durationStr
'-c:a', 'aac' '-c:a', $targetAudioCodec
'-b:a', $targetAudioBitrate
) )
} elseif ($externalAudioCodecMismatch) { } elseif ($externalAudioCodecMismatch) {
# External audio (e.g. Opus from a YouTube/DASH WebM) cannot be stream-copied into MP4. # External audio cannot be stream-copied into the target container.
$ffmpegArgs += @( $ffmpegArgs += @(
'-map', '0:v' '-map', '0:v'
'-map', '1:a' '-map', '1:a'
'-c:a', 'aac' '-c:a', $targetAudioCodec
'-b:a', '192k' '-b:a', $targetAudioBitrate
'-shortest' '-shortest'
) )
} else { } else {
@@ -431,14 +468,13 @@ 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) { # The atrim filter forces an audio re-encode; explicitly set the target
# ffmpeg will already re-encode here (no -c:a copy), but force AAC explicitly # container's audio codec so the result is always container-appropriate
# so the output is MP4-compatible regardless of source codec. # (e.g. Opus for WebM, AAC for MP4/MKV) rather than ffmpeg's default.
$ffmpegArgs += @('-c:a', 'aac', '-b:a', '192k') $ffmpegArgs += @('-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate)
}
} elseif ($audioCodecMismatch) { } elseif ($audioCodecMismatch) {
# Source audio (e.g. Opus from a YouTube/DASH WebM) cannot be stream-copied into MP4. # Source audio is not on the target container's stream-copy list.
$ffmpegArgs += @('-c:a', 'aac', '-b:a', '192k') $ffmpegArgs += @('-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate)
} else { } else {
$ffmpegArgs += @('-c:a', 'copy') $ffmpegArgs += @('-c:a', 'copy')
} }