Files
Video-Processing-Scripts/Crop-Clips.ps1
T

256 lines
10 KiB
PowerShell

<#
.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"