260 lines
10 KiB
PowerShell
260 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 = 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' }
|
|
)
|
|
|
|
$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"
|
|
|