From d9c0eb3303f21957a91246777d96beb5829e3b05 Mon Sep 17 00:00:00 2001 From: Claude Toupin Date: Sat, 30 May 2026 14:06:57 -0400 Subject: [PATCH] Add WebM/MKV support with automatic transcoding to H.264/AAC for MP4 compatibility --- Clone-Video.ps1 | 104 +++++++++++++++++++++++++++++++++++++++---- Create-PillarBox.ps1 | 32 ++++++++----- 2 files changed, 116 insertions(+), 20 deletions(-) diff --git a/Clone-Video.ps1 b/Clone-Video.ps1 index c697fc5..29ae469 100644 --- a/Clone-Video.ps1 +++ b/Clone-Video.ps1 @@ -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) } - $videoFilter = $filterParts -join ',' - $ffmpegArgs += @('-vf', $videoFilter) + 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') } diff --git a/Create-PillarBox.ps1 b/Create-PillarBox.ps1 index 664b167..69a95a0 100644 --- a/Create-PillarBox.ps1 +++ b/Create-PillarBox.ps1 @@ -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)" }