Inital push
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Clone-Video.ps1" %*
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Clones (copies) a video file using ffmpeg.
|
||||
When Sequence is provided and EndFrame is -1:
|
||||
- First, the last frame of the video is saved as a PNG image (using the Sequence value as filename).
|
||||
- After, the video is cloned (copied) from StartFrame to the second-to-last frame (excluding the last frame).
|
||||
When Sequence is provided and EndFrame is not -1:
|
||||
- First, the frame at EndFrame index is saved as a PNG image (using the Sequence value as filename).
|
||||
- After, the video is cloned (copied) from StartFrame to EndFrame-1 (excluding the extracted frame).
|
||||
.PARAMETER Source
|
||||
Path to the input video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided.
|
||||
.PARAMETER Target
|
||||
Path to the output video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided.
|
||||
.PARAMETER StartFrame
|
||||
0-based frame index to start cloning from.
|
||||
.PARAMETER EndFrame
|
||||
0-based frame index to end cloning at. Use -1 to clone to the end (default).
|
||||
.PARAMETER Sequence
|
||||
Path to PNG file for extracted frame. Can be absolute or relative to current directory. .png extension is added if not provided. If EndFrame is -1, extracts the last frame and excludes it from the cloned video. If EndFrame is not -1, extracts the frame at EndFrame index and excludes it from the cloned video.
|
||||
.PARAMETER Size
|
||||
Size of the output video. Uses the input video's size if not specified.
|
||||
Possible values: 480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height".
|
||||
.PARAMETER FPS
|
||||
Frames per second for the output video. Uses the input video's FPS if not specified.
|
||||
.PARAMETER NoAudio
|
||||
When present, excludes audio from the cloned video.
|
||||
.PARAMETER Audio
|
||||
Path to an alternate audio file to use for the target video. Can be absolute or relative to current directory. When specified, this audio replaces the source video's audio.
|
||||
.PARAMETER CRF
|
||||
Constant Rate Factor for video encoding (0-51, lower is better quality). When specified, overrides both the video bitrate and the default -crf 8 value. Only used when re-encoding is required.
|
||||
.PARAMETER Metadata
|
||||
When present, copies available JSON metadata from the source video file to the target video file.
|
||||
.DESCRIPTION
|
||||
This script uses ffmpeg to clone (copy) a video file with optional frame range and audio exclusion.
|
||||
When Sequence is provided, it extracts a specific frame to PNG and then clones the video accordingly.
|
||||
Supports -WhatIf and -Confirm for safe execution.
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Source,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Target,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$StartFrame = 0,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$EndFrame = -1,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Sequence = $null,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Size = $null,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[double]$FPS = 0,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$NoAudio,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Audio = $null,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$CRF = -1,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$Metadata
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$SourcePath = $Source
|
||||
|
||||
if (-not $SourcePath.EndsWith('.mp4', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$SourcePath += '.mp4'
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SourcePath)) {
|
||||
Write-Error ('Source video file not found: {0}' -f $SourcePath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$TargetPath = $Target
|
||||
|
||||
if (-not $TargetPath.EndsWith('.mp4', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$TargetPath += '.mp4'
|
||||
}
|
||||
|
||||
$TargetDir = Split-Path -Parent $TargetPath
|
||||
if ($TargetDir -and -not (Test-Path -LiteralPath $TargetDir)) {
|
||||
Write-Error ('Target directory does not exist: {0}' -f $TargetDir)
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($NoAudio -and -not [string]::IsNullOrWhiteSpace($Audio)) {
|
||||
Write-Error 'Cannot specify both -NoAudio and -Audio parameters'
|
||||
exit 1
|
||||
}
|
||||
|
||||
$AudioPath = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($Audio)) {
|
||||
$AudioPath = $Audio
|
||||
if (-not (Test-Path -LiteralPath $AudioPath)) {
|
||||
Write-Error ('Audio file not found: {0}' -f $AudioPath)
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$sizeMap = @{
|
||||
'480p' = @{ Width = 854; Height = 480 }
|
||||
'720p' = @{ Width = 1280; Height = 720 }
|
||||
'1080p' = @{ Width = 1920; Height = 1080 }
|
||||
'HD' = @{ Width = 1920; Height = 1080 }
|
||||
'1440p' = @{ Width = 2560; Height = 1440 }
|
||||
'2K' = @{ Width = 2560; Height = 1440 }
|
||||
'2160p' = @{ Width = 3840; Height = 2160 }
|
||||
'4K' = @{ Width = 3840; Height = 2160 }
|
||||
}
|
||||
|
||||
$scaleWidth = 0
|
||||
$scaleHeight = 0
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($Size)) {
|
||||
if ($sizeMap.ContainsKey($Size)) {
|
||||
$scaleWidth = $sizeMap[$Size].Width
|
||||
$scaleHeight = $sizeMap[$Size].Height
|
||||
} elseif ($Size -match '^(\d+):(\d+)$') {
|
||||
$scaleWidth = [int]$Matches[1]
|
||||
$scaleHeight = [int]$Matches[2]
|
||||
} else {
|
||||
Write-Error ('Invalid Size value: {0}. Possible values: 480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height".' -f $Size)
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$ffprobeFpsOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
|
||||
$ffprobeExit = $LASTEXITCODE
|
||||
|
||||
if ($ffprobeExit -ne 0) {
|
||||
$ffprobeFpsOutput | ForEach-Object { Write-Error $_ }
|
||||
Write-Error ('ffprobe failed to read frame rate from: {0}' -f $SourcePath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$fpsRatio = ($ffprobeFpsOutput | Select-Object -First 1).Trim()
|
||||
|
||||
if (-not $fpsRatio -or $fpsRatio -notmatch '^\d+/\d+$') {
|
||||
Write-Error ('Could not determine frame rate for: {0}' -f $SourcePath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$fpsNumerator = [double]($fpsRatio -split '/')[0]
|
||||
$fpsDenominator = [double]($fpsRatio -split '/')[1]
|
||||
$videoFPS = $fpsNumerator / $fpsDenominator
|
||||
|
||||
$ffprobeFramesOutput = & ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
|
||||
$ffprobeExit = $LASTEXITCODE
|
||||
|
||||
if ($ffprobeExit -ne 0) {
|
||||
$ffprobeFramesOutput | ForEach-Object { Write-Error $_ }
|
||||
Write-Error ('ffprobe failed to read frame count from: {0}' -f $SourcePath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$totalFrames = ($ffprobeFramesOutput | Select-Object -First 1).Trim()
|
||||
|
||||
if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') {
|
||||
Write-Error ('Could not determine frame count for: {0}' -f $SourcePath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$totalFramesInt = [int]$totalFrames
|
||||
$maxFrameIndex = $totalFramesInt - 1
|
||||
|
||||
$ffprobeBitrateOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
|
||||
$ffprobeExit = $LASTEXITCODE
|
||||
|
||||
$videoBitrate = $null
|
||||
if ($ffprobeExit -eq 0) {
|
||||
$bitrateValue = ($ffprobeBitrateOutput | Select-Object -First 1).Trim()
|
||||
if ($bitrateValue -and $bitrateValue -match '^\d+$') {
|
||||
$videoBitrate = $bitrateValue
|
||||
}
|
||||
}
|
||||
|
||||
if ($StartFrame -lt 0 -or $StartFrame -gt $maxFrameIndex) {
|
||||
Write-Error ('StartFrame {0} is out of range. Valid range: 0 to {1}' -f $StartFrame, $maxFrameIndex)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$actualEndFrame = if ($EndFrame -eq -1) {
|
||||
$maxFrameIndex
|
||||
} else {
|
||||
if ($EndFrame -lt $StartFrame -or $EndFrame -gt $maxFrameIndex) {
|
||||
Write-Error ('EndFrame {0} is out of range. Valid range: {1} to {2}' -f $EndFrame, $StartFrame, $maxFrameIndex)
|
||||
exit 1
|
||||
}
|
||||
$EndFrame
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($Sequence)) {
|
||||
$SequencePath = $Sequence
|
||||
|
||||
if (-not $SequencePath.EndsWith('.png', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$SequencePath += '.png'
|
||||
}
|
||||
|
||||
$SequenceDir = Split-Path -Parent $SequencePath
|
||||
if ($SequenceDir -and -not (Test-Path -LiteralPath $SequenceDir)) {
|
||||
Write-Error ('Sequence output directory does not exist: {0}' -f $SequenceDir)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$frameToExtract = $actualEndFrame
|
||||
|
||||
$videoFilter = 'select=eq(n\,{0})' -f $frameToExtract
|
||||
|
||||
$ffmpegArgsFrame = @(
|
||||
'-y'
|
||||
'-i', $SourcePath
|
||||
'-vf', $videoFilter
|
||||
'-vsync', 'vfr'
|
||||
'-frames:v', '1'
|
||||
'-q:v', '1'
|
||||
'--', $SequencePath
|
||||
)
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($SequencePath, 'Extract frame to PNG')) {
|
||||
Write-Host ('Extracting frame {0} to: {1}' -f $frameToExtract, $SequencePath)
|
||||
|
||||
$ffmpegOutput = & ffmpeg @ffmpegArgsFrame 2>&1
|
||||
$ffmpegExit = $LASTEXITCODE
|
||||
|
||||
if ($ffmpegExit -ne 0) {
|
||||
$ffmpegOutput | ForEach-Object { Write-Error $_ }
|
||||
Write-Error ('ffmpeg failed to extract frame')
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ('Frame extracted successfully')
|
||||
}
|
||||
|
||||
if ($EndFrame -eq -1) {
|
||||
$actualEndFrame = $maxFrameIndex - 1
|
||||
Write-Host ('Adjusting EndFrame to {0} to exclude the extracted last frame from cloning' -f $actualEndFrame)
|
||||
} else {
|
||||
$actualEndFrame = $EndFrame - 1
|
||||
Write-Host ('Adjusting EndFrame to {0} to exclude the extracted frame from cloning' -f $actualEndFrame)
|
||||
}
|
||||
}
|
||||
|
||||
$frameCount = $actualEndFrame - $StartFrame + 1
|
||||
|
||||
Write-Host ('Cloning video: {0}' -f $Source)
|
||||
Write-Host (' Frame range: {0} to {1} ({2} frames)' -f $StartFrame, $actualEndFrame, $frameCount)
|
||||
if ($scaleWidth -gt 0) {
|
||||
Write-Host (' Size: {0}x{1}' -f $scaleWidth, $scaleHeight)
|
||||
}
|
||||
if ($FPS -gt 0) {
|
||||
Write-Host (' FPS: {0}' -f $FPS)
|
||||
}
|
||||
Write-Host (' Target: {0}' -f $TargetPath)
|
||||
|
||||
$ffmpegArgs = @(
|
||||
'-y'
|
||||
'-i', $SourcePath
|
||||
)
|
||||
|
||||
if ($AudioPath) {
|
||||
$ffmpegArgs += @('-i', $AudioPath)
|
||||
}
|
||||
|
||||
$needsReencode = $scaleWidth -gt 0 -or $FPS -gt 0
|
||||
|
||||
if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
|
||||
$filterParts = @('select=between(n\,{0}\,{1})' -f $StartFrame, $actualEndFrame)
|
||||
$filterParts += 'setpts=PTS-STARTPTS'
|
||||
if ($scaleWidth -gt 0) {
|
||||
$filterParts += ('scale={0}:{1}' -f $scaleWidth, $scaleHeight)
|
||||
}
|
||||
if ($FPS -gt 0) {
|
||||
$filterParts += ('fps={0}' -f $FPS)
|
||||
}
|
||||
$videoFilter = $filterParts -join ','
|
||||
$ffmpegArgs += @(
|
||||
'-vf', $videoFilter
|
||||
'-vsync', 'vfr'
|
||||
)
|
||||
if ($CRF -ge 0) {
|
||||
$ffmpegArgs += @('-crf', $CRF.ToString())
|
||||
} elseif ($videoBitrate) {
|
||||
$ffmpegArgs += @('-b:v', $videoBitrate)
|
||||
} else {
|
||||
$ffmpegArgs += @('-crf', '14')
|
||||
}
|
||||
} elseif ($needsReencode) {
|
||||
$filterParts = @()
|
||||
if ($scaleWidth -gt 0) {
|
||||
$filterParts += ('scale={0}:{1}' -f $scaleWidth, $scaleHeight)
|
||||
}
|
||||
if ($FPS -gt 0) {
|
||||
$filterParts += ('fps={0}' -f $FPS)
|
||||
}
|
||||
$videoFilter = $filterParts -join ','
|
||||
$ffmpegArgs += @('-vf', $videoFilter)
|
||||
if ($CRF -ge 0) {
|
||||
$ffmpegArgs += @('-crf', $CRF.ToString())
|
||||
} elseif ($videoBitrate) {
|
||||
$ffmpegArgs += @('-b:v', $videoBitrate)
|
||||
} else {
|
||||
$ffmpegArgs += @('-crf', '14')
|
||||
}
|
||||
} else {
|
||||
$ffmpegArgs += @('-c:v', 'copy')
|
||||
}
|
||||
|
||||
if ($NoAudio) {
|
||||
$ffmpegArgs += @('-an')
|
||||
} elseif ($AudioPath) {
|
||||
if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
|
||||
$startTime = $StartFrame / $videoFPS
|
||||
$endTime = ($actualEndFrame + 1) / $videoFPS
|
||||
$duration = $endTime - $startTime
|
||||
$durationStr = $duration.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
$ffmpegArgs += @(
|
||||
'-map', '0:v'
|
||||
'-map', '1:a'
|
||||
'-t', $durationStr
|
||||
'-c:a', 'aac'
|
||||
)
|
||||
} else {
|
||||
$ffmpegArgs += @(
|
||||
'-map', '0:v'
|
||||
'-map', '1:a'
|
||||
'-c:a', 'copy'
|
||||
'-shortest'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
|
||||
$startTime = $StartFrame / $videoFPS
|
||||
$endTime = ($actualEndFrame + 1) / $videoFPS
|
||||
$startTimeStr = $startTime.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
$endTimeStr = $endTime.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
$audioFilter = "atrim=start=${startTimeStr}:end=${endTimeStr},asetpts=PTS-STARTPTS"
|
||||
$ffmpegArgs += @('-af', $audioFilter)
|
||||
} else {
|
||||
$ffmpegArgs += @('-c:a', 'copy')
|
||||
}
|
||||
}
|
||||
|
||||
if ($Metadata) {
|
||||
$ffmpegArgs += @('-map_metadata', '0')
|
||||
} else {
|
||||
$ffmpegArgs += @('-map_metadata', '-1')
|
||||
}
|
||||
|
||||
$ffmpegArgs += @('--', $TargetPath)
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($TargetPath, 'Clone video file')) {
|
||||
$ffmpegOutput = & ffmpeg @ffmpegArgs 2>&1
|
||||
$ffmpegExit = $LASTEXITCODE
|
||||
|
||||
if ($ffmpegExit -ne 0) {
|
||||
Write-Host 'ffmpeg error output:' -ForegroundColor Red
|
||||
$ffmpegOutput | ForEach-Object { Write-Host $_ }
|
||||
Write-Error ('ffmpeg failed to clone video with exit code: {0}' -f $ffmpegExit)
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ('Video cloned successfully to: {0}' -f $TargetPath)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Compare-Videos.ps1" %*
|
||||
@@ -0,0 +1,287 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates side-by-side or grid comparison videos with automatic scaling and padding to preserve aspect ratios.
|
||||
|
||||
.DESCRIPTION
|
||||
Combines 2 to 4 video files into a single comparison video using ffmpeg.
|
||||
By default, creates 1920x1080 (1K HD) output. Use -In4K for 3840x2160 (4K UHD) output.
|
||||
Each source video is scaled to fit its cell without stretching, with black letterbox/pillarbox
|
||||
padding applied as needed to preserve aspect ratios.
|
||||
|
||||
The script detects the orientation of S1 (landscape or portrait) and applies appropriate scaling:
|
||||
|
||||
Landscape mode (width > height) - 1K HD:
|
||||
- 2 videos: Side-by-side (960x1080 each)
|
||||
- 3 videos: 2x2 grid with S3 centered on bottom row (960x540 each)
|
||||
- 4 videos: 2x2 grid (960x540 each)
|
||||
|
||||
Landscape mode (width > height) - 4K UHD:
|
||||
- 2 videos: Side-by-side (1920x2160 each)
|
||||
- 3 videos: 2x2 grid with S3 centered on bottom row (1920x1080 each)
|
||||
- 4 videos: 2x2 grid (1920x1080 each)
|
||||
|
||||
Portrait mode (height > width, e.g., 1080x1920) - 1K HD:
|
||||
- 2 videos: Side-by-side (608x1080 each, centered with padding to 1920x1080)
|
||||
- 3 videos: Side-by-side (608x1080 each, centered with padding to 1920x1080)
|
||||
- 4 videos: Side-by-side (480x854 each, centered with padding to 1920x1080)
|
||||
|
||||
Portrait mode (height > width, e.g., 1080x1920) - 4K UHD:
|
||||
- 2 videos: Side-by-side (1216x2160 each, centered with padding to 3840x2160)
|
||||
- 3 videos: Side-by-side (1216x2160 each, centered with padding to 3840x2160)
|
||||
- 4 videos: Side-by-side (960x1708 each, centered with padding to 3840x2160)
|
||||
|
||||
Requires ffmpeg and ffprobe in system PATH. Supports -WhatIf for dry-run testing.
|
||||
|
||||
.PARAMETER S1
|
||||
Path to source video 1 (required).
|
||||
|
||||
.PARAMETER S2
|
||||
Path to source video 2 (required).
|
||||
|
||||
.PARAMETER S3
|
||||
Path to source video 3 (optional).
|
||||
|
||||
.PARAMETER S4
|
||||
Path to source video 4 (optional).
|
||||
|
||||
.PARAMETER Output
|
||||
Path to the output video file (required).
|
||||
|
||||
.PARAMETER NoAudio
|
||||
Exclude audio from output. By default, audio is copied from S1.
|
||||
|
||||
.PARAMETER In4K
|
||||
Generate comparison video in 4K UHD (3840x2160) resolution instead of 1K HD (1920x1080).
|
||||
|
||||
.EXAMPLE
|
||||
.\Compare-Videos.ps1 -S1 original.mp4 -S2 enhanced.mp4 -Output comparison.mp4
|
||||
|
||||
Compare two videos side-by-side.
|
||||
|
||||
.EXAMPLE
|
||||
.\Compare-Videos.ps1 -S1 version1.mp4 -S2 version2.mp4 -S3 version3.mp4 -Output comparison.mp4
|
||||
|
||||
Compare three videos.
|
||||
|
||||
.EXAMPLE
|
||||
.\Compare-Videos.ps1 -S1 a.mp4 -S2 b.mp4 -S3 c.mp4 -S4 d.mp4 -Output grid.mp4
|
||||
|
||||
Compare four videos in a 2x2 grid.
|
||||
|
||||
.EXAMPLE
|
||||
.\Compare-Videos.ps1 -S1 video1.mp4 -S2 video2.mp4 -Output comparison.mp4 -NoAudio
|
||||
|
||||
Compare videos without including audio in the output.
|
||||
|
||||
.EXAMPLE
|
||||
.\Compare-Videos.ps1 -S1 original.mp4 -S2 enhanced.mp4 -Output comparison_4k.mp4 -In4K
|
||||
|
||||
Compare two videos in 4K UHD (3840x2160) resolution.
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$S1,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$S2,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$S3,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$S4,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Output,
|
||||
|
||||
[switch]$NoAudio,
|
||||
|
||||
[switch]$In4K
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Test-VideoExists {
|
||||
param([string]$Path, [string]$Name)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
Write-Error "Source video $Name does not exist: $Path"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Test-VideoExists -Path $S1 -Name "S1"
|
||||
Test-VideoExists -Path $S2 -Name "S2"
|
||||
|
||||
if ($S3) {
|
||||
Test-VideoExists -Path $S3 -Name "S3"
|
||||
}
|
||||
|
||||
if ($S4) {
|
||||
Test-VideoExists -Path $S4 -Name "S4"
|
||||
}
|
||||
|
||||
$outputDir = Split-Path -Path $Output -Parent
|
||||
if ($outputDir -and -not (Test-Path -LiteralPath $outputDir -PathType Container)) {
|
||||
Write-Error "Output directory does not exist: $outputDir"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$videoCount = 2
|
||||
if ($S4) { $videoCount = 4 }
|
||||
elseif ($S3) { $videoCount = 3 }
|
||||
|
||||
# --- Detect orientation of S1 ---
|
||||
|
||||
Write-Host "Detecting orientation of S1..." -ForegroundColor Cyan
|
||||
$probeOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 $S1 2>&1
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Failed to probe video dimensions for S1: $probeOutput"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$dimensions = $probeOutput -split ','
|
||||
$width = [int]$dimensions[0]
|
||||
$height = [int]$dimensions[1]
|
||||
|
||||
$isPortrait = $height -gt $width
|
||||
|
||||
# Set resolution based on -In4K switch
|
||||
if ($In4K) {
|
||||
$outputWidth = 3840
|
||||
$outputHeight = 2160
|
||||
$resolutionLabel = "4K UHD"
|
||||
} else {
|
||||
$outputWidth = 1920
|
||||
$outputHeight = 1080
|
||||
$resolutionLabel = "1K HD"
|
||||
}
|
||||
|
||||
if ($isPortrait) {
|
||||
Write-Host "Portrait mode detected (${width}x${height}) - Output: $resolutionLabel" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Landscape mode detected (${width}x${height}) - Output: $resolutionLabel" -ForegroundColor Green
|
||||
}
|
||||
|
||||
$filterComplex = ""
|
||||
$inputArgs = @("-i", $S1, "-i", $S2)
|
||||
|
||||
if ($isPortrait) {
|
||||
# Portrait mode scaling - calculate cell dimensions based on output resolution
|
||||
$cellWidth2 = [int]($outputWidth * 0.6333 / 2) # ~608 for 1K, ~1216 for 4K
|
||||
$cellWidth3 = [int]($outputWidth * 0.6333 / 2) # ~608 for 1K, ~1216 for 4K
|
||||
$cellWidth4 = [int]($outputWidth / 4) # 480 for 1K, 960 for 4K
|
||||
$cellHeight4 = [int]($outputHeight * 0.79) # ~854 for 1K, ~1708 for 4K
|
||||
|
||||
switch ($videoCount) {
|
||||
2 {
|
||||
# 2 videos side-by-side, centered
|
||||
$filterComplex = "[0:v]scale=${cellWidth2}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth2}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
||||
"[1:v]scale=${cellWidth2}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth2}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
||||
"[v0][v1]hstack=inputs=2[stacked];" +
|
||||
"[stacked]pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[outv]"
|
||||
}
|
||||
3 {
|
||||
# 3 videos side-by-side, centered
|
||||
$inputArgs += @("-i", $S3)
|
||||
$filterComplex = "[0:v]scale=${cellWidth3}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth3}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
||||
"[1:v]scale=${cellWidth3}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth3}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
||||
"[2:v]scale=${cellWidth3}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth3}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v2];" +
|
||||
"[v0][v1][v2]hstack=inputs=3[stacked];" +
|
||||
"[stacked]pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[outv]"
|
||||
}
|
||||
4 {
|
||||
# 4 videos side-by-side, centered
|
||||
$inputArgs += @("-i", $S3, "-i", $S4)
|
||||
$filterComplex = "[0:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
||||
"[1:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
||||
"[2:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v2];" +
|
||||
"[3:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v3];" +
|
||||
"[v0][v1][v2][v3]hstack=inputs=4[stacked];" +
|
||||
"[stacked]pad=${outputWidth}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[outv]"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# Landscape mode scaling - calculate cell dimensions based on output resolution
|
||||
$cellWidth2 = [int]($outputWidth / 2) # 960 for 1K, 1920 for 4K
|
||||
$cellWidth4 = [int]($outputWidth / 2) # 960 for 1K, 1920 for 4K
|
||||
$cellHeight4 = [int]($outputHeight / 2) # 540 for 1K, 1080 for 4K
|
||||
$padX = [int]($outputWidth / 4) # 480 for 1K, 960 for 4K
|
||||
|
||||
switch ($videoCount) {
|
||||
2 {
|
||||
# 2 videos side-by-side
|
||||
$filterComplex = "[0:v]scale=${cellWidth2}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth2}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
||||
"[1:v]scale=${cellWidth2}:${outputHeight}:force_original_aspect_ratio=decrease,pad=${cellWidth2}:${outputHeight}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
||||
"[v0][v1]hstack=inputs=2[outv]"
|
||||
}
|
||||
3 {
|
||||
# 2x2 grid with S3 centered on bottom row
|
||||
$inputArgs += @("-i", $S3)
|
||||
$filterComplex = "[0:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
||||
"[1:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
||||
"[2:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v2];" +
|
||||
"[v0][v1]hstack=inputs=2[top];" +
|
||||
"[v2]pad=${outputWidth}:${cellHeight4}:${padX}:0:black[bottom];" +
|
||||
"[top][bottom]vstack=inputs=2[outv]"
|
||||
}
|
||||
4 {
|
||||
# 2x2 grid
|
||||
$inputArgs += @("-i", $S3, "-i", $S4)
|
||||
$filterComplex = "[0:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
|
||||
"[1:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
|
||||
"[2:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v2];" +
|
||||
"[3:v]scale=${cellWidth4}:${cellHeight4}:force_original_aspect_ratio=decrease,pad=${cellWidth4}:${cellHeight4}:(ow-iw)/2:(oh-ih)/2:black[v3];" +
|
||||
"[v0][v1]hstack=inputs=2[top];" +
|
||||
"[v2][v3]hstack=inputs=2[bottom];" +
|
||||
"[top][bottom]vstack=inputs=2[outv]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ffmpegArgs = $inputArgs + @(
|
||||
"-filter_complex", $filterComplex,
|
||||
"-map", "[outv]"
|
||||
)
|
||||
|
||||
if (-not $NoAudio) {
|
||||
$ffmpegArgs += @("-map", "0:a?")
|
||||
}
|
||||
|
||||
$ffmpegArgs += @(
|
||||
"-c:v", "libx264",
|
||||
"-preset", "fast",
|
||||
"-crf", "8",
|
||||
"-pix_fmt", "yuv420p"
|
||||
)
|
||||
|
||||
if (-not $NoAudio) {
|
||||
$ffmpegArgs += @("-c:a", "copy")
|
||||
}
|
||||
|
||||
$ffmpegArgs += @("-y", $Output)
|
||||
|
||||
$commandLine = "ffmpeg " + ($ffmpegArgs | ForEach-Object {
|
||||
if ($_ -match '\s') {
|
||||
"`"$_`""
|
||||
} else {
|
||||
$_
|
||||
}
|
||||
}) -join " "
|
||||
|
||||
Write-Host "Executing ffmpeg command:" -ForegroundColor Cyan
|
||||
Write-Host $commandLine -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($Output, "Create comparison video")) {
|
||||
& ffmpeg @ffmpegArgs
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "`nComparison video created successfully: $Output" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Error "ffmpeg failed with exit code $LASTEXITCODE"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Create-FullSubtitle.ps1" %*
|
||||
@@ -0,0 +1,246 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a full subtitle file from a video file from three sources: a reference video and two SRT subtitle files.
|
||||
This script will extract the video time length and the last subtitle number used in the first subtitle file,
|
||||
then will apply them to the second subtitle file for proper timing and numbering.
|
||||
The output file will contain all subtitles from the first and second subtitle files with proper timing and numbering.
|
||||
.DESCRIPTION
|
||||
This script creates a full subtitle file from a video file from three sources: a reference video and two SRT subtitle files.
|
||||
The output file contains all subtitles from the first and second subtitle files with proper timing and numbering.
|
||||
.PARAMETER Video
|
||||
Path to the video file.
|
||||
.PARAMETER Subtitle1
|
||||
Path to the first subtitle file.
|
||||
.PARAMETER Subtitle2
|
||||
Path to the second subtitle file.
|
||||
.PARAMETER Output
|
||||
Path to the output subtitle file.
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Video,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Subtitle1,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Subtitle2,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Output
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
#region Helper Functions
|
||||
|
||||
function Get-VideoDuration {
|
||||
param([string]$VideoPath)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $VideoPath -PathType Leaf)) {
|
||||
throw "Video file not found: $VideoPath"
|
||||
}
|
||||
|
||||
$frameCountOutput = ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 "$VideoPath" 2>&1
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffprobe failed to get video frame count"
|
||||
}
|
||||
|
||||
$fpsOutput = ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 "$VideoPath" 2>&1
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffprobe failed to get video fps"
|
||||
}
|
||||
|
||||
$frameCount = [int]$frameCountOutput.Trim()
|
||||
|
||||
# r_frame_rate is returned as a fraction e.g. "24/1" or "30000/1001"
|
||||
$fpsParts = $fpsOutput.Trim() -split '/'
|
||||
$fps = [double]$fpsParts[0] / [double]$fpsParts[1]
|
||||
|
||||
return $frameCount / $fps
|
||||
}
|
||||
|
||||
function ConvertTo-TimeSpan {
|
||||
param([string]$SrtTime)
|
||||
|
||||
# SRT format: HH:MM:SS,mmm
|
||||
$pattern = '^(\d{2}):(\d{2}):(\d{2}),(\d{3})$'
|
||||
if (-not ($SrtTime -match $pattern)) {
|
||||
throw "Invalid SRT time format: $SrtTime"
|
||||
}
|
||||
|
||||
$hours = [int]$matches[1]
|
||||
$minutes = [int]$matches[2]
|
||||
$seconds = [int]$matches[3]
|
||||
$milliseconds = [int]$matches[4]
|
||||
|
||||
return New-TimeSpan -Hours $hours -Minutes $minutes -Seconds $seconds -Milliseconds $milliseconds
|
||||
}
|
||||
|
||||
function ConvertFrom-TimeSpan {
|
||||
param([TimeSpan]$TimeSpan)
|
||||
|
||||
$hours = [int][Math]::Floor($TimeSpan.TotalHours)
|
||||
$minutes = $TimeSpan.Minutes
|
||||
$seconds = $TimeSpan.Seconds
|
||||
$milliseconds = $TimeSpan.Milliseconds
|
||||
|
||||
return '{0:D2}:{1:D2}:{2:D2},{3:D3}' -f $hours, $minutes, $seconds, $milliseconds
|
||||
}
|
||||
|
||||
function Add-TimeToSrt {
|
||||
param(
|
||||
[string]$SrtTime,
|
||||
[double]$OffsetSeconds
|
||||
)
|
||||
|
||||
$timeSpan = ConvertTo-TimeSpan -SrtTime $SrtTime
|
||||
$offsetSpan = [TimeSpan]::FromSeconds($OffsetSeconds)
|
||||
$newTimeSpan = $timeSpan.Add($offsetSpan)
|
||||
|
||||
return ConvertFrom-TimeSpan -TimeSpan $newTimeSpan
|
||||
}
|
||||
|
||||
function Get-SrtContent {
|
||||
param([string]$SrtPath)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SrtPath -PathType Leaf)) {
|
||||
throw "Subtitle file not found: $SrtPath"
|
||||
}
|
||||
|
||||
$content = Get-Content -LiteralPath $SrtPath -Raw
|
||||
$entries = @()
|
||||
|
||||
# Split by double newline to separate subtitle entries
|
||||
$rawEntries = $content -split "`r?`n`r?`n"
|
||||
|
||||
foreach ($rawEntry in $rawEntries) {
|
||||
$lines = $rawEntry.Trim() -split "`r?`n"
|
||||
|
||||
if ($lines.Count -lt 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
# First line should be the subtitle number
|
||||
$number = 0
|
||||
if (-not ([int]::TryParse($lines[0].Trim(), [ref]$number))) {
|
||||
continue
|
||||
}
|
||||
|
||||
# Second line should be the timing
|
||||
$timingLine = $lines[1].Trim()
|
||||
$timingPattern = '^(.+?) --> (.+?)$'
|
||||
if (-not ($timingLine -match $timingPattern)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$startTime = $matches[1]
|
||||
$endTime = $matches[2]
|
||||
|
||||
# Remaining lines are the text
|
||||
$textLines = $lines[2..($lines.Count - 1)]
|
||||
$text = ($textLines -join "`n").Trim()
|
||||
|
||||
$entries += [PSCustomObject]@{
|
||||
Number = $number
|
||||
StartTime = $startTime
|
||||
EndTime = $endTime
|
||||
Text = $text
|
||||
}
|
||||
}
|
||||
|
||||
return $entries
|
||||
}
|
||||
|
||||
function Export-SrtFile {
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[array]$Entries,
|
||||
[string]$OutputPath
|
||||
)
|
||||
|
||||
$outputDir = Split-Path -Parent $OutputPath
|
||||
if ($outputDir -and -not (Test-Path -LiteralPath $outputDir)) {
|
||||
if ($PSCmdlet.ShouldProcess($outputDir, 'Create output directory')) {
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
$srtContent = @()
|
||||
|
||||
for ($i = 0; $i -lt $Entries.Count; $i++) {
|
||||
$entry = $Entries[$i]
|
||||
$srtContent += $entry.Number
|
||||
$srtContent += "$($entry.StartTime) --> $($entry.EndTime)"
|
||||
$srtContent += $entry.Text
|
||||
|
||||
if ($i -lt $Entries.Count - 1) {
|
||||
$srtContent += ''
|
||||
}
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($OutputPath, 'Write combined SRT file')) {
|
||||
$srtContent -join "`r`n" | Out-File -LiteralPath $OutputPath -Encoding UTF8 -NoNewline
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Main Script
|
||||
|
||||
Write-Host "Parsing first subtitle file: $Subtitle1"
|
||||
$subtitle1Entries = Get-SrtContent -SrtPath $Subtitle1
|
||||
Write-Host " Found $($subtitle1Entries.Count) entries"
|
||||
|
||||
if ($subtitle1Entries.Count -eq 0) {
|
||||
throw "No valid subtitle entries found in: $Subtitle1"
|
||||
}
|
||||
|
||||
$lastNumber = $subtitle1Entries[-1].Number
|
||||
Write-Host " Last subtitle number: $lastNumber"
|
||||
|
||||
Write-Host "Extracting video duration from: $Video"
|
||||
$videoDuration = Get-VideoDuration -VideoPath $Video
|
||||
$videoDurationFormatted = ConvertFrom-TimeSpan -TimeSpan ([TimeSpan]::FromSeconds($videoDuration))
|
||||
Write-Host " Video duration: $videoDurationFormatted (${videoDuration}s)"
|
||||
|
||||
Write-Host "Parsing second subtitle file: $Subtitle2"
|
||||
$subtitle2Entries = Get-SrtContent -SrtPath $Subtitle2
|
||||
Write-Host " Found $($subtitle2Entries.Count) entries"
|
||||
|
||||
if ($subtitle2Entries.Count -eq 0) {
|
||||
throw "No valid subtitle entries found in: $Subtitle2"
|
||||
}
|
||||
|
||||
Write-Host "Adjusting second subtitle timing and numbering..."
|
||||
$adjustedSubtitle2Entries = @()
|
||||
|
||||
for ($i = 0; $i -lt $subtitle2Entries.Count; $i++) {
|
||||
$entry = $subtitle2Entries[$i]
|
||||
$newNumber = $lastNumber + $i + 1
|
||||
|
||||
$newStartTime = Add-TimeToSrt -SrtTime $entry.StartTime -OffsetSeconds $videoDuration
|
||||
$newEndTime = Add-TimeToSrt -SrtTime $entry.EndTime -OffsetSeconds $videoDuration
|
||||
|
||||
$adjustedSubtitle2Entries += [PSCustomObject]@{
|
||||
Number = $newNumber
|
||||
StartTime = $newStartTime
|
||||
EndTime = $newEndTime
|
||||
Text = $entry.Text
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host " Adjusted $($adjustedSubtitle2Entries.Count) entries"
|
||||
|
||||
$combinedEntries = $subtitle1Entries + $adjustedSubtitle2Entries
|
||||
Write-Host "Combined total: $($combinedEntries.Count) entries"
|
||||
|
||||
Write-Host "Writing output file: $Output"
|
||||
Export-SrtFile -Entries $combinedEntries -OutputPath $Output
|
||||
Write-Host "Output file created successfully: $Output"
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Create-ShortClip.ps1" %*
|
||||
@@ -0,0 +1,81 @@
|
||||
# Output clip video file specifications section
|
||||
[Clip]
|
||||
# Format: Output aspect ratio. Default: Portrait.
|
||||
# - Portrait: 9:16 aspect ratio
|
||||
# - Square: 1:1 aspect ratio
|
||||
Format=Portrait
|
||||
# Height: Vertical height in pixels. Width is computed based on format. Default: 1920.
|
||||
Height=1920
|
||||
# Padding: Vertical padding position when input height < output height. Default: Center.
|
||||
# - Top: All padding at top
|
||||
# - Bottom: All padding at bottom
|
||||
# - Center: Padding split evenly top and bottom
|
||||
# - None: No padding; scale video to fill output height and crop horizontally
|
||||
Padding=Center
|
||||
# Color: Padding color name or RGB value. Default: black.
|
||||
# - Named colors: black, white, red, blue, etc.
|
||||
# - Hex format: #RRGGBB (e.g., #1a1a1a)
|
||||
# - FFmpeg format: 0xRRGGBB
|
||||
Color=black
|
||||
# Overlay: Path to PNG file with transparency to overlay on each cropped frame. Default: none.
|
||||
# Example: Overlay=border.png
|
||||
# Overlay=
|
||||
# Audio: Copy audio from input video. Default: Yes.
|
||||
# - Yes: Include audio
|
||||
# - No: Video only
|
||||
Audio=Yes
|
||||
# Position: Reference point for numeric cropping offset values. Default: Center.
|
||||
# - Left: Numeric values represent absolute pixel offset from left edge
|
||||
# - Center: Numeric values represent pixel offset from center (value - output_width/2)
|
||||
# - Right: Numeric values represent pixel offset from right edge (value - output_width)
|
||||
Position=Left
|
||||
# Smoothing: Mode for adjusting cropping transition length to control speed. Default: None.
|
||||
# - None: No smoothing, use calculated interpolation as-is
|
||||
# - Up: Extend transition by incrementing upper frame until speed limit is met
|
||||
# - Center: Extend transition by adjusting frame closest to center until speed limit is met
|
||||
# - Down: Extend transition by decrementing lower frame until speed limit is met
|
||||
Smoothing=None
|
||||
# Speed: Maximum cropping transition speed in pixels per frame. Default: 10, minimum: 1.
|
||||
# Controls how fast the crop position can change between frames.
|
||||
# If interpolated speed exceeds this value, smoothing extends the transition.
|
||||
Speed=10
|
||||
# Horizontal cropping keyframes section
|
||||
[Cropping]
|
||||
# Defines horizontal cropping keyframes with automatic interpolation.
|
||||
# Format: frame_number=position
|
||||
#
|
||||
# Position values:
|
||||
# - Left: Crop from left edge (offset = 0)
|
||||
# - Center: Crop from center (offset = (input_width - output_width) / 2)
|
||||
# - Right: Crop from right edge (offset = input_width - output_width)
|
||||
# - number: Absolute pixel offset (capped at Right value)
|
||||
#
|
||||
# Behavior:
|
||||
# - Frame numbers must be in ascending order
|
||||
# - Intermediate frames are automatically interpolated with smooth transitions
|
||||
# - Frames after the last keyframe use the last defined offset
|
||||
# - If section is empty or missing, defaults to '0=Center'
|
||||
#
|
||||
# Example: Input video is 1920x1088, output video is 1080x1920, total frames: 100
|
||||
0=Left
|
||||
10=200
|
||||
50=Center
|
||||
75=Center
|
||||
91=Right
|
||||
# Calculated offsets:
|
||||
# Frame 0: 0 (Left)
|
||||
# Frames 1-9: Linear interpolation from 0 to 200
|
||||
# Frame 1: 20, Frame 2: 40, ..., Frame 9: 180
|
||||
# Frame 10: 200
|
||||
# Frames 11-49: Linear interpolation from 200 to 420 (Center = (1920-1080)/2)
|
||||
# Frame 11: 194.5, Frame 12: 189, ..., Frame 49: 425.5
|
||||
# Frame 50: 420 (Center)
|
||||
# Frames 51-74: No change (Center to Center)
|
||||
# All frames: 420
|
||||
# Frame 75: 420 (Center)
|
||||
# Frames 76-90: Linear interpolation from 420 to 840 (Right = 1920-1080)
|
||||
# Frame 76: 448, Frame 77: 476, ..., Frame 90: 812
|
||||
# Frame 91: 840 (Right)
|
||||
# Frames 92-99: 840 (last known value)
|
||||
#
|
||||
# All fractional values are rounded to nearest pixel.
|
||||
@@ -0,0 +1,730 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a short clip video file from a 16:9 video file using the specified plan.
|
||||
.PARAMETER Video
|
||||
Path to the input video file. Can be absolute or relative to current directory.
|
||||
.PARAMETER Images
|
||||
Path to the output PNG images file pattern from which the short clip video will be created. Can be absolute or relative to current directory.
|
||||
If not provided, the output files will be in subdirectory 'Frames' and named as the video file with a frame number suffix padded with zeros to 4 digits.
|
||||
.PARAMETER Plan
|
||||
Path to the plan file in INI format.
|
||||
The file contains sections and key=value entries that define how the short clip video is created.
|
||||
.PARAMETER Keep
|
||||
If specified, keeps the temporary directory containing extracted frames with padding applied.
|
||||
.PARAMETER Render
|
||||
If specified, skips frame extraction and cropping, and only creates the output video from existing frames in the Images path.
|
||||
.PARAMETER Color
|
||||
Color name or RGB value to use for padding. Overrides the Color value from the INI plan file if specified. Default: black.
|
||||
.PARAMETER Overlay
|
||||
Path to an optional PNG file with transparency to overlay on each cropped frame. Overrides the Overlay value from the INI plan file if specified. Can be absolute or relative to current directory.
|
||||
.PARAMETER Smoothing
|
||||
Mode for adjusting cropping transition length to control speed. Overrides the Smoothing value from the INI plan file if specified.
|
||||
Valid values: None, Up, Center, Down.
|
||||
.PARAMETER Speed
|
||||
Maximum cropping transition speed in pixels per frame (minimum: 1). Overrides the Speed value from the INI plan file if specified.
|
||||
.DESCRIPTION
|
||||
Creates a short clip video file from a 16:9 video file using the specified plan.
|
||||
See Create-ShortClip.ini for details about the INI format and supported entries for the plan file.
|
||||
Supports -WhatIf and -Confirm for safe execution.
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Video,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Images = '',
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Plan = '',
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$Keep,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$Render,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Color = '',
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Overlay = '',
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Smoothing = '',
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$Speed = $null
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Read-IniFile {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
throw "Plan file not found: $Path"
|
||||
}
|
||||
|
||||
$ini = @{}
|
||||
$currentSection = $null
|
||||
|
||||
foreach ($line in Get-Content $Path) {
|
||||
$line = $line.Trim()
|
||||
|
||||
if ($line -match '^\s*#' -or $line -eq '') {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($line -match '^\[(.+)\]$') {
|
||||
$currentSection = $matches[1]
|
||||
$ini[$currentSection] = @{}
|
||||
}
|
||||
elseif ($line -match '^([^=]+)=(.*)$' -and $currentSection) {
|
||||
$key = $matches[1].Trim()
|
||||
$value = $matches[2].Trim()
|
||||
$ini[$currentSection][$key] = $value
|
||||
}
|
||||
}
|
||||
|
||||
return $ini
|
||||
}
|
||||
|
||||
function Get-VideoInfo {
|
||||
param([string]$VideoPath)
|
||||
|
||||
$widthOutput = ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 $VideoPath 2>&1
|
||||
$heightOutput = ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 $VideoPath 2>&1
|
||||
$frameCountOutput = ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 $VideoPath 2>&1
|
||||
$fpsOutput = ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 $VideoPath 2>&1
|
||||
$bitrateOutput = ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of csv=p=0 $VideoPath 2>&1
|
||||
$codecOutput = ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 $VideoPath 2>&1
|
||||
$pixFmtOutput = ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt -of csv=p=0 $VideoPath 2>&1
|
||||
$audioCodecOutput = ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 $VideoPath 2>&1
|
||||
$audioBitrateOutput = ffprobe -v error -select_streams a:0 -show_entries stream=bit_rate -of csv=p=0 $VideoPath 2>&1
|
||||
|
||||
if ($widthOutput -match '^\d+$') {
|
||||
$width = [int]$widthOutput
|
||||
}
|
||||
else {
|
||||
throw "Failed to get video width"
|
||||
}
|
||||
|
||||
if ($heightOutput -match '^\d+$') {
|
||||
$height = [int]$heightOutput
|
||||
}
|
||||
else {
|
||||
throw "Failed to get video height"
|
||||
}
|
||||
|
||||
if ($frameCountOutput -match '^\d+$') {
|
||||
$frameCount = [int]$frameCountOutput
|
||||
}
|
||||
else {
|
||||
throw "Failed to get frame count"
|
||||
}
|
||||
|
||||
if ($fpsOutput -match '^(\d+)/(\d+)$') {
|
||||
$fps = [double]$matches[1] / [double]$matches[2]
|
||||
}
|
||||
elseif ($fpsOutput -match '^\d+(\.\d+)?$') {
|
||||
$fps = [double]$fpsOutput
|
||||
}
|
||||
else {
|
||||
throw "Failed to get frame rate"
|
||||
}
|
||||
|
||||
$bitrate = if ($bitrateOutput -match '^\d+$') { $bitrateOutput } else { $null }
|
||||
$codec = if ($codecOutput -and $codecOutput -ne 'N/A') { $codecOutput.Trim() } else { 'h264' }
|
||||
$pixFmt = if ($pixFmtOutput -and $pixFmtOutput -ne 'N/A') { $pixFmtOutput.Trim() } else { 'yuv420p' }
|
||||
$audioCodec = if ($audioCodecOutput -and $audioCodecOutput -ne 'N/A') { $audioCodecOutput.Trim() } else { $null }
|
||||
$audioBitrate = if ($audioBitrateOutput -match '^\d+$') { [int]$audioBitrateOutput } else { $null }
|
||||
|
||||
return @{
|
||||
Width = $width
|
||||
Height = $height
|
||||
FrameCount = $frameCount
|
||||
FPS = $fps
|
||||
Bitrate = $bitrate
|
||||
Codec = $codec
|
||||
PixelFormat = $pixFmt
|
||||
AudioCodec = $audioCodec
|
||||
AudioBitrate = $audioBitrate
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-CroppingOffset {
|
||||
param(
|
||||
[string]$Value,
|
||||
[int]$InputWidth,
|
||||
[int]$OutputWidth,
|
||||
[string]$Position = 'Center'
|
||||
)
|
||||
|
||||
switch ($Value) {
|
||||
'Left' { return 0 }
|
||||
'Center' {
|
||||
$offset = [int](($InputWidth - $OutputWidth) / 2)
|
||||
return [Math]::Max(0, $offset)
|
||||
}
|
||||
'Right' {
|
||||
return [Math]::Max(0, $InputWidth - $OutputWidth)
|
||||
}
|
||||
default {
|
||||
if ($Value -match '^\d+$') {
|
||||
$numValue = [int]$Value
|
||||
|
||||
switch ($Position) {
|
||||
'Left' {
|
||||
$adjustedValue = $numValue
|
||||
}
|
||||
'Center' {
|
||||
$adjustedValue = $numValue - [int]($OutputWidth / 2)
|
||||
}
|
||||
'Right' {
|
||||
$adjustedValue = $numValue - $OutputWidth
|
||||
}
|
||||
}
|
||||
|
||||
$maxRight = [Math]::Max(0, $InputWidth - $OutputWidth)
|
||||
return [Math]::Max(0, [Math]::Min($adjustedValue, $maxRight))
|
||||
}
|
||||
throw "Invalid cropping value: $Value"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CroppingOffsets {
|
||||
param(
|
||||
[hashtable]$CroppingSection,
|
||||
[int]$TotalFrames,
|
||||
[int]$InputWidth,
|
||||
[int]$OutputWidth,
|
||||
[string]$Position = 'Center',
|
||||
[string]$Smoothing = 'None',
|
||||
[int]$Speed = 10
|
||||
)
|
||||
|
||||
$offsets = @{}
|
||||
|
||||
if ($null -eq $CroppingSection -or $CroppingSection.Count -eq 0) {
|
||||
$defaultOffset = ConvertTo-CroppingOffset -Value 'Center' -InputWidth $InputWidth -OutputWidth $OutputWidth -Position $Position
|
||||
for ($i = 0; $i -lt $TotalFrames; $i++) {
|
||||
$offsets[$i] = $defaultOffset
|
||||
}
|
||||
return $offsets
|
||||
}
|
||||
|
||||
$keyFrames = @()
|
||||
foreach ($key in $CroppingSection.Keys) {
|
||||
if ($key -match '^\d+$') {
|
||||
$frameNum = [int]$key
|
||||
$value = $CroppingSection[$key]
|
||||
$offset = ConvertTo-CroppingOffset -Value $value -InputWidth $InputWidth -OutputWidth $OutputWidth -Position $Position
|
||||
$keyFrames += [PSCustomObject]@{ Frame = $frameNum; Offset = $offset }
|
||||
}
|
||||
}
|
||||
|
||||
$keyFrames = @($keyFrames | Sort-Object -Property Frame)
|
||||
|
||||
for ($i = 1; $i -lt $keyFrames.Count; $i++) {
|
||||
if ($keyFrames[$i].Frame -le $keyFrames[$i - 1].Frame) {
|
||||
throw "Frame numbers in Cropping section must be in ascending order"
|
||||
}
|
||||
}
|
||||
|
||||
if ($keyFrames.Count -eq 0) {
|
||||
$defaultOffset = ConvertTo-CroppingOffset -Value 'Center' -InputWidth $InputWidth -OutputWidth $OutputWidth -Position $Position
|
||||
for ($i = 0; $i -lt $TotalFrames; $i++) {
|
||||
$offsets[$i] = $defaultOffset
|
||||
}
|
||||
return $offsets
|
||||
}
|
||||
|
||||
for ($i = 0; $i -lt $keyFrames.Count; $i++) {
|
||||
$currentFrame = $keyFrames[$i].Frame
|
||||
$currentOffset = $keyFrames[$i].Offset
|
||||
|
||||
$offsets[$currentFrame] = $currentOffset
|
||||
|
||||
if ($i -lt $keyFrames.Count - 1) {
|
||||
$nextFrame = $keyFrames[$i + 1].Frame
|
||||
$nextOffset = $keyFrames[$i + 1].Offset
|
||||
|
||||
$frameDiff = $nextFrame - $currentFrame
|
||||
if ($frameDiff -gt 1) {
|
||||
$centerFrame = [Math]::Round(($currentFrame + $nextFrame) / 2)
|
||||
$offsetDiff = $nextOffset - $currentOffset
|
||||
$step = [double]$offsetDiff / [double]$frameDiff
|
||||
|
||||
if ($Smoothing -ne 'None' -and $offsetDiff -ne 0) {
|
||||
$computedSpeed = [Math]::Abs($step)
|
||||
$startFrame = $currentFrame
|
||||
$endFrame = $nextFrame
|
||||
|
||||
if ($computedSpeed -gt $Speed) {
|
||||
Write-Host "Smoothing required between frames $startFrame and $endFrame at speed $computedSpeed (Max: $Speed)"
|
||||
|
||||
while ($computedSpeed -gt $Speed) {
|
||||
$mode = $Smoothing
|
||||
|
||||
if ($mode -eq 'Center') {
|
||||
if (($endFrame - $centerFrame) -le ($centerFrame - $startFrame)) {
|
||||
$mode = 'Up'
|
||||
}
|
||||
else {
|
||||
$mode = 'Down'
|
||||
}
|
||||
}
|
||||
|
||||
if ($mode -eq 'Up') {
|
||||
if (($endFrame + 1) -lt $TotalFrames) {
|
||||
$endFrame++
|
||||
}
|
||||
elseif (($startFrame - 1) -ge 0) {
|
||||
$startFrame--
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
elseif ($mode -eq 'Down') {
|
||||
if (($startFrame - 1) -ge 0) {
|
||||
$startFrame--
|
||||
}
|
||||
elseif (($endFrame + 1) -lt $TotalFrames) {
|
||||
$endFrame++
|
||||
}
|
||||
else {
|
||||
break
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw "Internal error for mode value: $mode. Must be 'Up', or 'Down'"
|
||||
}
|
||||
|
||||
$frameDiff = $endFrame - $startFrame
|
||||
$step = [double]$offsetDiff / [double]$frameDiff
|
||||
$computedSpeed = [Math]::Abs($step)
|
||||
|
||||
if ($computedSpeed -gt $Speed) {
|
||||
$currentFrame = $startFrame
|
||||
$nextFrame = $endFrame
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Smoothing completed with frames now between $currentFrame and $nextFrame"
|
||||
}
|
||||
}
|
||||
|
||||
for ($f = $currentFrame + 1; $f -le $nextFrame; $f++) {
|
||||
$interpolatedOffset = $currentOffset + ($step * ($f - $currentFrame))
|
||||
$offsets[$f] = [Math]::Round($interpolatedOffset)
|
||||
}
|
||||
|
||||
if (($i + 1) -lt $keyFrames.Count) {
|
||||
$keyFrames[$i + 1].Frame = $nextFrame + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$lastKeyFrame = $keyFrames[-1]
|
||||
for ($f = $lastKeyFrame.Frame + 1; $f -lt $TotalFrames; $f++) {
|
||||
$offsets[$f] = $lastKeyFrame.Offset
|
||||
}
|
||||
|
||||
for ($f = 0; $f -lt $keyFrames[0].Frame; $f++) {
|
||||
if (-not $offsets.ContainsKey($f)) {
|
||||
$offsets[$f] = $keyFrames[0].Offset
|
||||
}
|
||||
}
|
||||
|
||||
return $offsets
|
||||
}
|
||||
|
||||
function Test-IniContent {
|
||||
param([hashtable]$Ini)
|
||||
|
||||
if (-not $Ini.ContainsKey('Clip')) {
|
||||
throw "INI file must contain a [Clip] section"
|
||||
}
|
||||
|
||||
$clip = $Ini['Clip']
|
||||
|
||||
if ($clip.ContainsKey('Format')) {
|
||||
if ($clip['Format'] -notin @('Portrait', 'Square')) {
|
||||
throw "Invalid Format value: $($clip['Format']). Must be 'Portrait' or 'Square'"
|
||||
}
|
||||
}
|
||||
|
||||
if ($clip.ContainsKey('Height')) {
|
||||
if ($clip['Height'] -notmatch '^\d+$') {
|
||||
throw "Invalid Height value: $($clip['Height']). Must be a positive integer"
|
||||
}
|
||||
}
|
||||
|
||||
if ($clip.ContainsKey('Padding')) {
|
||||
if ($clip['Padding'] -notin @('Top', 'Bottom', 'Center', 'None')) {
|
||||
throw "Invalid Padding value: $($clip['Padding']). Must be 'Top', 'Bottom', 'Center', or 'None'"
|
||||
}
|
||||
}
|
||||
|
||||
if ($clip.ContainsKey('Audio')) {
|
||||
if ($clip['Audio'] -notin @('Yes', 'No')) {
|
||||
throw "Invalid Audio value: $($clip['Audio']). Must be 'Yes' or 'No'"
|
||||
}
|
||||
}
|
||||
|
||||
if ($clip.ContainsKey('Position')) {
|
||||
if ($clip['Position'] -notin @('Left', 'Center', 'Right')) {
|
||||
throw "Invalid Position value: $($clip['Position']). Must be 'Left', 'Center', or 'Right'"
|
||||
}
|
||||
}
|
||||
|
||||
if ($clip.ContainsKey('Smoothing')) {
|
||||
if ($clip['Smoothing'] -notin @('None', 'Center', 'Up', 'Down')) {
|
||||
throw "Invalid Smoothing value: $($clip['Smoothing']). Must be 'None', 'Center', 'Up', or 'Down'"
|
||||
}
|
||||
}
|
||||
|
||||
if ($clip.ContainsKey('Speed')) {
|
||||
if ($clip['Speed'] -notmatch '^\d+$') {
|
||||
throw "Invalid Speed value: $($clip['Speed']). Must be a positive integer"
|
||||
}
|
||||
elseif ([int]$clip['Speed'] -lt 1) {
|
||||
throw "Invalid Speed value: $($clip['Speed']). Must be greater than or equal to 1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (-not (Test-Path $Video)) {
|
||||
throw "Video file not found: $Video"
|
||||
}
|
||||
|
||||
# Overlay validation will be done after reading INI file
|
||||
|
||||
Write-Host "Reading plan file: $Plan"
|
||||
$ini = Read-IniFile -Path $Plan
|
||||
|
||||
Write-Host "Validating plan file content"
|
||||
Test-IniContent -Ini $ini
|
||||
|
||||
$clip = $ini['Clip']
|
||||
$format = if ($clip.ContainsKey('Format')) { $clip['Format'] } else { 'Portrait' }
|
||||
$outputHeight = if ($clip.ContainsKey('Height')) { [int]$clip['Height'] } else { 1920 }
|
||||
$padding = if ($clip.ContainsKey('Padding')) { $clip['Padding'] } else { 'Center' }
|
||||
$audio = if ($clip.ContainsKey('Audio')) { $clip['Audio'] } else { 'Yes' }
|
||||
$position = if ($clip.ContainsKey('Position')) { $clip['Position'] } else { 'Center' }
|
||||
|
||||
if ($Smoothing -ne '') {
|
||||
$smoothing = $Smoothing
|
||||
}
|
||||
elseif ($clip.ContainsKey('Smoothing')) {
|
||||
$smoothing = $clip['Smoothing']
|
||||
}
|
||||
else {
|
||||
$smoothing = 'None'
|
||||
}
|
||||
|
||||
if ($Speed -gt 0) {
|
||||
$speed = $Speed
|
||||
}
|
||||
elseif ($clip.ContainsKey('Speed')) {
|
||||
$speed = [int]$clip['Speed']
|
||||
}
|
||||
else {
|
||||
$speed = 10
|
||||
}
|
||||
|
||||
# Color: Parameter overrides INI, defaults to 'black'
|
||||
if ($Color -ne '') {
|
||||
$color = $Color
|
||||
}
|
||||
elseif ($clip.ContainsKey('Color')) {
|
||||
$color = $clip['Color']
|
||||
}
|
||||
else {
|
||||
$color = 'black'
|
||||
}
|
||||
|
||||
# Overlay: Parameter overrides INI, defaults to empty
|
||||
if ($Overlay -ne '') {
|
||||
$overlay = $Overlay
|
||||
}
|
||||
elseif ($clip.ContainsKey('Overlay')) {
|
||||
$overlay = $clip['Overlay']
|
||||
}
|
||||
else {
|
||||
$overlay = ''
|
||||
}
|
||||
|
||||
# Convert overlay to absolute path if specified
|
||||
if ($overlay -ne '') {
|
||||
if (-not [System.IO.Path]::IsPathRooted($overlay)) {
|
||||
$overlay = Join-Path (Get-Location).Path $overlay
|
||||
}
|
||||
}
|
||||
|
||||
# Validate overlay file exists if specified
|
||||
if ($overlay -ne '' -and -not (Test-Path $overlay)) {
|
||||
throw "Overlay file not found: $overlay"
|
||||
}
|
||||
|
||||
$outputWidth = if ($format -eq 'Portrait') { [int]($outputHeight * 9 / 16) } else { $outputHeight }
|
||||
|
||||
Write-Host "Getting video information"
|
||||
$videoInfo = Get-VideoInfo -VideoPath $Video
|
||||
|
||||
Write-Host "Input video: $($videoInfo.Width)x$($videoInfo.Height), $($videoInfo.FrameCount) frames, $($videoInfo.FPS) fps"
|
||||
Write-Host "Output clip: ${outputWidth}x${outputHeight}, Format: $format"
|
||||
|
||||
# Determine if we need to work at input resolution then scale down
|
||||
$needsDownscale = $videoInfo.Height -gt $outputHeight
|
||||
if ($needsDownscale) {
|
||||
# Calculate intermediate dimensions at input resolution scale
|
||||
$intermediateHeight = $videoInfo.Height
|
||||
$intermediateWidth = if ($format -eq 'Portrait') { [int]($intermediateHeight * 9 / 16) } else { $intermediateHeight }
|
||||
Write-Host "Working at input resolution: ${intermediateWidth}x${intermediateHeight}, then scaling to ${outputWidth}x${outputHeight}"
|
||||
} else {
|
||||
$intermediateHeight = $outputHeight
|
||||
$intermediateWidth = $outputWidth
|
||||
}
|
||||
|
||||
if ($Images -eq '') {
|
||||
$videoBaseName = [System.IO.Path]::GetFileNameWithoutExtension($Video)
|
||||
$videoDir = [System.IO.Path]::GetDirectoryName($Video)
|
||||
if ($videoDir -eq '') { $videoDir = '.' }
|
||||
$framesDir = Join-Path $videoDir 'Frames'
|
||||
if (Test-Path $framesDir) {
|
||||
if ($PSCmdlet.ShouldProcess($framesDir, 'Clear existing frames directory')) {
|
||||
Get-ChildItem -Path $framesDir -File | Remove-Item -Force
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($PSCmdlet.ShouldProcess($framesDir, 'Create frames directory')) {
|
||||
New-Item -ItemType Directory -Path $framesDir | Out-Null
|
||||
}
|
||||
}
|
||||
$Images = Join-Path $framesDir "${videoBaseName}-%04d.png"
|
||||
}
|
||||
|
||||
$imagesDir = [System.IO.Path]::GetDirectoryName($Images)
|
||||
if ($imagesDir -and -not (Test-Path $imagesDir)) {
|
||||
if ($PSCmdlet.ShouldProcess($imagesDir, 'Create output images directory')) {
|
||||
New-Item -ItemType Directory -Path $imagesDir | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
$paddingColor = $color
|
||||
if ($color -match '^#([0-9A-Fa-f]{6})$') {
|
||||
$paddingColor = "0x$($matches[1])"
|
||||
}
|
||||
|
||||
if ($Render) {
|
||||
Write-Host "Render mode: Skipping frame extraction and cropping"
|
||||
|
||||
$imagesPattern = $Images -replace '%\d+d', '*'
|
||||
$existingFrames = Get-ChildItem -Path $imagesPattern -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $existingFrames -or $existingFrames.Count -eq 0) {
|
||||
throw "No existing frames found at: $Images. Cannot render without frames."
|
||||
}
|
||||
|
||||
Write-Host "Found $($existingFrames.Count) existing frames to render"
|
||||
}
|
||||
else {
|
||||
Write-Host "Calculating cropping offsets"
|
||||
$croppingSection = if ($ini.ContainsKey('Cropping')) { $ini['Cropping'] } else { $null }
|
||||
# Use intermediate dimensions for cropping calculation
|
||||
$croppingOffsets = Get-CroppingOffsets -CroppingSection $croppingSection -TotalFrames $videoInfo.FrameCount -InputWidth $videoInfo.Width -OutputWidth $intermediateWidth -Position $position -Smoothing $smoothing -Speed $speed
|
||||
|
||||
Write-Host "`nCropping Offsets:"
|
||||
Write-Host ("Frame".PadRight(10) + "Left Offset")
|
||||
Write-Host ("-" * 21)
|
||||
$previousOffset = $null
|
||||
foreach ($frame in ($croppingOffsets.Keys | Sort-Object)) {
|
||||
$currentOffset = $croppingOffsets[$frame]
|
||||
if ($currentOffset -ne $previousOffset) {
|
||||
Write-Host ("{0,-10}{1}" -f $frame, $currentOffset)
|
||||
$previousOffset = $currentOffset
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "Extracting and processing frames"
|
||||
|
||||
$paddingFilter = ''
|
||||
if ($padding -eq 'None') {
|
||||
# Scale to fill intermediate height only; horizontal cropping will be applied per-frame
|
||||
$paddingFilter = "scale=-2:${intermediateHeight}"
|
||||
}
|
||||
elseif ($videoInfo.Height -lt $intermediateHeight) {
|
||||
$padHeight = $intermediateHeight - $videoInfo.Height
|
||||
switch ($padding) {
|
||||
'Top' { $paddingFilter = "pad=$($videoInfo.Width):${intermediateHeight}:0:${padHeight}:${paddingColor}" }
|
||||
'Bottom' { $paddingFilter = "pad=$($videoInfo.Width):${intermediateHeight}:0:0:${paddingColor}" }
|
||||
'Center' {
|
||||
$padTop = [int]($padHeight / 2)
|
||||
$paddingFilter = "pad=$($videoInfo.Width):${intermediateHeight}:0:${padTop}:${paddingColor}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "ShortClip_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir | Out-Null
|
||||
|
||||
try {
|
||||
$extractPattern = Join-Path $tempDir "frame-%04d.png"
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($tempDir, 'Extract frames with padding to temporary directory')) {
|
||||
Write-Host "Extracting frames with padding to temporary directory"
|
||||
if ($paddingFilter -ne '') {
|
||||
ffmpeg -i $Video -vf $paddingFilter $extractPattern -y -loglevel error
|
||||
}
|
||||
else {
|
||||
ffmpeg -i $Video $extractPattern -y -loglevel error
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffmpeg failed to extract frames with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
$extractedFrames = Get-ChildItem -Path $tempDir -Filter "frame-*.png" | Sort-Object Name
|
||||
|
||||
if ($extractedFrames.Count -eq 0) {
|
||||
throw "No frames were extracted"
|
||||
}
|
||||
|
||||
Write-Host "Applying cropping to $($extractedFrames.Count) frames"
|
||||
|
||||
$imagesPattern = $Images -replace '%\d+d', '*'
|
||||
$existingOutputFrames = Get-ChildItem -Path $imagesPattern -ErrorAction SilentlyContinue
|
||||
if (($existingOutputFrames) -and ($PSCmdlet.ShouldProcess($imagesPattern, 'Remove existing output frames'))) {
|
||||
Write-Host "Removing existing output frames"
|
||||
$existingOutputFrames | Remove-Item -Force
|
||||
}
|
||||
|
||||
for ($i = 0; $i -lt $extractedFrames.Count; $i++) {
|
||||
$frameFile = $extractedFrames[$i]
|
||||
$frameNumber = $i
|
||||
$cropOffset = $croppingOffsets[$frameNumber]
|
||||
|
||||
$outputFramePath = $Images -replace '%04d', $frameNumber.ToString('0000')
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($outputFramePath, 'Crop and save frame')) {
|
||||
# Crop at intermediate resolution, then scale down if needed
|
||||
if ($needsDownscale) {
|
||||
if ($overlay -ne '') {
|
||||
# Crop, scale, then overlay
|
||||
$cropFilter = "[0:v]crop=${intermediateWidth}:${intermediateHeight}:${cropOffset}:0,scale=${outputWidth}:${outputHeight}[scaled];[scaled][1:v]overlay=0:0"
|
||||
ffmpeg -i $frameFile.FullName -i $overlay -filter_complex $cropFilter $outputFramePath -y -loglevel error
|
||||
}
|
||||
else {
|
||||
$cropFilter = "crop=${intermediateWidth}:${intermediateHeight}:${cropOffset}:0,scale=${outputWidth}:${outputHeight}"
|
||||
ffmpeg -i $frameFile.FullName -vf $cropFilter $outputFramePath -y -loglevel error
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($overlay -ne '') {
|
||||
# Use overlay as second input to avoid path escaping issues
|
||||
$cropFilter = "[0:v]crop=${outputWidth}:${outputHeight}:${cropOffset}:0[cropped];[cropped][1:v]overlay=0:0"
|
||||
ffmpeg -i $frameFile.FullName -i $overlay -filter_complex $cropFilter $outputFramePath -y -loglevel error
|
||||
}
|
||||
else {
|
||||
$cropFilter = "crop=${outputWidth}:${outputHeight}:${cropOffset}:0"
|
||||
ffmpeg -i $frameFile.FullName -vf $cropFilter $outputFramePath -y -loglevel error
|
||||
}
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffmpeg failed to crop frame $frameNumber with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
if (($i + 1) % 10 -eq 0 -or ($i + 1) -eq $extractedFrames.Count) {
|
||||
Write-Host "Processed $($i + 1) / $($extractedFrames.Count) frames"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force
|
||||
Write-Host "Temporary directory removed: $tempDir"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Creating output video file"
|
||||
$videoExt = [System.IO.Path]::GetExtension($Video)
|
||||
$videoBaseName = [System.IO.Path]::GetFileNameWithoutExtension($Video)
|
||||
$videoDir = [System.IO.Path]::GetDirectoryName($Video)
|
||||
if ($videoDir -eq '') { $videoDir = '.' }
|
||||
$outputVideo = Join-Path $videoDir "${videoBaseName}-ShortClip${videoExt}"
|
||||
|
||||
$inputPattern = $Images
|
||||
|
||||
$videoCodec = if ($videoInfo.Codec -eq 'h264') { 'libx264' } elseif ($videoInfo.Codec -eq 'hevc') { 'libx265' } else { 'libx264' }
|
||||
$pixFmt = $videoInfo.PixelFormat
|
||||
|
||||
$encodingArgs = @('-c:v', $videoCodec, '-pix_fmt', $pixFmt, '-preset', 'slow')
|
||||
|
||||
if ($videoInfo.Bitrate) {
|
||||
$encodingArgs += @('-b:v', $videoInfo.Bitrate)
|
||||
}
|
||||
else {
|
||||
$encodingArgs += @('-crf', '8')
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($outputVideo, 'Create output video file')) {
|
||||
if ($audio -eq 'Yes') {
|
||||
Write-Host "Creating video with audio"
|
||||
$mp4CompatibleAudioCodecs = @('aac', 'ac3', 'eac3', 'mp3')
|
||||
if ($videoInfo.AudioCodec -and $mp4CompatibleAudioCodecs -contains $videoInfo.AudioCodec) {
|
||||
$audioArgs = @('-c:a', 'copy')
|
||||
}
|
||||
elseif ($videoInfo.AudioBitrate) {
|
||||
$audioBitrateK = [Math]::Max(320, [int]($videoInfo.AudioBitrate / 1000))
|
||||
$audioArgs = @('-c:a', 'aac', '-b:a', "${audioBitrateK}k")
|
||||
}
|
||||
else {
|
||||
$audioArgs = @('-c:a', 'aac', '-b:a', '320k')
|
||||
}
|
||||
Write-Host "Audio args: $($audioArgs -join ' ')"
|
||||
ffmpeg -framerate $videoInfo.FPS -i $inputPattern -i $Video -map 0:v:0 -map 1:a:0? @encodingArgs @audioArgs $outputVideo -y
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffmpeg failed to create output video with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "Creating video without audio"
|
||||
ffmpeg -framerate $videoInfo.FPS -i $inputPattern @encodingArgs $outputVideo -y
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffmpeg failed to create output video with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $outputVideo)) {
|
||||
throw "Output video file was not created: $outputVideo"
|
||||
}
|
||||
|
||||
Write-Host "Output video created: $outputVideo" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Clean up frames directory after video is successfully created
|
||||
if ((-not $Keep) -and (Test-Path $framesDir) -and ($PSCmdlet.ShouldProcess($framesDir, 'Remove frames directory'))) {
|
||||
Remove-Item -Path $framesDir -Recurse -Force
|
||||
Write-Host "Frames directory removed: $framesDir"
|
||||
}
|
||||
elseif ($Keep -and (Test-Path $framesDir)) {
|
||||
Write-Host "Frames directory preserved: $framesDir"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Error: $_"
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Crop-Clips.ps1" %*
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
<#
|
||||
.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
|
||||
|
||||
$fps = $null
|
||||
if ($videoStream.r_frame_rate -and $videoStream.r_frame_rate -match '^(\d+)/(\d+)$') {
|
||||
$num = [double]$Matches[1]
|
||||
$den = [double]$Matches[2]
|
||||
if ($den -ne 0) {
|
||||
$fps = $num / $den
|
||||
}
|
||||
}
|
||||
|
||||
$bitRate = $videoStream.bit_rate
|
||||
if (-not $bitRate) {
|
||||
$bitRate = $info.format.bit_rate
|
||||
}
|
||||
|
||||
[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 = $videoStream.bit_rate
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Crop-ClipsKling.ps1" %*
|
||||
@@ -0,0 +1,193 @@
|
||||
<#
|
||||
.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
|
||||
|
||||
$fps = $null
|
||||
if ($videoStream.r_frame_rate -and $videoStream.r_frame_rate -match '^(\d+)/(\d+)$') {
|
||||
$num = [double]$Matches[1]
|
||||
$den = [double]$Matches[2]
|
||||
if ($den -ne 0) {
|
||||
$fps = $num / $den
|
||||
}
|
||||
}
|
||||
|
||||
$bitRate = $videoStream.bit_rate
|
||||
if (-not $bitRate) {
|
||||
$bitRate = $info.format.bit_rate
|
||||
}
|
||||
|
||||
[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 = $videoStream.bit_rate
|
||||
}
|
||||
}
|
||||
|
||||
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' }
|
||||
|
||||
if ($inInfo.Height -ne 1072 -or ($inInfo.Width -ne 1920 -and $inInfo.Width -ne 1928)) {
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Crop-ClipsWan.ps1" %*
|
||||
@@ -0,0 +1,196 @@
|
||||
<#
|
||||
.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
|
||||
|
||||
$fps = $null
|
||||
if ($videoStream.r_frame_rate -and $videoStream.r_frame_rate -match '^(\d+)/(\d+)$') {
|
||||
$num = [double]$Matches[1]
|
||||
$den = [double]$Matches[2]
|
||||
if ($den -ne 0) {
|
||||
$fps = $num / $den
|
||||
}
|
||||
}
|
||||
|
||||
$bitRate = $videoStream.bit_rate
|
||||
if (-not $bitRate) {
|
||||
$bitRate = $info.format.bit_rate
|
||||
}
|
||||
|
||||
[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 = $videoStream.bit_rate
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Get-Frames.ps1" %*
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts specific range of frames of a video file to PNG images using ffmpeg.
|
||||
.PARAMETER Video
|
||||
Path to the input video file. Can be absolute or relative to current directory.
|
||||
.PARAMETER Images
|
||||
Path to output PNG images files base name. Can be absolute or relative to current directory.
|
||||
If not provided, the output files will be in subdirectory 'Frames' and named as the video file with a frame number suffix padded with zeros to 4 digits.
|
||||
.PARAMETER Frames
|
||||
0-based frames index to extract. Supports the following formats:
|
||||
- start-end: Extract frames from start to end (e.g., "100-200")
|
||||
- positive_number: Extract frames from 0 to that number (e.g., "50")
|
||||
- -negative_number: Extract last N frames (e.g., "-10")
|
||||
- %: Represents the last frame index. Can be used alone or in ranges (e.g., "%", "10-%", "-%")
|
||||
- all: Extract all frames (same as "0-%")
|
||||
.DESCRIPTION
|
||||
Extracts specific range of frames of a video file to PNG images using ffmpeg.
|
||||
Supports -WhatIf and -Confirm for safe execution.
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Video,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Images = '',
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Frames = ''
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$VideoPath = $Video
|
||||
|
||||
if (-not (Test-Path -LiteralPath $VideoPath)) {
|
||||
Write-Error ('Video file not found: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$totalFramesOutput = & ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 -- $VideoPath 2>&1
|
||||
$ffprobeExit = $LASTEXITCODE
|
||||
|
||||
if ($ffprobeExit -ne 0) {
|
||||
$totalFramesOutput | ForEach-Object { Write-Error $_ }
|
||||
Write-Error ('ffprobe failed to read video file: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$totalFrames = $totalFramesOutput | Select-Object -First 1
|
||||
|
||||
if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') {
|
||||
Write-Error ('Could not determine frame count for: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$totalFramesInt = [int]$totalFrames
|
||||
$maxFrameIndex = $totalFramesInt - 1
|
||||
|
||||
$startFrame = 0
|
||||
$endFrame = 0
|
||||
|
||||
# Replace % with actual last frame index, and handle 'all' keyword
|
||||
$framesPattern = $Frames -replace '%', $maxFrameIndex
|
||||
if ($framesPattern -ieq 'all') {
|
||||
$framesPattern = "0-$maxFrameIndex"
|
||||
}
|
||||
|
||||
if ($framesPattern -match '^(\d+)-(\d+)$') {
|
||||
$startFrame = [int]$matches[1]
|
||||
$endFrame = [int]$matches[2]
|
||||
}
|
||||
elseif ($framesPattern -match '^-(\d+)$') {
|
||||
$negativeCount = [int]$matches[1]
|
||||
$startFrame = [Math]::Max(0, $maxFrameIndex - $negativeCount)
|
||||
$endFrame = $maxFrameIndex
|
||||
}
|
||||
elseif ($framesPattern -match '^\d+$') {
|
||||
$positiveCount = [int]$framesPattern
|
||||
$startFrame = 0
|
||||
$endFrame = [Math]::Min($positiveCount, $maxFrameIndex)
|
||||
}
|
||||
else {
|
||||
Write-Error ('Invalid Frames format: {0}. Expected: start-end, positive number, negative number, % for last frame, or "all"' -f $Frames)
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($startFrame -lt 0 -or $startFrame -gt $maxFrameIndex) {
|
||||
Write-Error ('Start frame {0} is out of range. Valid range: 0 to {1}' -f $startFrame, $maxFrameIndex)
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($endFrame -lt $startFrame -or $endFrame -gt $maxFrameIndex) {
|
||||
Write-Error ('End frame {0} is out of range. Valid range: {1} to {2}' -f $endFrame, $startFrame, $maxFrameIndex)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$frameCount = $endFrame - $startFrame + 1
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Images)) {
|
||||
$videoBaseName = [System.IO.Path]::GetFileNameWithoutExtension($Video)
|
||||
$outputDir = Split-Path -Parent $VideoPath
|
||||
$Images = Join-Path $outputDir 'Frames' ($videoBaseName + '_%04d.png')
|
||||
} else {
|
||||
$outputPattern = $Images
|
||||
|
||||
if ($outputPattern -notmatch '%\d+d') {
|
||||
$outputPattern = $outputPattern -replace '\.png$', ''
|
||||
$outputPattern = $outputPattern + '_%04d.png'
|
||||
}
|
||||
|
||||
$Images = $outputPattern
|
||||
}
|
||||
|
||||
$outputDir = Split-Path -Parent $Images
|
||||
if ($outputDir -and -not (Test-Path -LiteralPath $outputDir)) {
|
||||
if ($PSCmdlet.ShouldProcess($outputDir, 'Create output directory')) {
|
||||
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
|
||||
Write-Host ('Created output directory: {0}' -f $outputDir)
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ('Extracting frames from video: {0}' -f $Video)
|
||||
Write-Host (' Frame range: {0} to {1} ({2} frames)' -f $startFrame, $endFrame, $frameCount)
|
||||
|
||||
$outputBaseName = $Images -replace '%\d+d\.png$', ''
|
||||
$outputExtension = '.png'
|
||||
|
||||
for ($frameIndex = $startFrame; $frameIndex -le $endFrame; $frameIndex++) {
|
||||
$outputFile = ('{0}{1:D4}{2}' -f $outputBaseName, $frameIndex, $outputExtension)
|
||||
|
||||
$videoFilter = 'select=eq(n\,{0})' -f $frameIndex
|
||||
|
||||
$ffmpegArgs = @(
|
||||
'-i', $VideoPath
|
||||
'-vf', $videoFilter
|
||||
'-vsync', 'vfr'
|
||||
'-frames:v', '1'
|
||||
'-q:v', '1'
|
||||
'--', $outputFile
|
||||
)
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($outputFile, ('Extract frame {0} to PNG' -f $frameIndex))) {
|
||||
Write-Host (' Extracting frame {0} to: {1}' -f $frameIndex, (Split-Path -Leaf $outputFile))
|
||||
|
||||
$ffmpegOutput = & ffmpeg @ffmpegArgs 2>&1
|
||||
$ffmpegExit = $LASTEXITCODE
|
||||
|
||||
if ($ffmpegExit -ne 0) {
|
||||
$ffmpegOutput | ForEach-Object { Write-Error $_ }
|
||||
Write-Error ('ffmpeg failed to extract frame {0}' -f $frameIndex)
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ('Frames extracted successfully')
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Get-FramesCount.ps1" %*
|
||||
@@ -0,0 +1,33 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Gets the total frame count of a video file using ffprobe.
|
||||
.PARAMETER Video
|
||||
Path to the input video file. Can be absolute or relative to current directory.
|
||||
.DESCRIPTION
|
||||
Gets the total frame count of a video file using ffprobe.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Video
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$VideoPath = $Video
|
||||
|
||||
if (-not (Test-Path $VideoPath)) {
|
||||
Write-Error ('Video file not found: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$totalFrames = & ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 $VideoPath 2>&1 | Select-Object -First 1
|
||||
|
||||
if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') {
|
||||
Write-Error ('Could not determine frame count for: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ('Total frames: {0}' -f $totalFrames)
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Get-LastFrame.ps1" %*
|
||||
@@ -0,0 +1,78 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts a specific frame or the last frame of a video file to a PNG image using ffmpeg.
|
||||
.PARAMETER Video
|
||||
Path to the input video file. Can be absolute or relative to current directory.
|
||||
.PARAMETER Image
|
||||
Path to the output PNG image file. Can be absolute or relative to current directory.
|
||||
.PARAMETER Frame
|
||||
0-based frame index to extract. Negative values count from the end:
|
||||
-1 extracts the last frame (default), -2 extracts second-to-last, etc.
|
||||
The resulting frame index is clamped to the valid range (0 to total frames - 1).
|
||||
.DESCRIPTION
|
||||
Extracts a specific frame of a video file to a PNG image using ffmpeg.
|
||||
Supports negative frame indices to count from the end of the video.
|
||||
Supports -WhatIf and -Confirm for safe execution.
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Video,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Image,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$Frame = -1
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$VideoPath = $Video
|
||||
|
||||
if (-not (Test-Path $VideoPath)) {
|
||||
Write-Error ('Video file not found: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
$totalFrames = & ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 $VideoPath 2>&1 | Select-Object -First 1
|
||||
|
||||
if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') {
|
||||
Write-Error ('Could not determine frame count for: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$totalFramesInt = [int]$totalFrames
|
||||
$maxFrameIndex = $totalFramesInt - 1
|
||||
|
||||
$frameToExtract = if ($Frame -lt 0) {
|
||||
# Negative values count from the end
|
||||
$calculatedFrame = $totalFramesInt + $Frame
|
||||
# Clamp to valid range [0, maxFrameIndex]
|
||||
[Math]::Max(0, [Math]::Min($calculatedFrame, $maxFrameIndex))
|
||||
} else {
|
||||
# Positive values are direct indices
|
||||
if ($Frame -gt $maxFrameIndex) {
|
||||
Write-Error ('Frame index {0} is out of range. Valid range: 0 to {1}' -f $Frame, $maxFrameIndex)
|
||||
exit 1
|
||||
}
|
||||
$Frame
|
||||
}
|
||||
|
||||
Write-Host ('Total frames: {0} - extracting frame index {1}' -f $totalFramesInt, $frameToExtract)
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($Image, ('Extract frame {0} to PNG' -f $frameToExtract))) {
|
||||
$videoFilter = 'select=eq(n\,{0})' -f $frameToExtract
|
||||
$ffmpegStderr = & ffmpeg -y -i $VideoPath -vf $videoFilter -vsync vfr -frames:v 1 -q:v 1 $Image 2>&1
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$ffmpegStderr | ForEach-Object { Write-Error $_ }
|
||||
Write-Error 'ffmpeg failed to extract the last frame.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ('Frame saved to: {0}' -f $Image)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Get-VideoAudio.ps1" %*
|
||||
@@ -0,0 +1,89 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts audio in either WAV or MP3 format from a video file.
|
||||
.PARAMETER Video
|
||||
Path to the input video file. Can be absolute or relative to current directory.
|
||||
.PARAMETER Output
|
||||
Path to the output audio file. By default, the output file name is derived from the video file name.
|
||||
.PARAMETER MP3
|
||||
When present, extracts audio in MP3 format instead of WAV. Always uses best audio quality at 320 kbps.
|
||||
.DESCRIPTION
|
||||
This script uses ffmpeg to extract audio from a video file in either WAV or MP3 format using ffmpeg.
|
||||
If the video file has no audio, then no output file is created and a message is displayed.
|
||||
.NOTES
|
||||
Requires ffmpeg to be installed and available in the system PATH.
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Video,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$Output,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$MP3
|
||||
)
|
||||
|
||||
try {
|
||||
if (-not (Test-Path $Video)) {
|
||||
throw "Video file not found: $Video"
|
||||
}
|
||||
|
||||
$videoPath = Resolve-Path $Video
|
||||
Write-Host "Processing video: $videoPath"
|
||||
|
||||
$ffprobe = Get-Command ffprobe -ErrorAction SilentlyContinue
|
||||
if (-not $ffprobe) {
|
||||
throw "ffprobe not found in PATH. Please install ffmpeg."
|
||||
}
|
||||
|
||||
$ffprobeOutput = & ffprobe -v error -select_streams a:0 -show_entries stream=codec_type -of default=noprint_wrappers=1:nokey=1 $videoPath 2>&1
|
||||
|
||||
if ($ffprobeOutput -ne 'audio') {
|
||||
Write-Host "No audio stream found in video file." -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrEmpty($Output)) {
|
||||
$videoBaseName = [System.IO.Path]::GetFileNameWithoutExtension($videoPath)
|
||||
$videoDir = [System.IO.Path]::GetDirectoryName($videoPath)
|
||||
|
||||
if ($MP3) {
|
||||
$Output = Join-Path $videoDir "$videoBaseName.mp3"
|
||||
}
|
||||
else {
|
||||
$Output = Join-Path $videoDir "$videoBaseName.wav"
|
||||
}
|
||||
}
|
||||
|
||||
$outputPath = $Output
|
||||
if (-not [System.IO.Path]::IsPathRooted($outputPath)) {
|
||||
$outputPath = Join-Path (Get-Location).Path $outputPath
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($outputPath, 'Extract audio from video')) {
|
||||
Write-Host "Extracting audio to: $outputPath"
|
||||
|
||||
if ($MP3) {
|
||||
ffmpeg -i $videoPath -vn -acodec libmp3lame -b:a 320k $outputPath -y -loglevel error
|
||||
}
|
||||
else {
|
||||
ffmpeg -i $videoPath -vn -acodec pcm_s16le -ar 48000 $outputPath -y -loglevel error
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffmpeg failed to extract audio with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $outputPath)) {
|
||||
throw "Output audio file was not created: $outputPath"
|
||||
}
|
||||
|
||||
Write-Host "Audio extracted successfully: $outputPath" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Error: $_"
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Get-VideoInfo.ps1" %*
|
||||
@@ -0,0 +1,15 @@
|
||||
Text_Prompt=prompt
|
||||
Model=type
|
||||
Resolution=resolution
|
||||
Number_of_frames=video_length
|
||||
Seed=seed
|
||||
Frames_Positions=frames_positions
|
||||
Guidance=guidance_scale
|
||||
Audio_Guidance_Scale=audio_guidance_scale
|
||||
Modality_Guidance=alt_guidance_scale
|
||||
Guidance_Rescale=alt_scale
|
||||
Num_Inference_Steps=num_inference_steps
|
||||
LoRAs=activated_loras
|
||||
LoRAs_Multipliers=loras_multipliers
|
||||
Creation_Date=creation_date
|
||||
Generation_Time=generation_time
|
||||
@@ -0,0 +1,153 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extracts and displays video metadata from the comment tag in a video file's format metadata.
|
||||
.PARAMETER Video
|
||||
Path to the input video file. Can be absolute or relative to current directory.
|
||||
.PARAMETER Full
|
||||
When present, outputs the parsed comment metadata as JSON.
|
||||
.DESCRIPTION
|
||||
This script uses ffprobe to extract video format metadata, parses the comment tag as JSON,
|
||||
and outputs the structured information with proper error handling.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Video,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[switch]$Full
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$VideoPath = $Video
|
||||
|
||||
if (-not (Test-Path -LiteralPath $VideoPath)) {
|
||||
Write-Error ('Video file not found: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
|
||||
try {
|
||||
$ffprobeOutput = & ffprobe -v quiet -print_format json -show_format -- $VideoPath 2>&1
|
||||
$ffprobeExit = $LASTEXITCODE
|
||||
|
||||
if ($ffprobeExit -ne 0) {
|
||||
$ffprobeOutput | ForEach-Object { Write-Error $_ }
|
||||
Write-Error ('ffprobe failed to read video file: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$raw = $ffprobeOutput | ConvertFrom-Json
|
||||
|
||||
if (-not $raw -or -not $raw.format) {
|
||||
Write-Error ('ffprobe did not return valid format metadata for: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $raw.format.tags -or -not $raw.format.tags.comment) {
|
||||
Write-Error ('Video file does not contain a comment tag in format metadata: {0}' -f $VideoPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$commentJson = $raw.format.tags.comment
|
||||
|
||||
try {
|
||||
$parsedComment = $commentJson | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
Write-Error ('Failed to parse comment tag as JSON: {0}' -f $_.Exception.Message)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$output = $parsedComment | ConvertTo-Json -Depth 20
|
||||
|
||||
if ($Full) {
|
||||
Write-Output $output
|
||||
}
|
||||
|
||||
Write-Output '----------------------------------------'
|
||||
Write-Output ('File Name: {0}' -f $Video)
|
||||
|
||||
$iniPath = Join-Path $PSScriptRoot 'Get-VideoInfo.ini'
|
||||
if (-not (Test-Path -LiteralPath $iniPath)) {
|
||||
Write-Error ('INI configuration file not found: {0}' -f $iniPath)
|
||||
exit 1
|
||||
}
|
||||
|
||||
$iniLines = Get-Content -LiteralPath $iniPath -Encoding UTF8
|
||||
foreach ($line in $iniLines) {
|
||||
$trimmedLine = $line.Trim()
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($trimmedLine) -or $trimmedLine.StartsWith('#')) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($trimmedLine -notmatch '^([^=]+)=([^=]+)$') {
|
||||
continue
|
||||
}
|
||||
|
||||
$displayName = $matches[1].Trim()
|
||||
$jsonFieldName = $matches[2].Trim()
|
||||
|
||||
$displayNameFormatted = $displayName -replace '_', ' '
|
||||
|
||||
$fieldExists = $parsedComment.PSObject.Properties.Name -contains $jsonFieldName
|
||||
|
||||
if (-not $fieldExists) {
|
||||
$valueDisplay = 'Not found'
|
||||
}
|
||||
else {
|
||||
$fieldValue = $parsedComment.$jsonFieldName
|
||||
|
||||
if ($null -eq $fieldValue) {
|
||||
$valueDisplay = 'Not found'
|
||||
}
|
||||
elseif ($jsonFieldName -eq 'generation_time') {
|
||||
$totalSeconds = [int]$fieldValue
|
||||
$minutes = [Math]::Floor($totalSeconds / 60)
|
||||
$seconds = $totalSeconds % 60
|
||||
$valueDisplay = ('{0}s ({1}m {2}s)' -f $totalSeconds, $minutes, $seconds)
|
||||
}
|
||||
elseif ($jsonFieldName -eq 'creation_date') {
|
||||
try {
|
||||
$dateTime = [DateTime]::Parse($fieldValue)
|
||||
$valueDisplay = $dateTime.ToString('yyyy-MM-dd HH:mm:ss')
|
||||
}
|
||||
catch {
|
||||
$valueDisplay = $fieldValue -replace 'T', ' '
|
||||
}
|
||||
}
|
||||
elseif ($jsonFieldName -eq 'video_length') {
|
||||
$fps = 24.0
|
||||
$fieldExists = $parsedComment.PSObject.Properties.Name -contains 'force_fps'
|
||||
|
||||
if ($fieldExists) {
|
||||
$fps = [double]$parsedComment.force_fps
|
||||
}
|
||||
|
||||
$frames = [int]$fieldValue
|
||||
$secondsAt24fps = [Math]::Round($frames / $fps, 1)
|
||||
$valueDisplay = ('{0} frames ({1}s, {2} fps)' -f $frames, $secondsAt24fps, $fps)
|
||||
}
|
||||
elseif ($fieldValue -is [array]) {
|
||||
$valueDisplay = ($fieldValue | ConvertTo-Json -Compress -Depth 10)
|
||||
}
|
||||
else {
|
||||
$valueDisplay = $fieldValue
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ('{0}: {1}' -f $displayNameFormatted, $valueDisplay)
|
||||
}
|
||||
|
||||
Write-Output '----------------------------------------'
|
||||
}
|
||||
catch {
|
||||
Write-Error ('Unexpected error: {0}' -f $_.Exception.Message)
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Video Processing PowerShell Scripts
|
||||
|
||||
A collection of PowerShell scripts for video processing tasks using ffmpeg and ffprobe.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **PowerShell 7+** (pwsh.exe)
|
||||
- **ffmpeg** - Must be in system PATH
|
||||
- **ffprobe** - Must be in system PATH
|
||||
- **rife-ncnn-vulkan** - Must be in system PATH
|
||||
|
||||
## ffmpeg
|
||||
|
||||
To install ffmpeg on Windows, download it from [ffmpeg.org](https://ffmpeg.org/download.html) and add it to your system PATH.
|
||||
|
||||
## rife-ncnn-vulkan
|
||||
|
||||
To install rife-ncnn-vulkan on Windows, download it from [rife-ncnn-vulkan](https://github.com/nihui/rife-ncnn-vulkan) and add it to your system PATH.
|
||||
|
||||
---
|
||||
|
||||
## Scripts Overview
|
||||
|
||||
| Script | Purpose |
|
||||
| :--- | :--- |
|
||||
| Clone-Video.ps1 | Clone/copy video files with optional frame range and sequence extraction |
|
||||
| Compare-Videos.ps1 | Combine 2 to 4 video files into a single 1920x1080 side-by-side or grid comparison video |
|
||||
| Create-FullSubtitle.ps1 | Creates a full subtitle file from a video file and two SRT subtitle files by combining them with adjusted timing and sequential numbering |
|
||||
| Create-ShortClip.ps1 | Create short clips from videos with cropping and overlay options |
|
||||
| Crop-Clips.ps1 | Batch crop videos generated by LTX-2 from 1920x1088 or 1280x704 resolution |
|
||||
| Crop-ClipsKling.ps1 | Batch crop videos generated by Kling from 1920x1072 or 1928x1072 resolution |
|
||||
| Crop-ClipsWan.ps1 | Batch crop videos generated by Wan from 1928x1076 resolution |
|
||||
| Get-Frames.ps1 | Extract a range of frames to PNG images |
|
||||
| Get-FramesCount.ps1 | Get total frame count of a video |
|
||||
| Get-LastFrame.ps1 | Extract a specific frame or last frame to PNG |
|
||||
| Get-VideoAudio.ps1 | Extracts audio in either WAV or MP3 format from a video file |
|
||||
| Get-VideoInfo.ps1 | Extract and display video metadata from comment tags |
|
||||
| Stich-InBetween.ps1 | Generate interpolated in-between frames between two images using RIFE and encode to video |
|
||||
|
||||
**Important**:
|
||||
|
||||
- All scripts require PowerShell 7+ (use `pwsh.exe`)
|
||||
- Each script has its own Windows command file (`.cmd`) that calls the PowerShell script
|
||||
|
||||
## Scripts Installation
|
||||
|
||||
The scripts are available from two locations:
|
||||
- SVN: **public-svn**, project: **Video Processing Scripts**. Add the project folder to your PATH environment variable to access the scripts from any location.
|
||||
- Git: **git.gurusushi.dyndns.org**, repository: **guru/Video-Processing-Scripts**.
|
||||
|
||||
Add the project folder to your PATH environment variable to access the scripts from any location.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All scripts require PowerShell 7+ (use `pwsh.exe`)
|
||||
- File extensions (`.mp4`, `.png`) are automatically added if not provided
|
||||
- Paths can be absolute or relative to current directory
|
||||
- Use `.skip.mp4` extension to exclude files from batch processing
|
||||
- Backup files (`.bak.mp4`) are automatically skipped in batch operations
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: "Video file not found"
|
||||
|
||||
- Check that the file path is correct
|
||||
- Ensure file exists at specified location
|
||||
- Use absolute paths if relative paths aren't working
|
||||
|
||||
### Error: "ffmpeg failed" or "ffprobe failed"
|
||||
|
||||
- Verify ffmpeg and ffprobe are installed and in PATH
|
||||
- Check that the video file is not corrupted
|
||||
- Ensure sufficient disk space for output
|
||||
|
||||
### Error: "Frame index out of range"
|
||||
|
||||
- Use `Get-FramesCount.ps1` to check valid frame range
|
||||
- Frame indices are 0-based (first frame is 0)
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Stich-InBetween.ps1" %*
|
||||
@@ -0,0 +1,231 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generates interpolated frames between two images using RIFE and creates a video.
|
||||
|
||||
.DESCRIPTION
|
||||
This script uses rife-ncnn-vulkan to generate intermediate frames between a first
|
||||
and last image, then encodes the result into an H.264 video using ffmpeg.
|
||||
|
||||
The script:
|
||||
- Validates input image files exist
|
||||
- Creates a dedicated temporary directory for input images
|
||||
- Copies the first and last images to the dedicated temp directory
|
||||
- Runs rife-ncnn-vulkan to generate interpolated frames
|
||||
- Filters generated frames to keep only the desired sequence
|
||||
- Encodes final video with specified FPS
|
||||
|
||||
.PARAMETER First
|
||||
Full or relative path to the first image file (e.g., "First.png" or "D:\Images\First.png").
|
||||
|
||||
.PARAMETER Last
|
||||
Full or relative path to the last image file (e.g., "Last.png" or "D:\Images\Last.png").
|
||||
|
||||
.PARAMETER TempDir
|
||||
Full absolute path to a temporary directory for intermediate PNG frames.
|
||||
Must end with a backslash (\). Defaults to a system temp subdirectory.
|
||||
|
||||
.PARAMETER Frames
|
||||
Number of frames to generate in the output video. Must be greater than 0.
|
||||
|
||||
.PARAMETER Video
|
||||
Output video filename. If relative, will be resolved against current directory.
|
||||
|
||||
.PARAMETER FPS
|
||||
Frames per second for the output video. Default is 24.
|
||||
|
||||
.EXAMPLE
|
||||
.\Stich-InBetween.ps1 -First "frame1.png" -Last "frame2.png" -Frames 30 -Video "output.mp4"
|
||||
|
||||
.EXAMPLE
|
||||
.\Stich-InBetween.ps1 -First "D:\Images\start.png" -Last "D:\Images\end.png" -Frames 60 -Video "D:\Output\video.mp4" -FPS 30
|
||||
|
||||
.NOTES
|
||||
Requires:
|
||||
- rife-ncnn-vulkan.exe in the system PATH
|
||||
- ffmpeg in the system PATH
|
||||
- PowerShell 7 or later
|
||||
|
||||
The script automatically locates rife-ncnn-vulkan.exe from PATH and expects
|
||||
the rife-v4 model directory to be in the same directory as the executable.
|
||||
|
||||
Supports -WhatIf and -Confirm for safe execution.
|
||||
#>
|
||||
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$First,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Last,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$TempDir,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateScript({ $_ -gt 0 })]
|
||||
[int]$Frames,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Video,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$FPS = 24
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Validate and expand parameters ---
|
||||
|
||||
# First and Last - resolve to absolute paths
|
||||
if (-not [System.IO.Path]::IsPathRooted($First)) {
|
||||
$First = Join-Path (Get-Location).Path $First
|
||||
}
|
||||
if (-not [System.IO.Path]::IsPathRooted($Last)) {
|
||||
$Last = Join-Path (Get-Location).Path $Last
|
||||
}
|
||||
|
||||
# TempDir
|
||||
if (-not $TempDir) {
|
||||
$TempDir = [System.IO.Path]::GetTempPath() + [System.Guid]::NewGuid().ToString() + '\'
|
||||
} else {
|
||||
if (-not [System.IO.Path]::IsPathRooted($TempDir)) {
|
||||
throw "TempDir must be a full absolute path, not a relative path: '$TempDir'"
|
||||
}
|
||||
if (-not $TempDir.EndsWith('\')) {
|
||||
throw "TempDir must end with a backslash (\): '$TempDir'"
|
||||
}
|
||||
}
|
||||
|
||||
# Video - if relative, resolve against current directory
|
||||
if (-not [System.IO.Path]::IsPathRooted($Video)) {
|
||||
$Video = Join-Path (Get-Location).Path $Video
|
||||
}
|
||||
|
||||
# --- Compute derived variables ---
|
||||
|
||||
$Count = 4 + (($Frames - 1) * 2)
|
||||
$EndsAt = [int]($Count / 2)
|
||||
|
||||
# --- Validate source files ---
|
||||
|
||||
if (-not (Test-Path -LiteralPath $First -PathType Leaf)) {
|
||||
throw "First image does not exist: $First"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Last -PathType Leaf)) {
|
||||
throw "Last image does not exist: $Last"
|
||||
}
|
||||
|
||||
# --- Delete output video if it exists ---
|
||||
|
||||
if (Test-Path -LiteralPath $Video -PathType Leaf) {
|
||||
if ($PSCmdlet.ShouldProcess($Video, 'Delete existing output video')) {
|
||||
Remove-Item -LiteralPath $Video -Force
|
||||
Write-Host "Deleted existing output video: $Video"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Create dedicated temp directory for input images ---
|
||||
|
||||
$InputTempDir = [System.IO.Path]::GetTempPath() + [System.Guid]::NewGuid().ToString() + '\'
|
||||
$inputTempDirPath = $InputTempDir.TrimEnd('\')
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($InputTempDir, 'Create dedicated input temp directory')) {
|
||||
New-Item -ItemType Directory -Path $inputTempDirPath | Out-Null
|
||||
Write-Host "Created input temp directory: $InputTempDir"
|
||||
}
|
||||
|
||||
# --- Copy input images to dedicated temp directory ---
|
||||
|
||||
$FirstFilename = [System.IO.Path]::GetFileName($First)
|
||||
$LastFilename = [System.IO.Path]::GetFileName($Last)
|
||||
|
||||
$InputFirstPath = Join-Path $inputTempDirPath $FirstFilename
|
||||
$InputLastPath = Join-Path $inputTempDirPath $LastFilename
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($First, "Copy to $InputFirstPath")) {
|
||||
Copy-Item -LiteralPath $First -Destination $InputFirstPath -Force
|
||||
Write-Host "Copied $First -> $InputFirstPath"
|
||||
}
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($Last, "Copy to $InputLastPath")) {
|
||||
Copy-Item -LiteralPath $Last -Destination $InputLastPath -Force
|
||||
Write-Host "Copied $Last -> $InputLastPath"
|
||||
}
|
||||
|
||||
# --- Locate rife-ncnn-vulkan.exe ---
|
||||
|
||||
$rifeExe = Get-Command -Name "rife-ncnn-vulkan.exe" -ErrorAction SilentlyContinue
|
||||
if (-not $rifeExe) {
|
||||
throw "rife-ncnn-vulkan.exe not found in PATH. Please ensure it is installed and added to the system PATH."
|
||||
}
|
||||
|
||||
$rifeExePath = $rifeExe.Source
|
||||
$rifeDir = Split-Path -Parent $rifeExePath
|
||||
$rifeModelPath = Join-Path $rifeDir "rife-v4"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $rifeModelPath -PathType Container)) {
|
||||
throw "RIFE model directory not found at: $rifeModelPath"
|
||||
}
|
||||
|
||||
Write-Host "Found rife-ncnn-vulkan.exe at: $rifeExePath"
|
||||
Write-Host "Using model path: $rifeModelPath"
|
||||
|
||||
# --- Prepare TempDir ---
|
||||
|
||||
$tempDirPath = $TempDir.TrimEnd('\')
|
||||
if (Test-Path -LiteralPath $tempDirPath) {
|
||||
if ($PSCmdlet.ShouldProcess($TempDir, 'Clear temporary directory')) {
|
||||
Get-ChildItem -LiteralPath $tempDirPath -Force | Remove-Item -Recurse -Force
|
||||
Write-Host "Cleared TempDir: $TempDir"
|
||||
}
|
||||
} else {
|
||||
if ($PSCmdlet.ShouldProcess($TempDir, 'Create temporary directory')) {
|
||||
New-Item -ItemType Directory -Path $tempDirPath | Out-Null
|
||||
Write-Host "Created TempDir: $TempDir"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Execute rife-ncnn-vulkan ---
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($TempDir, "Generate interpolated frames with rife-ncnn-vulkan (Count=$Count)")) {
|
||||
Write-Host "Executing rife-ncnn-vulkan.exe (Count=$Count, EndsAt=$EndsAt)..."
|
||||
& $rifeExePath -0 $FirstFilename -1 $LastFilename -i $InputTempDir -o $TempDir -n $Count -m $rifeModelPath
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "rife-ncnn-vulkan.exe failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
# --- Clean up input temp directory ---
|
||||
if (Test-Path -LiteralPath $inputTempDirPath) {
|
||||
Remove-Item -LiteralPath $inputTempDirPath -Recurse -Force
|
||||
Write-Host "Cleaned up input temp directory: $InputTempDir"
|
||||
}
|
||||
|
||||
# --- Delete unwanted frames from TempDir ---
|
||||
# Always delete 00000001.png; delete all files with numeric index > $EndsAt
|
||||
|
||||
$filesToDelete = Get-ChildItem -LiteralPath $tempDirPath -Filter "*.png" | Where-Object {
|
||||
$index = [int]($_.BaseName)
|
||||
$index -eq 1 -or $index -gt $EndsAt
|
||||
}
|
||||
foreach ($file in $filesToDelete) {
|
||||
if ($PSCmdlet.ShouldProcess($file.FullName, 'Delete unwanted intermediate frame')) {
|
||||
Remove-Item -LiteralPath $file.FullName -Force
|
||||
Write-Host "Deleted: $($file.Name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Execute ffmpeg ---
|
||||
|
||||
if ($PSCmdlet.ShouldProcess($Video, 'Create output video with ffmpeg')) {
|
||||
Write-Host "Executing ffmpeg (FPS=$FPS)..."
|
||||
& ffmpeg -y -framerate $FPS -i "${TempDir}%08d.png" -crf 8 -c:v libx264 -pix_fmt yuv420p "$Video"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffmpeg failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host "Video created successfully: $Video" -ForegroundColor Green
|
||||
}
|
||||
Reference in New Issue
Block a user