<# .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 using ffmpeg. 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" } $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' } $vf = $null if ($inInfo.Width -eq 1920 -and $inInfo.Height -eq 1088) { $vf = 'crop=1920:1080:0:4' } elseif ($inInfo.Width -eq 1280 -and $inInfo.Height -eq 704) { $vf = 'scale=-2:720,crop=1280:720:(in_w-1280)/2:(in_h-720)/2' } elseif ($inInfo.Width -eq 1088 -and $inInfo.Height -eq 1920) { $vf = 'crop=1080:1920:4:0' } elseif ($inInfo.Width -eq 704 -and $inInfo.Height -eq 1280) { $vf = 'scale=720:-2,crop=720:1280:(in_w-720)/2:(in_h-1280)/2' } elseif ($inInfo.Width -eq 832 -and $inInfo.Height -eq 448) { $vf = 'scale=-2:480,crop=854:480:(in_w-854)/2:(in_h-480)/2' } elseif ($inInfo.Width -eq 448 -and $inInfo.Height -eq 832) { $vf = 'scale=480:-2,crop=480:854:(in_w-480)/2:(in_h-854)/2' } 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"