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
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'
$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
@@ -129,9 +168,9 @@ if (-not [string]::IsNullOrWhiteSpace($Audio)) {
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.
# 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) {
$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
# 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.
# 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) {
@@ -232,14 +271,11 @@ 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))
# 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)
@@ -310,15 +346,16 @@ if (-not [string]::IsNullOrWhiteSpace($Sequence)) {
$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 h264)' } else { '' }), `
$sourceVideoCodec, $(if ($videoCodecMismatch) { " (will transcode to $targetVideoCodec)" } else { '' }), `
$(if ($sourceAudioCodec) { $sourceAudioCodec } else { 'none' }), `
$(if ($audioCodecMismatch) { ' (will transcode to aac)' } else { '' }))
$(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 aac)' } else { '' }))
$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) {
@@ -353,16 +390,17 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
$ffmpegArgs += @(
'-vf', $videoFilter
'-vsync', 'vfr'
'-c:v', 'libx264'
'-pix_fmt', 'yuv420p'
'-preset', 'slow'
)
$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 = @()
@@ -376,17 +414,15 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
$videoFilter = $filterParts -join ','
$ffmpegArgs += @('-vf', $videoFilter)
}
$ffmpegArgs += @(
'-c:v', 'libx264'
'-pix_fmt', 'yuv420p'
'-preset', 'slow'
)
$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')
@@ -404,15 +440,16 @@ if ($NoAudio) {
'-map', '0:v'
'-map', '1:a'
'-t', $durationStr
'-c:a', 'aac'
'-c:a', $targetAudioCodec
'-b:a', $targetAudioBitrate
)
} 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 += @(
'-map', '0:v'
'-map', '1:a'
'-c:a', 'aac'
'-b:a', '192k'
'-c:a', $targetAudioCodec
'-b:a', $targetAudioBitrate
'-shortest'
)
} else {
@@ -431,14 +468,13 @@ 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')
}
# 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 (e.g. Opus from a YouTube/DASH WebM) cannot be stream-copied into MP4.
$ffmpegArgs += @('-c:a', 'aac', '-b:a', '192k')
# Source audio is not on the target container's stream-copy list.
$ffmpegArgs += @('-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate)
} else {
$ffmpegArgs += @('-c:a', 'copy')
}