<# .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 from 1928x1076 or 1926x1076 to 1920x1080 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' } $isValidResolution = ($inInfo.Width -eq 1928 -and $inInfo.Height -eq 1076) -or ($inInfo.Width -eq 1926 -and $inInfo.Height -eq 1076) if (-not $isValidResolution) { 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', 'scale=-2:1080,crop=1920:1080:(in_w-1920)/2:(in_h-1080)/2' ) 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"