<# .SYNOPSIS Crops video clips using ffmpeg. .PARAMETER Source Source directory containing video files. .PARAMETER Target Target directory for cropped video files. .PARAMETER Size Size of the output video. Uses the known non-standard generator resolutions table if not specified. Possible values: 320p, 384p, 480p, 720p, 900p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height". When specified, the resolutions table is overridden and every video is scaled (preserving aspect ratio) so it fully covers the requested size, then centre-cropped to it, so videos of any resolution are processed instead of being skipped. Named presets are landscape (e.g. 1080p = 1920x1080) and are transposed for portrait sources (a 1080x1920 source with -Size 1080p stays 1080x1920); a custom "width:height" is used exactly as given. Videos already at the requested size are skipped. .DESCRIPTION Crops video clips to standard resolutions using ffmpeg. Handles the non-standard resolutions produced by LTX-2, Kling and Wan (see the $cropProfiles table), plus any square (1:1) source, which is scaled to 1080x1080. Videos with any other resolution are skipped, unless -Size is used, which overrides the table and converts every video to the requested size. Supports -WhatIf and -Confirm for safe execution. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory = $false)] [string]$Source = '.', [Parameter(Mandatory = $false)] [string]$Target = '.', [Parameter(Mandatory = $false)] [string]$Size = $null ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Get-VideoInfo { param( [Parameter(Mandatory = $true)] [string]$Path ) $json = & ffprobe -v error -print_format json -show_streams -show_format -- "$Path" if ($LASTEXITCODE -ne 0 -or -not $json) { throw "ffprobe failed for: $Path" } $info = $json | ConvertFrom-Json $videoStream = $info.streams | Where-Object { $_.codec_type -eq 'video' } | Select-Object -First 1 if (-not $videoStream) { throw "No video stream found in: $Path" } $audioStream = $info.streams | Where-Object { $_.codec_type -eq 'audio' } | Select-Object -First 1 # Safe property accessor: returns $null when the property is absent (strict-mode friendly). $get = { param($obj, $name) if ($obj -and ($obj.PSObject.Properties.Name -contains $name)) { $obj.$name } else { $null } } $rFrameRate = & $get $videoStream 'r_frame_rate' $fps = $null if ($rFrameRate -and $rFrameRate -match '^(\d+)/(\d+)$') { $num = [double]$Matches[1] $den = [double]$Matches[2] if ($den -ne 0) { $fps = $num / $den } } $videoBitRate = & $get $videoStream 'bit_rate' $formatBitRate = & $get $info.format 'bit_rate' $bitRate = if ($videoBitRate) { $videoBitRate } else { $formatBitRate } [pscustomobject]@{ Path = $Path FileName = [System.IO.Path]::GetFileName($Path) Width = [int]$videoStream.width Height = [int]$videoStream.height Fps = $fps BitRate = $bitRate HasAudio = [bool]$audioStream VideoBitRate = $videoBitRate } } function Format-Quality { param( [Parameter(Mandatory = $false)] $BitRate ) if (-not $BitRate -or $BitRate -notmatch '^\d+$') { return 'unknown' } $kbps = [math]::Round(([double]$BitRate) / 1000.0) return "${kbps}kbps" } # Known non-standard generator resolutions and the ffmpeg filter that brings each one # back to a standard size. Resolutions are unique across all generators, so a single # lookup is unambiguous. Any resolution not listed here is skipped, except square # sources, which are handled by a generic rule in the loop below. $cropProfiles = @( # LTX-2 landscape @{ Width = 1920; Height = 1088; Filter = 'crop=1920:1080:0:4' } @{ Width = 1280; Height = 704; Filter = 'scale=-2:720,crop=1280:720:(in_w-1280)/2:(in_h-720)/2' } @{ Width = 1312; Height = 704; Filter = 'scale=-2:720,crop=1280:720:(in_w-1280)/2:(in_h-720)/2' } @{ Width = 1600; Height = 896; Filter = 'scale=-2:900,crop=1600:900:(in_w-1600)/2:(in_h-900)/2' } @{ Width = 832; Height = 448; Filter = 'scale=-2:480,crop=854:480:(in_w-854)/2:(in_h-480)/2' } @{ Width = 640; Height = 384; Filter = 'scale=684:-2,crop=684:384:(in_w-684)/2:(in_h-384)/2' } @{ Width = 576; Height = 320; Filter = 'crop=570:320:3:0' } @{ Width = 3744; Height = 2112; Filter = 'scale=3840:-2,crop=3840:2160:0:(in_h-2160)/2' } @{ Width = 2496; Height = 1408; Filter = 'scale=2560:-2,crop=2560:1440:0:(in_h-1440)/2' } @{ Width = 2160; Height = 1216; Filter = 'scale=1920:-2,crop=1920:1080:0:(in_h-1080)/2' } @{ Width = 1920; Height = 1056; Filter = 'scale=-2:1080,crop=1920:1080:(in_w-1920)/2:(in_h-1080)/2' } @{ Width = 1872; Height = 1056; Filter = 'scale=1920:-2,crop=1920:1080:0:(in_h-1080)/2' } @{ Width = 1248; Height = 704; Filter = 'scale=1280:-2,crop=1280:720:0:(in_h-720)/2' } # LTX-2 portrait @{ Width = 1088; Height = 1920; Filter = 'crop=1080:1920:4:0' } @{ Width = 704; Height = 1280; Filter = 'scale=720:-2,crop=720:1280:(in_w-720)/2:(in_h-1280)/2' } @{ Width = 704; Height = 1312; Filter = 'scale=720:-2,crop=720:1280:(in_w-720)/2:(in_h-1280)/2' } @{ Width = 896; Height = 1600; Filter = 'scale=900:-2,crop=900:1600:(in_w-900)/2:(in_h-1600)/2' } @{ Width = 448; Height = 832; Filter = 'scale=480:-2,crop=480:854:(in_w-480)/2:(in_h-854)/2' } @{ Width = 384; Height = 640; Filter = 'scale=-2:684,crop=384:684:(in_w-384)/2:(in_h-684)/2' } @{ Width = 320; Height = 576; Filter = 'crop=320:570:0:3' } @{ Width = 2112; Height = 3744; Filter = 'scale=-2:3840,crop=2160:3840:(in_w-2160)/2:0' } @{ Width = 1408; Height = 2496; Filter = 'scale=-2:2560,crop=1440:2560:(in_w-1440)/2:0' } @{ Width = 1216; Height = 2160; Filter = 'scale=-2:1920,crop=1080:1920:(in_w-1080)/2:0' } @{ Width = 1056; Height = 1920; Filter = 'scale=1080:-2,crop=1080:1920:(in_w-1080)/2:(in_h-1920)/2' } @{ Width = 1056; Height = 1872; Filter = 'scale=-2:1920,crop=1080:1920:(in_w-1080)/2:0' } @{ Width = 704; Height = 1248; Filter = 'scale=-2:1280,crop=720:1280:(in_w-720)/2:0' } # Kling @{ Width = 1920; Height = 1072; Filter = 'scale=-2:1080,crop=1920:1080:(in_w-1920)/2:(in_h-1080)/2' } @{ Width = 1928; Height = 1072; Filter = 'scale=-2:1080,crop=1920:1080:(in_w-1920)/2:(in_h-1080)/2' } @{ Width = 1920; Height = 1076; Filter = 'scale=-2:1080,crop=1920:1080:(in_w-1920)/2:(in_h-1080)/2' } # 1916x1080 is already 1080 tall: pad the missing 4 columns instead of rescaling. @{ Width = 1916; Height = 1080; Filter = 'pad=1920:1082:2:1:black,crop=1920:1080:0:1' } # Wan @{ Width = 1928; Height = 1076; Filter = 'scale=-2:1080,crop=1920:1080:(in_w-1920)/2:(in_h-1080)/2' } @{ Width = 1926; Height = 1076; Filter = 'scale=-2:1080,crop=1920:1080:(in_w-1920)/2:(in_h-1080)/2' } @{ Width = 1284; Height = 716; Filter = 'scale=-2:720,crop=1280:720:(in_w-1280)/2:(in_h-720)/2' } ) # --- Optional -Size: overrides the $cropProfiles lookup for every video --- # Named presets are stored in landscape form and transposed for portrait sources, so a 9:16 # clip stays 9:16. A custom 'width:height' is always used exactly as given. Parsing happens # once here so an invalid value fails before any video is touched. $sizePresets = @{ '320p' = @{ Width = 570; Height = 320 } '384p' = @{ Width = 684; Height = 384 } '480p' = @{ Width = 854; Height = 480 } '720p' = @{ Width = 1280; Height = 720 } '900p' = @{ Width = 1600; Height = 900 } '1080p' = @{ Width = 1920; Height = 1080 } 'HD' = @{ Width = 1920; Height = 1080 } '1440p' = @{ Width = 2560; Height = 1440 } '2K' = @{ Width = 2560; Height = 1440 } '2160p' = @{ Width = 3840; Height = 2160 } '4K' = @{ Width = 3840; Height = 2160 } } $sizePreset = $null $sizeExact = $null if (-not [string]::IsNullOrWhiteSpace($Size)) { if ($sizePresets.ContainsKey($Size)) { $sizePreset = $sizePresets[$Size] } elseif ($Size -match '^(\d+):(\d+)$') { $sizeExact = @{ Width = [int]$Matches[1]; Height = [int]$Matches[2] } } else { throw "Invalid -Size value: $Size. Possible values: 320p, 384p, 480p, 720p, 900p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format ""width:height""." } } $sourceFull = (Resolve-Path -LiteralPath $Source).Path $targetFull = (Resolve-Path -LiteralPath $Target).Path $BackupRequired = ($sourceFull.TrimEnd('\') -ieq $targetFull.TrimEnd('\')) $processedCount = 0 $videos = Get-ChildItem -LiteralPath $sourceFull -Filter '*.mp4' -File foreach ($video in $videos) { if ($video.Name -like '*.bak.mp4' -or $video.Name -like '*.skip.mp4') { continue } $inputPath = $video.FullName $inInfo = $null try { $inInfo = Get-VideoInfo -Path $inputPath } catch { Write-Error $_ throw } $inRes = "{0}x{1}" -f $inInfo.Width, $inInfo.Height $inFpsText = if ($inInfo.Fps) { "{0:0.###}" -f $inInfo.Fps } else { 'unknown' } $inQuality = Format-Quality -BitRate $inInfo.BitRate $inAudioText = if ($inInfo.HasAudio) { 'yes' } else { 'no' } if ($sizePreset -or $sizeExact) { # -Size given: ignore the resolution table and fit every video to the requested size by # cover-scaling (force_original_aspect_ratio=increase keeps the pixel aspect ratio and # guarantees full coverage) then centre-cropping to the exact size. if ($sizeExact) { $outW = $sizeExact.Width $outH = $sizeExact.Height } elseif ($inInfo.Height -gt $inInfo.Width) { # Portrait source: use the portrait form of the preset. $outW = $sizePreset.Height $outH = $sizePreset.Width } else { $outW = $sizePreset.Width $outH = $sizePreset.Height } # libx264/yuv420p requires even dimensions. if ($outW % 2) { $outW++ } if ($outH % 2) { $outH++ } if ($inInfo.Width -eq $outW -and $inInfo.Height -eq $outH) { Write-Host "Skipping: $($inInfo.FileName) (already ${outW}x${outH})" continue } $vf = 'scale={0}:{1}:force_original_aspect_ratio=increase,crop={0}:{1}' -f $outW, $outH } else { $cropProfile = $cropProfiles | Where-Object { $_.Width -eq $inInfo.Width -and $_.Height -eq $inInfo.Height } | Select-Object -First 1 if ($cropProfile) { $vf = $cropProfile.Filter } elseif ($inInfo.Width -eq $inInfo.Height -and $inInfo.Width -ne 1080) { # Any square (1:1) source other than 1080x1080 is scaled to 1080x1080; no crop needed. $vf = 'scale=1080:1080' } else { $vf = $null } if (-not $vf) { Write-Host "Skipping: $($inInfo.FileName) (resolution $inRes)" continue } } Write-Host "Input : Filename=$($inInfo.FileName) Resolution=$inRes FPS=$inFpsText Quality=$inQuality Audio=$inAudioText" if ($BackupRequired) { $backupPath = Join-Path $video.DirectoryName ($video.BaseName + '.bak' + $video.Extension) if ($PSCmdlet.ShouldProcess($backupPath, 'Create backup of original video')) { Copy-Item -LiteralPath $inputPath -Destination $backupPath -Force } } $outCropPath = Join-Path $targetFull ($video.BaseName + '.crop.mp4') $outFinalPath = Join-Path $targetFull $video.Name $ffArgs = @( '-y', '-i', $inputPath, '-vf', $vf ) if ($inInfo.HasAudio) { $ffArgs += @('-c:a', 'copy') } else { $ffArgs += @('-an') } if ($inInfo.VideoBitRate -and $inInfo.VideoBitRate -match '^\d+$') { $ffArgs += @('-b:v', $inInfo.VideoBitRate) } else { $ffArgs += @('-crf', '8', '-preset', 'slow') } $ffArgs += @($outCropPath) if ($PSCmdlet.ShouldProcess($outCropPath, 'Crop video with ffmpeg')) { $ffmpegOutput = & ffmpeg @ffArgs 2>&1 $ffmpegExit = $LASTEXITCODE if ($ffmpegExit -ne 0) { if (Test-Path -LiteralPath $outCropPath) { Remove-Item -LiteralPath $outCropPath -Force -ErrorAction SilentlyContinue } $ffmpegOutput | ForEach-Object { Write-Error $_ } Write-Error "ffmpeg failed while cropping: $($inInfo.FileName)" Write-Host "ffmpeg $($ffArgs -join ' ')" exit 1 } if (($BackupRequired) -and ($PSCmdlet.ShouldProcess($inputPath, 'Remove original video after backup'))) { Remove-Item -LiteralPath $inputPath -Force } if ((Test-Path -LiteralPath $outFinalPath) -and ($PSCmdlet.ShouldProcess($outFinalPath, 'Remove existing output file'))) { Remove-Item -LiteralPath $outFinalPath -Force } if ($PSCmdlet.ShouldProcess("$outCropPath -> $outFinalPath", 'Move cropped video to final location')) { Move-Item -LiteralPath $outCropPath -Destination $outFinalPath -Force } } else { $formattedArgs = $ffArgs | ForEach-Object { if ($_ -match '\s') { "`"$_`"" } else { $_ } } Write-Host "WhatIf: Would crop video with ffmpeg $($formattedArgs -join ' ')" } # Under -WhatIf (or -Confirm declined) nothing was written, so there is no output file to # probe; reporting is skipped rather than failing on the missing file. if (Test-Path -LiteralPath $outFinalPath) { $outInfo = Get-VideoInfo -Path $outFinalPath $outRes = "{0}x{1}" -f $outInfo.Width, $outInfo.Height $outFpsText = if ($outInfo.Fps) { "{0:0.###}" -f $outInfo.Fps } else { 'unknown' } $outQuality = Format-Quality -BitRate $outInfo.BitRate $outAudioText = if ($outInfo.HasAudio) { 'yes' } else { 'no' } Write-Host "Output: Filename=$($outInfo.FileName) Resolution=$outRes FPS=$outFpsText Quality=$outQuality Audio=$outAudioText" } $processedCount++ } Write-Host "Processed (cropped) videos: $processedCount"