<# .SYNOPSIS Crops video clips using ffmpeg. .PARAMETER Source Source directory containing video files. .PARAMETER Target Target directory for cropped video files. .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. Supports -WhatIf and -Confirm for safe execution. #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory = $false)] [string]$Source = '.', [Parameter(Mandatory = $false)] [string]$Target = '.' ) 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 = 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 = 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 = 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 = 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' } ) $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' } $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 ' ')" } $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"