From 6b5a67dddd590735ce0d7e7551b4cf4e65a075c6 Mon Sep 17 00:00:00 2001 From: Claude Toupin Date: Mon, 1 Jun 2026 01:15:09 -0400 Subject: [PATCH] Add support for WebM and MKV output containers with codec-aware stream copying and transcoding --- Clone-Video.ps1 | 110 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 73 insertions(+), 37 deletions(-) diff --git a/Clone-Video.ps1 b/Clone-Video.ps1 index 29ae469..5e7aebc 100644 --- a/Clone-Video.ps1 +++ b/Clone-Video.ps1 @@ -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') }