Compare commits

..

17 Commits

Author SHA1 Message Date
guru 8addeb0db1 Add upscayl-bin command line logging to Clone-Image.ps1 for debugging upscaling operations 2026-06-10 17:58:20 -04:00
guru fb4f371450 Add -Upscale parameter to Clone-Image.ps1 for AI upscaling with upscayl-bin before ffmpeg processing, add -Model and -Keep parameters for model selection and output preservation, and update Stich-InBetween.ps1 to use RIFE_HOME and NCNN_GPU_ID environment variables for TNTwise fork compatibility 2026-06-10 17:36:45 -04:00
guru 1e58558308 Add -LTX parameter to select closest LTX-2 canonical resolution for specified aspect ratio when using -Size with HD/1080p/720p/480p presets 2026-06-10 11:32:15 -04:00
guru 46335f2cb1 Fix file name display to show basename instead of full path when using wildcard patterns 2026-06-09 22:37:53 -04:00
guru a057ab0d1d Add wildcard support to Get-VideoInfo.ps1 for processing multiple video files with pattern matching and sequential metadata display 2026-06-09 20:50:09 -04:00
guru 9cbe54d8d3 Add NoAudio and CRF parameters to re-encode detection logic and update parameter documentation to clarify when re-encoding is forced 2026-06-07 21:16:06 -04:00
guru fcfa99dddc Add -Reverse parameter to reencode video backward while keeping audio forward, and add 1284x716 to 1280x720 crop support in Crop-ClipsWan 2026-06-07 16:44:21 -04:00
guru 201cc2625f Add compact JSON formatting for single-element arrays in video info display 2026-06-04 08:58:31 -04:00
guru a687bb5348 Add mixed-orientation layout for -Clip mode with landscape video at native size and portrait video scaled to match height 2026-06-03 15:26:45 -04:00
guru edf9f6b874 Add support for silver, gray, charcoal, and custom hex color pillarbox effects with color:RRGGBB syntax 2026-06-01 09:08:16 -04:00
guru fab3020dd0 Add -Clip parameter to remove letterboxing from 2-video comparisons by matching output height to scaled source height 2026-06-01 02:28:59 -04:00
guru 6b5a67dddd Add support for WebM and MKV output containers with codec-aware stream copying and transcoding 2026-06-01 01:15:09 -04:00
guru ad31100d64 Add actual encoded resolution and frame rate detection from ffprobe video stream data 2026-05-31 22:18:37 -04:00
guru 5bf1b5caae Add robust parsing for force_fps field to handle non-numeric values in video metadata 2026-05-31 21:02:37 -04:00
guru 567af452f1 Add optional -Target parameter with automatic filename generation based on -Effect value 2026-05-30 14:55:30 -04:00
guru d9c0eb3303 Add WebM/MKV support with automatic transcoding to H.264/AAC for MP4 compatibility 2026-05-30 14:06:57 -04:00
guru 1e78cc67ae Add safe property accessor to Get-VideoInfo function for strict mode compatibility 2026-05-30 12:13:54 -04:00
11 changed files with 1457 additions and 66 deletions
+2
View File
@@ -0,0 +1,2 @@
@echo off
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Clone-Image.ps1" %*
+492
View File
@@ -0,0 +1,492 @@
<#
.SYNOPSIS
Clones (copies) an image file using ffmpeg.
Supports PNG, JPEG, WEBP, BMP, AVIF and TIFF.
.PARAMETER Source
Path to the input image file. Can be absolute or relative to current directory.
If the path has no extension, .png, .jpg, .jpeg, .webp, .bmp, .avif, .tif and .tiff are tried in turn.
.PARAMETER Target
Path to the output image file. Can be absolute or relative to current directory.
If the path has no recognised image extension, .png is appended.
.PARAMETER Size
Size of the output image. Uses the input image's size if not specified.
Possible values: 480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K, or custom size in format "width:height".
When specified, the source is scaled (preserving aspect ratio) so it fully covers the requested
width and height, then centre-cropped to the exact target size. The original pixel aspect ratio
is preserved (no warping).
.PARAMETER Quality
Quality factor value for lossy output formats (jpeg, webp and avif). Lower values mean better quality.
- jpeg: passed to -q:v (typical range 1-31, lower is better).
- webp: inverted internally and passed to libwebp's -quality (0-100, higher is better) so the
"lower = better quality" semantic is consistent across formats.
- avif: passed to libaom-av1's -crf (0-63, lower is better).
Ignored for png, bmp and tiff (lossless).
.PARAMETER Compression
Compression level for png and webp formats. For png, this is libpng's -compression_level (0-9,
higher means smaller files but slower encoding). For webp, this is libwebp's -compression_level
(0-6, higher means smaller files but slower encoding). Ignored for jpeg, avif, bmp and tiff.
.PARAMETER Metadata
When present, copies available metadata from the source image file to the target image file.
.PARAMETER Upscale
Optional upscaling factor (2, 3 or 4) applied to the source image with upscayl-bin.exe
before ffmpeg runs. The upscaled image is written to a Windows temp PNG and used as the
ffmpeg input, so any -Size / -LTX cover-scale-then-crop is applied to the upscaled image.
Requires upscayl-bin.exe in PATH, the UPSCAYL_HOME environment variable pointing to the
upscayl-bin install directory, and the NCNN_GPU_ID environment variable set to the desired
GPU device ID (-1 for CPU, 0/1/2/... for GPU).
.PARAMETER Model
Upscayl model name (without extension) to use when -Upscale is specified. Defaults to
'4xNomos8kSC'. The model files (<Model>.bin and <Model>.param) must exist under
%UPSCAYL_HOME%\models. Ignored when -Upscale is not specified.
.PARAMETER Keep
Optional path to save a copy of the upscayl-bin output (i.e. the upscaled image, before
ffmpeg's cover-scale-then-crop). The file extension determines the format; if it differs
from .png (the upscayl output format) the temp PNG is converted with ffmpeg using the
appropriate per-format encoder. If the extension is missing or unrecognised, .png is
appended. Only valid when -Upscale is specified.
.PARAMETER LTX
When present, overrides the dimensions selected by -Size with the closest LTX-2 (Wan2GP by DeepBeepMeep)
canonical resolution for the source's aspect ratio. Only valid when -Size is one of HD, 1080p, 720p or 480p.
The closest aspect ratio is picked from the following buckets:
- HD / 1080p: 1920x1088 (16:9), 1088x1920 (9:16), 1664x960 (21:9), 960x1664 (9:21)
- 720p: 1280x704 (16:9), 704x1280 (9:16), 1088x576 (21:9), 576x1088 (9:21)
- 480p: 832x448 (16:9), 448x832 (9:16), 896x512 (4:3), 512x896 (3:4)
The cover-scale-then-centre-crop logic still applies, so the source is fitted to the picked LTX dimensions
while preserving the original pixel aspect ratio (no warping).
.DESCRIPTION
This script uses ffmpeg to clone (copy) an image file with optional size change and quality/compression settings.
Supports -WhatIf and -Confirm for safe execution.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Target,
[Parameter(Mandatory = $false)]
[string]$Size = $null,
[Parameter(Mandatory = $false)]
[int]$Quality = -1,
[Parameter(Mandatory = $false)]
[int]$Compression = -1,
[Parameter(Mandatory = $false)]
[switch]$Metadata,
[Parameter(Mandatory = $false)]
[switch]$LTX,
[Parameter(Mandatory = $false)]
[ValidateSet(0, 2, 3, 4)]
[int]$Upscale = 0,
[Parameter(Mandatory = $false)]
[string]$Model = '4xNomos8kSC',
[Parameter(Mandatory = $false)]
[string]$Keep = $null
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$SourcePath = $Source
# Resolve source path: accept common image extensions. If the path doesn't
# exist as-is, try appending each supported extension in turn.
$supportedSourceExts = @('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.avif', '.tif', '.tiff')
if (-not (Test-Path -LiteralPath $SourcePath)) {
$resolved = $null
foreach ($ext in $supportedSourceExts) {
$candidate = "$SourcePath$ext"
if (Test-Path -LiteralPath $candidate) {
$resolved = $candidate
break
}
}
if ($resolved) {
$SourcePath = $resolved
}
}
if (-not (Test-Path -LiteralPath $SourcePath)) {
Write-Error ('Source image file not found: {0}' -f $Source)
exit 1
}
$TargetPath = $Target
# Determine output format from the target extension. Unrecognised/missing
# extensions fall back to .png.
$supportedTargetExts = @('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.avif', '.tif', '.tiff')
$currentTargetExt = [System.IO.Path]::GetExtension($TargetPath).ToLowerInvariant()
if ($supportedTargetExts -notcontains $currentTargetExt) {
$TargetPath += '.png'
$currentTargetExt = '.png'
}
# Normalise extension to a single format key.
switch ($currentTargetExt) {
'.jpeg' { $targetFormat = 'jpeg' }
'.jpg' { $targetFormat = 'jpeg' }
'.tif' { $targetFormat = 'tiff' }
'.tiff' { $targetFormat = 'tiff' }
default { $targetFormat = $currentTargetExt.TrimStart('.') }
}
$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
}
$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
# Probe source image dimensions. Used both for informational output and (when
# -LTX is set) to select the closest LTX-2 canonical resolution.
$ffprobeDimOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -- $SourcePath 2>&1
$sourceDimensions = ''
$sourceWidth = 0
$sourceHeight = 0
if ($LASTEXITCODE -eq 0) {
$dimLine = $ffprobeDimOutput | Select-Object -First 1
if ($dimLine) {
$sourceDimensions = ([string]$dimLine).Trim()
if ($sourceDimensions -match '^(\d+)x(\d+)$') {
$sourceWidth = [int]$Matches[1]
$sourceHeight = [int]$Matches[2]
}
}
}
if ($LTX) {
$ltxKey = $Size
if ($ltxKey -eq 'HD') { $ltxKey = '1080p' }
$ltxMap = @{
'1080p' = @(
@{ W = 1920; H = 1088 },
@{ W = 1088; H = 1920 },
@{ W = 1664; H = 960 },
@{ W = 960; H = 1664 }
)
'720p' = @(
@{ W = 1280; H = 704 },
@{ W = 704; H = 1280 },
@{ W = 1088; H = 576 },
@{ W = 576; H = 1088 }
)
'480p' = @(
@{ W = 832; H = 448 },
@{ W = 448; H = 832 },
@{ W = 896; H = 512 },
@{ W = 512; H = 896 }
)
}
if (-not $ltxMap.ContainsKey($ltxKey)) {
Write-Error '-LTX requires -Size to be one of: 480p, 720p, 1080p, HD'
exit 1
}
if ($sourceWidth -le 0 -or $sourceHeight -le 0) {
Write-Error 'Could not determine source image dimensions; -LTX cannot pick an aspect-ratio bucket.'
exit 1
}
$srcRatio = [double]$sourceWidth / [double]$sourceHeight
$best = $null
$bestDiff = [double]::MaxValue
foreach ($entry in $ltxMap[$ltxKey]) {
$r = [double]$entry.W / [double]$entry.H
$d = [Math]::Abs($r - $srcRatio)
if ($d -lt $bestDiff) {
$bestDiff = $d
$best = $entry
}
}
$scaleWidth = $best.W
$scaleHeight = $best.H
}
# Build the per-format encoder args. Quality is always interpreted as
# "lower = better" from the user's point of view; format-specific mapping
# happens here.
$encoderArgs = @()
switch ($targetFormat) {
'jpeg' {
$encoderArgs += @('-c:v', 'mjpeg', '-pix_fmt', 'yuvj420p')
if ($Quality -ge 0) {
$encoderArgs += @('-q:v', $Quality.ToString())
} else {
$encoderArgs += @('-q:v', '2')
}
}
'png' {
$encoderArgs += @('-c:v', 'png')
if ($Compression -ge 0) {
$encoderArgs += @('-compression_level', $Compression.ToString())
}
}
'webp' {
$encoderArgs += @('-c:v', 'libwebp')
if ($Quality -ge 0) {
# Invert so that "lower = better" matches JPEG/AVIF behaviour.
$webpQuality = 100 - $Quality
if ($webpQuality -lt 0) { $webpQuality = 0 }
if ($webpQuality -gt 100) { $webpQuality = 100 }
$encoderArgs += @('-quality', $webpQuality.ToString())
} else {
$encoderArgs += @('-quality', '90')
}
if ($Compression -ge 0) {
$encoderArgs += @('-compression_level', $Compression.ToString())
}
}
'avif' {
$encoderArgs += @('-c:v', 'libaom-av1', '-still-picture', '1', '-cpu-used', '4')
if ($Quality -ge 0) {
$encoderArgs += @('-crf', $Quality.ToString())
} else {
$encoderArgs += @('-crf', '25')
}
}
'bmp' {
$encoderArgs += @('-c:v', 'bmp')
}
'tiff' {
$encoderArgs += @('-c:v', 'tiff')
}
}
# --- Optional upscayl-bin upscaling step (runs before ffmpeg). ---
# When -Upscale is given (2/3/4) the source image is upscaled with upscayl-bin
# into a temp PNG; that temp PNG then becomes the ffmpeg input so any -Size /
# -LTX cover-scale-then-crop is applied to the already-upscaled image.
$upscaledTempPath = $null
if ($Upscale -gt 0) {
$upscaylExe = Get-Command -Name 'upscayl-bin.exe' -ErrorAction SilentlyContinue
if (-not $upscaylExe) {
Write-Error 'upscayl-bin.exe not found in PATH. Install upscayl-bin and add it to the system PATH (see mkdocs/Upscalers.md).'
exit 1
}
$upscaylExePath = $upscaylExe.Source
$upscaylHome = $env:UPSCAYL_HOME
if (-not $upscaylHome) {
Write-Error 'UPSCAYL_HOME environment variable is not set. Set it to the upscayl-bin install directory.'
exit 1
}
$upscaylHome = $upscaylHome.TrimEnd('\', '/')
if (-not (Test-Path -LiteralPath $upscaylHome -PathType Container)) {
Write-Error ('UPSCAYL_HOME directory does not exist: {0}' -f $upscaylHome)
exit 1
}
$modelsDir = Join-Path $upscaylHome 'models'
if (-not (Test-Path -LiteralPath $modelsDir -PathType Container)) {
Write-Error ('Upscayl models directory not found: {0}' -f $modelsDir)
exit 1
}
$modelBin = Join-Path $modelsDir ('{0}.bin' -f $Model)
$modelParam = Join-Path $modelsDir ('{0}.param' -f $Model)
if (-not (Test-Path -LiteralPath $modelBin -PathType Leaf) -or -not (Test-Path -LiteralPath $modelParam -PathType Leaf)) {
Write-Error ('Upscayl model files not found for model ''{0}'' under {1} (expected {0}.bin and {0}.param).' -f $Model, $modelsDir)
exit 1
}
$gpuId = $env:NCNN_GPU_ID
if (-not $gpuId) {
Write-Error 'NCNN_GPU_ID environment variable is not set. Set it to -1 (CPU) or 0/1/2/... (GPU device index).'
exit 1
}
if ($gpuId -notmatch '^-?\d+$') {
Write-Error ('NCNN_GPU_ID must be an integer (-1 for CPU, 0+ for GPU). Current value: {0}' -f $gpuId)
exit 1
}
$upscaledTempPath = Join-Path ([System.IO.Path]::GetTempPath()) (('upscayl-{0}.png') -f ([System.Guid]::NewGuid().ToString()))
Write-Host ('Upscaling source with upscayl-bin (x{0}, model={1}, gpu={2})...' -f $Upscale, $Model, $gpuId)
Write-Host (' Upscayl temp file: {0}' -f $upscaledTempPath)
$upscaylArgs = @('-i', $SourcePath, '-o', $upscaledTempPath, '-s', $Upscale.ToString(), '-n', $Model, '-m', $modelsDir, '-g', $gpuId)
$upscaylCmdLine = '"{0}" {1}' -f $upscaylExePath, (($upscaylArgs | ForEach-Object {
if ($_ -match '\s') { '"{0}"' -f $_ } else { $_ }
}) -join ' ')
Write-Host (' Upscayl command: {0}' -f $upscaylCmdLine)
if ($PSCmdlet.ShouldProcess($SourcePath, ('Upscale x{0} with upscayl-bin into temp file' -f $Upscale))) {
$upscaylOutput = & $upscaylExePath @upscaylArgs 2>&1
$upscaylExit = $LASTEXITCODE
if ($upscaylExit -ne 0) {
Write-Host 'upscayl-bin error output:' -ForegroundColor Red
$upscaylOutput | ForEach-Object { Write-Host $_ }
Write-Error ('upscayl-bin failed with exit code: {0}' -f $upscaylExit)
if (Test-Path -LiteralPath $upscaledTempPath) { Remove-Item -LiteralPath $upscaledTempPath -Force -ErrorAction SilentlyContinue }
exit 1
}
# Switch ffmpeg's input to the upscaled image.
$SourcePath = $upscaledTempPath
# Refresh informational source dimensions from the upscaled file.
$ffprobeDimOutput2 = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -- $SourcePath 2>&1
if ($LASTEXITCODE -eq 0) {
$dimLine2 = $ffprobeDimOutput2 | Select-Object -First 1
if ($dimLine2) {
$sourceDimensions = ([string]$dimLine2).Trim() + (' (upscaled x{0})' -f $Upscale)
}
}
# --- Optional -Keep: save a copy of the upscaled image. ---
if (-not [string]::IsNullOrWhiteSpace($Keep)) {
$KeepPath = $Keep
$keepExt = [System.IO.Path]::GetExtension($KeepPath).ToLowerInvariant()
if ($supportedTargetExts -notcontains $keepExt) {
$KeepPath += '.png'
$keepExt = '.png'
}
$KeepDir = Split-Path -Parent $KeepPath
if ($KeepDir -and -not (Test-Path -LiteralPath $KeepDir)) {
Write-Error ('Keep target directory does not exist: {0}' -f $KeepDir)
if (Test-Path -LiteralPath $upscaledTempPath) { Remove-Item -LiteralPath $upscaledTempPath -Force -ErrorAction SilentlyContinue }
exit 1
}
if ($keepExt -eq '.png') {
# Upscayl already wrote a PNG; just copy.
if ($PSCmdlet.ShouldProcess($KeepPath, 'Copy upscaled image (PNG)')) {
Copy-Item -LiteralPath $upscaledTempPath -Destination $KeepPath -Force
Write-Host ('Upscaled image saved to: {0}' -f $KeepPath)
}
} else {
# Convert PNG -> requested format with ffmpeg, using sane defaults.
switch ($keepExt) {
'.jpg' { $keepFormat = 'jpeg' }
'.jpeg' { $keepFormat = 'jpeg' }
'.tif' { $keepFormat = 'tiff' }
'.tiff' { $keepFormat = 'tiff' }
default { $keepFormat = $keepExt.TrimStart('.') }
}
$keepEncoderArgs = @()
switch ($keepFormat) {
'jpeg' { $keepEncoderArgs += @('-c:v', 'mjpeg', '-pix_fmt', 'yuvj420p', '-q:v', '2') }
'webp' { $keepEncoderArgs += @('-c:v', 'libwebp', '-quality', '90') }
'avif' { $keepEncoderArgs += @('-c:v', 'libaom-av1', '-still-picture', '1', '-cpu-used', '4', '-crf', '25') }
'bmp' { $keepEncoderArgs += @('-c:v', 'bmp') }
'tiff' { $keepEncoderArgs += @('-c:v', 'tiff') }
'png' { $keepEncoderArgs += @('-c:v', 'png') }
}
Write-Host ('Converting upscaled image to {0}: {1}' -f $keepFormat, $KeepPath)
if ($PSCmdlet.ShouldProcess($KeepPath, ('Save upscaled image as {0}' -f $keepFormat))) {
$keepFfmpegArgs = @('-y', '-i', $upscaledTempPath) + $keepEncoderArgs + @('-frames:v', '1', '-map_metadata', '-1', '--', $KeepPath)
$keepFfmpegOutput = & ffmpeg @keepFfmpegArgs 2>&1
$keepFfmpegExit = $LASTEXITCODE
if ($keepFfmpegExit -ne 0) {
Write-Host 'ffmpeg error output (Keep step):' -ForegroundColor Red
$keepFfmpegOutput | ForEach-Object { Write-Host $_ }
Write-Error ('ffmpeg failed to save upscaled image (-Keep) with exit code: {0}' -f $keepFfmpegExit)
if (Test-Path -LiteralPath $upscaledTempPath) { Remove-Item -LiteralPath $upscaledTempPath -Force -ErrorAction SilentlyContinue }
exit 1
}
Write-Host ('Upscaled image saved to: {0}' -f $KeepPath)
}
}
}
}
}
if (-not [string]::IsNullOrWhiteSpace($Keep) -and $Upscale -le 0) {
Write-Warning '-Keep was specified without -Upscale; ignoring (nothing to save).'
}
Write-Host ('Cloning image: {0}' -f $SourcePath)
Write-Host (' Target format: {0}' -f $targetFormat)
if ($sourceDimensions) {
Write-Host (' Source size: {0}' -f $sourceDimensions)
}
if ($scaleWidth -gt 0) {
if ($LTX) {
Write-Host (' Target size: {0}x{1} (LTX-2, cover-scale then centre-crop)' -f $scaleWidth, $scaleHeight)
} else {
Write-Host (' Target size: {0}x{1} (cover-scale then centre-crop)' -f $scaleWidth, $scaleHeight)
}
}
if ($Quality -ge 0) {
Write-Host (' Quality: {0} (lower = better)' -f $Quality)
}
if ($Compression -ge 0) {
Write-Host (' Compression: {0}' -f $Compression)
}
Write-Host (' Target: {0}' -f $TargetPath)
$ffmpegArgs = @(
'-y'
'-i', $SourcePath
)
if ($scaleWidth -gt 0) {
# Cover-scale (force_original_aspect_ratio=increase guarantees the scaled
# image fully covers WxH; the original pixel aspect ratio is preserved)
# then centre-crop to the exact requested size.
$videoFilter = 'scale={0}:{1}:force_original_aspect_ratio=increase,crop={0}:{1}' -f $scaleWidth, $scaleHeight
$ffmpegArgs += @('-vf', $videoFilter)
}
$ffmpegArgs += $encoderArgs
$ffmpegArgs += @('-frames:v', '1')
if ($Metadata) {
$ffmpegArgs += @('-map_metadata', '0')
} else {
$ffmpegArgs += @('-map_metadata', '-1')
}
$ffmpegArgs += @('--', $TargetPath)
if ($PSCmdlet.ShouldProcess($TargetPath, 'Clone image 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 image with exit code: {0}' -f $ffmpegExit)
exit 1
}
Write-Host ('Image cloned successfully to: {0}' -f $TargetPath)
}
# --- Clean up the upscayl temp file (if any). ---
if ($upscaledTempPath -and (Test-Path -LiteralPath $upscaledTempPath)) {
Remove-Item -LiteralPath $upscaledTempPath -Force -ErrorAction SilentlyContinue
}
+251 -17
View File
@@ -1,6 +1,8 @@
<#
.SYNOPSIS
Clones (copies) a video file using ffmpeg.
Supports MP4 (H.264), WebM and Matroska (MKV) sources, including YouTube/Google DASH downloads encoded as VP9 + Opus/AAC.
Non-H.264 video and non-MP4-compatible audio are automatically transcoded to H.264 / AAC so the target is always a standard MP4.
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).
@@ -8,7 +10,8 @@
- 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.
Path to the input video file. Can be absolute or relative to current directory.
If the path has no extension, .mp4, .webm and .mkv are tried in turn.
.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
@@ -20,16 +23,32 @@
.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".
Forces a video re-encode, since scaling requires re-encoding.
.PARAMETER FPS
Frames per second for the output video. Uses the input video's FPS if not specified.
Forces a video re-encode, since changing frame rate requires re-encoding.
.PARAMETER NoAudio
When present, excludes audio from the cloned video.
Forces a video re-encode, since removing the audio stream requires re-muxing the output file.
.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.
.PARAMETER Reverse
When present, re-encodes the video so it plays backward (from the last selected frame to the first).
Honours all other parameters (StartFrame/EndFrame range, Size, FPS, CRF, container choice, etc.).
Audio is NOT reversed; it is processed exactly as it would be without -Reverse (trimmed/replaced/copied as applicable) and plays forward alongside the reversed video.
Forces a video re-encode, since the reverse filter cannot be applied to a stream-copied video.
.PARAMETER LTX
When present, overrides the dimensions selected by -Size with the closest LTX-2 (Wan2GP by DeepBeepMeep)
canonical resolution for the source's aspect ratio. Only valid when -Size is one of HD, 1080p, 720p or 480p.
The closest aspect ratio is picked from the following buckets:
- HD / 1080p: 1920x1088 (16:9), 1088x1920 (9:16), 1664x960 (21:9), 960x1664 (9:21)
- 720p: 1280x704 (16:9), 704x1280 (9:16), 1088x576 (21:9), 576x1088 (9:21)
- 480p: 832x448 (16:9), 448x832 (9:16), 896x512 (4:3), 512x896 (3:4)
Forces a video re-encode (same as -Size).
.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.
@@ -68,7 +87,13 @@ param(
[int]$CRF = -1,
[Parameter(Mandatory = $false)]
[switch]$Metadata
[switch]$Metadata,
[Parameter(Mandatory = $false)]
[switch]$Reverse,
[Parameter(Mandatory = $false)]
[switch]$LTX
)
Set-StrictMode -Version Latest
@@ -76,19 +101,72 @@ $ErrorActionPreference = 'Stop'
$SourcePath = $Source
if (-not $SourcePath.EndsWith('.mp4', [StringComparison]::OrdinalIgnoreCase)) {
$SourcePath += '.mp4'
# Resolve source path: accept .mp4, .webm or .mkv. If the path doesn't exist as-is,
# try appending each supported extension in turn (preserving the original .mp4-default
# behaviour while adding WebM/MKV support for YouTube/Google DASH downloads).
$supportedSourceExts = @('.mp4', '.webm', '.mkv')
if (-not (Test-Path -LiteralPath $SourcePath)) {
$resolved = $null
foreach ($ext in $supportedSourceExts) {
$candidate = "$SourcePath$ext"
if (Test-Path -LiteralPath $candidate) {
$resolved = $candidate
break
}
}
if ($resolved) {
$SourcePath = $resolved
}
}
if (-not (Test-Path -LiteralPath $SourcePath)) {
Write-Error ('Source video file not found: {0}' -f $SourcePath)
Write-Error ('Source video file not found: {0}' -f $Source)
exit 1
}
$TargetPath = $Target
if (-not $TargetPath.EndsWith('.mp4', [StringComparison]::OrdinalIgnoreCase)) {
# Determine output container from the target extension.
# Supported: .mp4 (H.264/AAC), .webm (VP9/Opus), .mkv (H.264/AAC, permissive copy).
# If no recognised extension is provided, default to .mp4 (preserves prior behaviour).
$supportedTargetExts = @('.mp4', '.webm', '.mkv')
$currentTargetExt = [System.IO.Path]::GetExtension($TargetPath).ToLowerInvariant()
if ($supportedTargetExts -notcontains $currentTargetExt) {
$TargetPath += '.mp4'
$currentTargetExt = '.mp4'
}
$targetContainer = $currentTargetExt.TrimStart('.')
# Output-container profile: target codecs, ffmpeg encode args, and the list of
# source codecs that can be stream-copied directly into the container without
# re-encoding.
switch ($targetContainer) {
'webm' {
$targetVideoCodec = 'libvpx-vp9'
$targetVideoEncodeArgs = @('-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', '-deadline', 'good', '-cpu-used', '2', '-row-mt', '1')
$targetAudioCodec = 'libopus'
$targetAudioBitrate = '160k'
$videoCopyCodecs = @('vp8', 'vp9', 'av1')
$audioCopyCodecs = @('opus', 'vorbis')
}
'mkv' {
$targetVideoCodec = 'libx264'
$targetVideoEncodeArgs = @('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'slow')
$targetAudioCodec = 'aac'
$targetAudioBitrate = '192k'
# MKV is permissive: stream-copy almost any common codec.
$videoCopyCodecs = @('h264', 'hevc', 'vp8', 'vp9', 'av1', 'mpeg4')
$audioCopyCodecs = @('aac', 'mp3', 'ac3', 'eac3', 'alac', 'flac', 'opus', 'vorbis')
}
default {
# 'mp4' fallback
$targetVideoCodec = 'libx264'
$targetVideoEncodeArgs = @('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'slow')
$targetAudioCodec = 'aac'
$targetAudioBitrate = '192k'
$videoCopyCodecs = @('h264')
$audioCopyCodecs = @('aac', 'mp3', 'ac3', 'eac3', 'alac')
}
}
$TargetDir = Split-Path -Parent $TargetPath
@@ -103,12 +181,25 @@ if ($NoAudio -and -not [string]::IsNullOrWhiteSpace($Audio)) {
}
$AudioPath = $null
$externalAudioCodec = ''
$externalAudioCodecMismatch = $false
if (-not [string]::IsNullOrWhiteSpace($Audio)) {
$AudioPath = $Audio
if (-not (Test-Path -LiteralPath $AudioPath)) {
Write-Error ('Audio file not found: {0}' -f $AudioPath)
exit 1
}
# Probe the external audio file's codec so a codec that's not compatible with
# the target container (e.g. Opus into MP4, or AAC into WebM) is transcoded
# rather than stream-copied.
$ffprobeExtAudioOutput = & ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $AudioPath 2>&1
if ($LASTEXITCODE -eq 0) {
$extAudioLine = $ffprobeExtAudioOutput | Select-Object -First 1
if ($extAudioLine) {
$externalAudioCodec = ([string]$extAudioLine).Trim().ToLowerInvariant()
}
}
}
$sizeMap = @{
@@ -149,7 +240,8 @@ if ($ffprobeExit -ne 0) {
exit 1
}
$fpsRatio = ($ffprobeFpsOutput | Select-Object -First 1).Trim()
$fpsRatioLine = $ffprobeFpsOutput | Select-Object -First 1
$fpsRatio = if ($fpsRatioLine) { ([string]$fpsRatioLine).Trim() } else { '' }
if (-not $fpsRatio -or $fpsRatio -notmatch '^\d+/\d+$') {
Write-Error ('Could not determine frame rate for: {0}' -f $SourcePath)
@@ -169,7 +261,8 @@ if ($ffprobeExit -ne 0) {
exit 1
}
$totalFrames = ($ffprobeFramesOutput | Select-Object -First 1).Trim()
$totalFramesLine = $ffprobeFramesOutput | Select-Object -First 1
$totalFrames = if ($totalFramesLine) { ([string]$totalFramesLine).Trim() } else { '' }
if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') {
Write-Error ('Could not determine frame count for: {0}' -f $SourcePath)
@@ -179,17 +272,108 @@ if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') {
$totalFramesInt = [int]$totalFrames
$maxFrameIndex = $totalFramesInt - 1
# Probe source video dimensions (used by -LTX to pick the closest aspect-ratio bucket).
$ffprobeDimOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -- $SourcePath 2>&1
$sourceWidth = 0
$sourceHeight = 0
if ($LASTEXITCODE -eq 0) {
$dimLine = $ffprobeDimOutput | Select-Object -First 1
if ($dimLine) {
$dimText = ([string]$dimLine).Trim()
if ($dimText -match '^(\d+)x(\d+)$') {
$sourceWidth = [int]$Matches[1]
$sourceHeight = [int]$Matches[2]
}
}
}
if ($LTX) {
$ltxKey = $Size
if ($ltxKey -eq 'HD') { $ltxKey = '1080p' }
$ltxMap = @{
'1080p' = @(
@{ W = 1920; H = 1088 },
@{ W = 1088; H = 1920 },
@{ W = 1664; H = 960 },
@{ W = 960; H = 1664 }
)
'720p' = @(
@{ W = 1280; H = 704 },
@{ W = 704; H = 1280 },
@{ W = 1088; H = 576 },
@{ W = 576; H = 1088 }
)
'480p' = @(
@{ W = 832; H = 448 },
@{ W = 448; H = 832 },
@{ W = 896; H = 512 },
@{ W = 512; H = 896 }
)
}
if (-not $ltxMap.ContainsKey($ltxKey)) {
Write-Error '-LTX requires -Size to be one of: 480p, 720p, 1080p, HD'
exit 1
}
if ($sourceWidth -le 0 -or $sourceHeight -le 0) {
Write-Error 'Could not determine source video dimensions; -LTX cannot pick an aspect-ratio bucket.'
exit 1
}
$srcRatio = [double]$sourceWidth / [double]$sourceHeight
$best = $null
$bestDiff = [double]::MaxValue
foreach ($entry in $ltxMap[$ltxKey]) {
$r = [double]$entry.W / [double]$entry.H
$d = [Math]::Abs($r - $srcRatio)
if ($d -lt $bestDiff) {
$bestDiff = $d
$best = $entry
}
}
$scaleWidth = $best.W
$scaleHeight = $best.H
}
$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
$bitrateLine = $ffprobeBitrateOutput | Select-Object -First 1
if ($bitrateLine) {
$bitrateValue = ([string]$bitrateLine).Trim()
if ($bitrateValue -and $bitrateValue -match '^\d+$') {
$videoBitrate = $bitrateValue
}
}
}
# Probe source video and audio codecs. When the source codec isn't on the
# target container's stream-copy list, the script will transcode to the
# container's chosen codec (see container profile above).
$ffprobeVideoCodecOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
$sourceVideoCodec = ''
if ($LASTEXITCODE -eq 0) {
$vCodecLine = $ffprobeVideoCodecOutput | Select-Object -First 1
if ($vCodecLine) {
$sourceVideoCodec = ([string]$vCodecLine).Trim().ToLowerInvariant()
}
}
$ffprobeAudioCodecOutput = & ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
$sourceAudioCodec = ''
if ($LASTEXITCODE -eq 0) {
$aCodecLine = $ffprobeAudioCodecOutput | Select-Object -First 1
if ($aCodecLine) {
$sourceAudioCodec = ([string]$aCodecLine).Trim().ToLowerInvariant()
}
}
# A source codec not on the target container's stream-copy list forces a re-encode
# regardless of trimming/scaling/FPS changes.
$videoCodecMismatch = ($sourceVideoCodec -and ($videoCopyCodecs -notcontains $sourceVideoCodec))
$audioCodecMismatch = ($sourceAudioCodec -and ($audioCopyCodecs -notcontains $sourceAudioCodec))
$externalAudioCodecMismatch = ($externalAudioCodec -and ($audioCopyCodecs -notcontains $externalAudioCodec))
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
@@ -258,14 +442,32 @@ if (-not [string]::IsNullOrWhiteSpace($Sequence)) {
$frameCount = $actualEndFrame - $StartFrame + 1
Write-Host ('Cloning video: {0}' -f $Source)
Write-Host ('Cloning video: {0}' -f $SourcePath)
Write-Host (' Target container: {0} (video={1} audio={2})' -f $targetContainer, $targetVideoCodec, $targetAudioCodec)
if ($sourceVideoCodec) {
Write-Host (' Source codec: video={0}{1} audio={2}{3}' -f `
$sourceVideoCodec, $(if ($videoCodecMismatch) { " (will transcode to $targetVideoCodec)" } else { '' }), `
$(if ($sourceAudioCodec) { $sourceAudioCodec } else { 'none' }), `
$(if ($audioCodecMismatch) { " (will transcode to $targetAudioCodec)" } else { '' }))
}
if ($AudioPath -and $externalAudioCodec) {
Write-Host (' External audio codec: {0}{1}' -f `
$externalAudioCodec, $(if ($externalAudioCodecMismatch) { " (will transcode to $targetAudioCodec)" } else { '' }))
}
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 ($LTX) {
Write-Host (' Size: {0}x{1} (LTX-2)' -f $scaleWidth, $scaleHeight)
} else {
Write-Host (' Size: {0}x{1}' -f $scaleWidth, $scaleHeight)
}
}
if ($FPS -gt 0) {
Write-Host (' FPS: {0}' -f $FPS)
}
if ($Reverse) {
Write-Host ' Reverse: video reversed, audio unchanged'
}
Write-Host (' Target: {0}' -f $TargetPath)
$ffmpegArgs = @(
@@ -277,7 +479,7 @@ if ($AudioPath) {
$ffmpegArgs += @('-i', $AudioPath)
}
$needsReencode = $scaleWidth -gt 0 -or $FPS -gt 0
$needsReencode = $scaleWidth -gt 0 -or $FPS -gt 0 -or $CRF -ge 0 -or $videoCodecMismatch -or $Reverse -or $NoAudio
if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
$filterParts = @('select=between(n\,{0}\,{1})' -f $StartFrame, $actualEndFrame)
@@ -288,17 +490,24 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
if ($FPS -gt 0) {
$filterParts += ('fps={0}' -f $FPS)
}
if ($Reverse) {
$filterParts += 'reverse'
}
$videoFilter = $filterParts -join ','
$ffmpegArgs += @(
'-vf', $videoFilter
'-vsync', 'vfr'
)
$ffmpegArgs += $targetVideoEncodeArgs
if ($CRF -ge 0) {
$ffmpegArgs += @('-crf', $CRF.ToString())
# libvpx-vp9 ignores -crf unless -b:v 0 is also set (constant-quality mode).
if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') }
} elseif ($videoBitrate) {
$ffmpegArgs += @('-b:v', $videoBitrate)
} else {
$ffmpegArgs += @('-crf', '14')
if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') }
}
} elseif ($needsReencode) {
$filterParts = @()
@@ -308,14 +517,22 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
if ($FPS -gt 0) {
$filterParts += ('fps={0}' -f $FPS)
}
$videoFilter = $filterParts -join ','
$ffmpegArgs += @('-vf', $videoFilter)
if ($Reverse) {
$filterParts += 'reverse'
}
if ($filterParts.Count -gt 0) {
$videoFilter = $filterParts -join ','
$ffmpegArgs += @('-vf', $videoFilter)
}
$ffmpegArgs += $targetVideoEncodeArgs
if ($CRF -ge 0) {
$ffmpegArgs += @('-crf', $CRF.ToString())
if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') }
} elseif ($videoBitrate) {
$ffmpegArgs += @('-b:v', $videoBitrate)
} else {
$ffmpegArgs += @('-crf', '14')
if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') }
}
} else {
$ffmpegArgs += @('-c:v', 'copy')
@@ -333,7 +550,17 @@ if ($NoAudio) {
'-map', '0:v'
'-map', '1:a'
'-t', $durationStr
'-c:a', 'aac'
'-c:a', $targetAudioCodec
'-b:a', $targetAudioBitrate
)
} elseif ($externalAudioCodecMismatch) {
# External audio cannot be stream-copied into the target container.
$ffmpegArgs += @(
'-map', '0:v'
'-map', '1:a'
'-c:a', $targetAudioCodec
'-b:a', $targetAudioBitrate
'-shortest'
)
} else {
$ffmpegArgs += @(
@@ -351,6 +578,13 @@ if ($NoAudio) {
$endTimeStr = $endTime.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture)
$audioFilter = "atrim=start=${startTimeStr}:end=${endTimeStr},asetpts=PTS-STARTPTS"
$ffmpegArgs += @('-af', $audioFilter)
# The atrim filter forces an audio re-encode; explicitly set the target
# container's audio codec so the result is always container-appropriate
# (e.g. Opus for WebM, AAC for MP4/MKV) rather than ffmpeg's default.
$ffmpegArgs += @('-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate)
} elseif ($audioCodecMismatch) {
# Source audio is not on the target container's stream-copy list.
$ffmpegArgs += @('-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate)
} else {
$ffmpegArgs += @('-c:a', 'copy')
}
+124 -2
View File
@@ -53,6 +53,25 @@
.PARAMETER In4K
Generate comparison video in 4K UHD (3840x2160) resolution instead of 1K HD (1920x1080).
.PARAMETER Clip
Reduces the output height to match the actual scaled height of the sources, removing
the top/bottom black letterbox padding. Only applies when exactly 2 source videos are
provided; ignored otherwise.
Behavior depends on the orientations of the two sources:
- Both same orientation (both landscape or both portrait): the output height is set
to the larger of the two sources' scaled heights inside their half-width cells.
Example: two 1280x720 sources -> 1920x540 (1K) or 3840x1080 (4K).
- Mixed orientations (one landscape, one portrait), and the landscape source is
smaller than the target resolution: the landscape video occupies its native pixel
width at 1K HD (or 2x in 4K), and the portrait video is scaled to match the
landscape's height and centered in the remaining width with black pillar boxes.
Example: 1280x720 landscape + 720x1280 portrait at 1K HD -> 1920x720 (landscape
occupies 1280x720 on its S1/S2 side, portrait fits in the remaining 640x720).
At 4K the same pair becomes 3840x1440 (landscape 2560x1440, portrait cell 1280x1440).
.EXAMPLE
.\Compare-Videos.ps1 -S1 original.mp4 -S2 enhanced.mp4 -Output comparison.mp4
@@ -77,6 +96,11 @@
.\Compare-Videos.ps1 -S1 original.mp4 -S2 enhanced.mp4 -Output comparison_4k.mp4 -In4K
Compare two videos in 4K UHD (3840x2160) resolution.
.EXAMPLE
.\Compare-Videos.ps1 -S1 a_720p.mp4 -S2 b_720p.mp4 -Output side-by-side.mp4 -Clip
Compare two 720p (1280x720) videos with no top/bottom letterboxing: output is 1920x540.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
@@ -97,7 +121,9 @@ param(
[switch]$NoAudio,
[switch]$In4K
[switch]$In4K,
[switch]$Clip
)
$ErrorActionPreference = "Stop"
@@ -132,6 +158,11 @@ $videoCount = 2
if ($S4) { $videoCount = 4 }
elseif ($S3) { $videoCount = 3 }
if ($Clip -and $videoCount -ne 2) {
Write-Warning "-Clip is only supported with exactly 2 source videos; ignoring."
$Clip = $false
}
# --- Detect orientation of S1 ---
Write-Host "Detecting orientation of S1..." -ForegroundColor Cyan
@@ -165,10 +196,101 @@ if ($isPortrait) {
Write-Host "Landscape mode detected (${width}x${height}) - Output: $resolutionLabel" -ForegroundColor Green
}
# --- Clip mode: shrink output height to the actual scaled height of the sources ---
$clipMixedLandscape = $false
$clipMixedFilterComplex = $null
if ($Clip) {
Write-Host "Probing S2 dimensions for -Clip..." -ForegroundColor Cyan
$probeOutput2 = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 $S2 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to probe video dimensions for S2: $probeOutput2"
exit 1
}
$dim2 = $probeOutput2 -split ','
$w2 = [int]$dim2[0]
$h2 = [int]$dim2[1]
$s1IsLandscape = $width -gt $height
$s2IsLandscape = $w2 -gt $h2
$isMixedOrientation = ($s1IsLandscape -xor $s2IsLandscape)
if ($isMixedOrientation) {
# One source is landscape, the other is portrait. Try the mixed-orientation
# layout: the landscape video keeps its native pixel size at 1K HD (or 2x in 4K),
# and the portrait video is scaled to match the landscape's height and centered
# in the remaining width with black pillar-box bars.
$scaleFactor = $outputWidth / 1920.0 # 1.0 at 1K HD, 2.0 at 4K UHD
if ($s1IsLandscape) {
$landscapeW = $width; $landscapeH = $height
} else {
$landscapeW = $w2; $landscapeH = $h2
}
$landscapeOutW = [int][Math]::Floor($landscapeW * $scaleFactor)
$landscapeOutH = [int][Math]::Floor($landscapeH * $scaleFactor)
if ($landscapeOutW % 2) { $landscapeOutW++ }
if ($landscapeOutH % 2) { $landscapeOutH++ }
if ($landscapeOutW -lt $outputWidth -and $landscapeOutH -le $outputHeight) {
$portraitCellW = $outputWidth - $landscapeOutW
$outputHeight = $landscapeOutH
Write-Host ("Clip mode (mixed orientation): landscape {0}x{1} + portrait {2}x{1} -> {3}x{1}" -f $landscapeOutW, $landscapeOutH, $portraitCellW, $outputWidth) -ForegroundColor Yellow
# Preserve S1=left, S2=right ordering. Each input is scaled into its cell
# with aspect-ratio preserved, then padded with black to exactly fill the cell.
if ($s1IsLandscape) {
$leftW = $landscapeOutW; $rightW = $portraitCellW
} else {
$leftW = $portraitCellW; $rightW = $landscapeOutW
}
$clipMixedFilterComplex = "[0:v]scale=${leftW}:${landscapeOutH}:force_original_aspect_ratio=decrease,pad=${leftW}:${landscapeOutH}:(ow-iw)/2:(oh-ih)/2:black[v0];" +
"[1:v]scale=${rightW}:${landscapeOutH}:force_original_aspect_ratio=decrease,pad=${rightW}:${landscapeOutH}:(ow-iw)/2:(oh-ih)/2:black[v1];" +
"[v0][v1]hstack=inputs=2[outv]"
$clipMixedLandscape = $true
} else {
Write-Host "Clip mode: mixed orientations detected, but the landscape source already fills the target width; using standard clip layout." -ForegroundColor Yellow
}
}
if (-not $clipMixedLandscape) {
# Standard -Clip behaviour: both sources share an orientation (or mixed fell back).
# Compute the largest natural scaled height across both sources and reduce the
# output height to that value.
# Per-source cell width matches the 2-video branches below.
if ($isPortrait) {
$clipCellWidth = [int]($outputWidth * 0.6333 / 2)
} else {
$clipCellWidth = [int]($outputWidth / 2)
}
# Height each source would naturally scale to inside its cell when fitted by width
# (force_original_aspect_ratio=decrease). Take the larger of the two so neither
# source loses content; cap at the original output height.
$h1Scaled = [int][Math]::Floor($clipCellWidth * $height / $width)
$h2Scaled = [int][Math]::Floor($clipCellWidth * $h2 / $w2)
$clippedHeight = [Math]::Max($h1Scaled, $h2Scaled)
if ($clippedHeight % 2) { $clippedHeight++ } # libx264/yuv420p requires even dimensions
if ($clippedHeight -gt $outputHeight) { $clippedHeight = $outputHeight }
if ($clippedHeight -lt $outputHeight) {
Write-Host ("Clip mode: output height reduced from {0} to {1} ({2}x{1})" -f $outputHeight, $clippedHeight, $outputWidth) -ForegroundColor Yellow
$outputHeight = $clippedHeight
} else {
Write-Host "Clip mode: sources already fill the full output height; no clipping applied." -ForegroundColor Yellow
}
}
}
$filterComplex = ""
$inputArgs = @("-i", $S1, "-i", $S2)
if ($isPortrait) {
if ($clipMixedLandscape) {
# Mixed-orientation -Clip layout was prepared above; use it as-is.
$filterComplex = $clipMixedFilterComplex
} elseif ($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
+2
View File
@@ -0,0 +1,2 @@
@echo off
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Create-PillarBox.ps1" %*
+421
View File
@@ -0,0 +1,421 @@
<#
.SYNOPSIS
Creates a pillar-boxed video file from a video file.
.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.
If omitted, defaults to the source filename with the -Effect value (lowercased) appended after a dash,
saved alongside the source (e.g. video.mp4 + -Effect black -> video-black.mp4).
.PARAMETER Effect
The effect to apply to the side bars. Possible values are:
black: The side bars will be solid black (#000000).
white: The side bars will be solid white (#FFFFFF).
silver: The side bars will be solid silver (#C0C0C0), a light neutral gray.
gray: The side bars will be solid mid gray (#808080).
charcoal: The side bars will be solid charcoal (#36454F), a very dark blue-gray.
color:RRGGBB: The side bars will be filled with the given hex color (case-insensitive). Example: -Effect color:FFFF00 yields yellow bars.
standard: Creates softly blurred side bars generated from the video itself, with a subtle zoom to fill the 16:9 frame.
dark: Produces blurred side bars with reduced brightness, creating a darker, more contrasted background that enhances focus on the main video.
gaussian: Generates smooth, highquality Gaussianblurred side bars for a clean, professional broadcast-style background.
.PARAMETER Size
Size of the output video. Uses the input video's closest size that will fit the 16:9 aspect ratio 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 source 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.
.DESCRIPTION
Creates a pillar-boxed video file from a video file. Will add black bars to the sides of the video to match 16:9 aspect ratio.
The black bars will be verticals on each side of the output video if the source video is more portrait-like (e.g. 9:16).
Otherwise, the black bars will be horizontal on the top and bottom of the output video.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $false)]
[string]$Target = $null,
[Parameter(Mandatory = $true)]
[string]$Effect,
[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
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-VideoInfo {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$json = & ffprobe -v error -print_format json -show_streams -show_format -- "$Path"
if ($LASTEXITCODE -ne 0 -or -not $json) {
throw "ffprobe failed for: $Path"
}
$info = $json | ConvertFrom-Json
$videoStream = $info.streams | Where-Object { $_.codec_type -eq 'video' } | Select-Object -First 1
if (-not $videoStream) {
throw "No video stream found in: $Path"
}
$audioStream = $info.streams | Where-Object { $_.codec_type -eq 'audio' } | Select-Object -First 1
# Safe property accessor: returns $null when the property is absent (strict-mode friendly).
$get = { param($obj, $name) if ($obj -and ($obj.PSObject.Properties.Name -contains $name)) { $obj.$name } else { $null } }
$rFrameRate = & $get $videoStream 'r_frame_rate'
$fps = $null
if ($rFrameRate -and $rFrameRate -match '^(\d+)/(\d+)$') {
$num = [double]$Matches[1]
$den = [double]$Matches[2]
if ($den -ne 0) {
$fps = $num / $den
}
}
$videoBitRate = & $get $videoStream 'bit_rate'
$formatBitRate = & $get $info.format 'bit_rate'
$bitRate = if ($videoBitRate) { $videoBitRate } else { $formatBitRate }
[pscustomobject]@{
Path = $Path
FileName = [System.IO.Path]::GetFileName($Path)
Width = [int]$videoStream.width
Height = [int]$videoStream.height
Fps = $fps
BitRate = $bitRate
HasAudio = [bool]$audioStream
VideoBitRate = $videoBitRate
}
}
function Format-Quality {
param(
[Parameter(Mandatory = $false)]
$BitRate
)
if (-not $BitRate -or $BitRate -notmatch '^\d+$') {
return 'unknown'
}
$kbps = [math]::Round(([double]$BitRate) / 1000.0)
return "${kbps}kbps"
}
function Resolve-Size {
param(
[Parameter(Mandatory = $false)]
[string]$Value,
[Parameter(Mandatory = $true)]
[int]$SourceWidth,
[Parameter(Mandatory = $true)]
[int]$SourceHeight
)
# Standard 16:9 sizes (largest first only matters for closest-fit lookup)
$standards = @(
@{ Name = '480p'; Width = 854; Height = 480 },
@{ Name = '720p'; Width = 1280; Height = 720 },
@{ Name = '1080p'; Width = 1920; Height = 1080 },
@{ Name = 'HD'; Width = 1920; Height = 1080 },
@{ Name = '1440p'; Width = 2560; Height = 1440 },
@{ Name = '2K'; Width = 2560; Height = 1440 },
@{ Name = '2160p'; Width = 3840; Height = 2160 },
@{ Name = '4K'; Width = 3840; Height = 2160 }
)
if ($Value) {
$named = $standards | Where-Object { $_.Name -ieq $Value } | Select-Object -First 1
if ($named) {
return [pscustomobject]@{ Width = $named.Width; Height = $named.Height }
}
if ($Value -match '^(\d+):(\d+)$') {
return [pscustomobject]@{ Width = [int]$Matches[1]; Height = [int]$Matches[2] }
}
throw "Invalid -Size value: $Value. Use a named preset (480p, 720p, 1080p, HD, 1440p, 2K, 2160p, 4K) or 'width:height'."
}
# Pick the smallest standard 16:9 preset whose longer side (width) is at least
# the source's longer side. The foreground is scaled down with
# 'force_original_aspect_ratio=decrease', so the source is allowed to shrink
# to fit; we only need the preset to be "big enough" relative to the source's
# largest dimension. This produces the documented behavior:
# 1080x1920 portrait -> 1920x1080 (vertical bars on either side)
# 720x1280 portrait -> 1280x720
# 2560x1080 landscape -> 2560x1440 (horizontal bars top/bottom)
$longSide = [math]::Max($SourceWidth, $SourceHeight)
$unique = $standards | Sort-Object { $_.Width } -Unique
foreach ($s in $unique) {
if ($s.Width -ge $longSide) {
return [pscustomobject]@{ Width = $s.Width; Height = $s.Height }
}
}
# Source larger than any standard preset: build a 16:9 frame whose width
# matches the source's longer side (rounded to even).
$w = $longSide + ($longSide % 2)
$h = [int][math]::Ceiling($w * 9.0 / 16.0)
if ($h % 2) { $h++ }
return [pscustomobject]@{ Width = $w; Height = $h }
}
# Validate -Effect first so it can be used in the default -Target name.
# Solid-color effects map an effect name to the ffmpeg color string used in the pad filter.
# Named SVG colors (black, white, silver, gray) are passed through directly; custom shades
# (e.g. charcoal) use an explicit 0xRRGGBB value since ffmpeg does not recognise the name.
$solidColorMap = @{
'black' = 'black'
'white' = 'white'
'silver' = 'silver'
'gray' = 'gray'
'charcoal' = '0x36454F'
}
$blurEffects = @('standard', 'dark', 'gaussian')
$validEffects = @($solidColorMap.Keys) + $blurEffects
$effectLower = $Effect.ToLower()
# Ad-hoc solid color via 'color:RRGGBB' syntax (case-insensitive on the hex value).
# The hex string is normalised to lowercase and the effect is treated like the
# built-in solid-color effects (simple pad filter, no blur).
$customColorHex = $null
if ($effectLower -match '^color:([0-9a-f]{6})$') {
$customColorHex = $Matches[1]
$effectLower = "color:$customColorHex"
}
elseif ($effectLower -notin $validEffects) {
throw "Invalid -Effect value: $Effect. Must be one of: $($validEffects -join ', '), or 'color:RRGGBB' (6 hex digits)."
}
# Append .mp4 extension if missing on -Source
if (-not [System.IO.Path]::GetExtension($Source)) {
$Source = "$Source.mp4"
}
if (-not (Test-Path -LiteralPath $Source)) {
throw "Source video not found: $Source"
}
$sourceFull = (Resolve-Path -LiteralPath $Source).Path
# Default -Target: same directory and base name as -Source, with the effect appended
# (e.g. C:\clips\video.mp4 + -Effect Black -> C:\clips\video-black.mp4).
# For 'color:RRGGBB', the colon is replaced with a dash to keep the filename valid
# (e.g. -Effect color:FFFF00 -> video-color-ffff00.mp4).
if ([string]::IsNullOrWhiteSpace($Target)) {
$srcDir = [System.IO.Path]::GetDirectoryName($sourceFull)
$srcBase = [System.IO.Path]::GetFileNameWithoutExtension($sourceFull)
$effectForName = $effectLower -replace ':', '-'
$Target = Join-Path $srcDir "$srcBase-$effectForName.mp4"
}
elseif (-not [System.IO.Path]::GetExtension($Target)) {
$Target = "$Target.mp4"
}
# Resolve Target to an absolute path (may not exist yet)
if ([System.IO.Path]::IsPathRooted($Target)) {
$targetFull = $Target
}
else {
$targetFull = Join-Path (Get-Location).Path $Target
}
if ($Audio) {
if (-not (Test-Path -LiteralPath $Audio)) {
throw "Audio file not found: $Audio"
}
$audioFull = (Resolve-Path -LiteralPath $Audio).Path
}
else {
$audioFull = $null
}
if ($CRF -ne -1 -and ($CRF -lt 0 -or $CRF -gt 51)) {
throw "Invalid -CRF value: $CRF. Must be in range 0-51."
}
$inInfo = Get-VideoInfo -Path $sourceFull
$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' }
Write-Host "Input : Filename=$($inInfo.FileName) Resolution=$inRes FPS=$inFpsText Quality=$inQuality Audio=$inAudioText"
$outSize = Resolve-Size -Value $Size -SourceWidth $inInfo.Width -SourceHeight $inInfo.Height
$outWidth = $outSize.Width
$outHeight = $outSize.Height
# Force even dimensions for yuv420p compatibility
if ($outWidth % 2) { $outWidth++ }
if ($outHeight % 2) { $outHeight++ }
# Scale the source so it fits entirely inside the output frame, preserving aspect ratio.
$scaleFg = "scale=w=${outWidth}:h=${outHeight}:force_original_aspect_ratio=decrease"
# Build the video filter graph depending on the requested -Effect.
# solid colors : simple pad with the chosen color (uses -vf)
# black, white, silver, gray, charcoal, color:RRGGBB
# standard : softly blurred background with a subtle zoom (uses -filter_complex)
# dark : blurred background with reduced brightness/saturation (uses -filter_complex)
# gaussian : high-quality Gaussian-blurred background (uses -filter_complex)
$useFilterComplex = $effectLower -in $blurEffects
if (-not $useFilterComplex) {
if ($customColorHex) {
$padColor = "0x$customColorHex"
}
else {
$padColor = $solidColorMap[$effectLower]
}
$padExpr = "pad=${outWidth}:${outHeight}:(ow-iw)/2:(oh-ih)/2:${padColor}"
$vf = "${scaleFg},${padExpr},setsar=1"
}
else {
# Background scaling: fill frame entirely, then crop. 'standard' adds a subtle ~10% zoom.
if ($effectLower -eq 'standard') {
$zoomW = [int][math]::Ceiling($outWidth * 1.1)
$zoomH = [int][math]::Ceiling($outHeight * 1.1)
if ($zoomW % 2) { $zoomW++ }
if ($zoomH % 2) { $zoomH++ }
$bgScale = "scale=w=${zoomW}:h=${zoomH}:force_original_aspect_ratio=increase,crop=${outWidth}:${outHeight}"
}
else {
$bgScale = "scale=w=${outWidth}:h=${outHeight}:force_original_aspect_ratio=increase,crop=${outWidth}:${outHeight}"
}
# Per-effect blur and color treatment for the background.
switch ($effectLower) {
'standard' { $bgEffect = 'boxblur=20:1' }
'dark' { $bgEffect = 'boxblur=20:1,eq=brightness=-0.25:saturation=0.8' }
'gaussian' { $bgEffect = 'gblur=sigma=30' }
}
$filterComplex = "[0:v]split=2[bg][fg];" `
+ "[bg]${bgScale},${bgEffect},setsar=1[blurred];" `
+ "[fg]${scaleFg},setsar=1[scaled];" `
+ "[blurred][scaled]overlay=(W-w)/2:(H-h)/2[outv]"
}
# Effective output FPS for log display
$effFps = if ($FPS -gt 0) { $FPS } elseif ($inInfo.Fps) { $inInfo.Fps } else { $null }
$effFpsText = if ($effFps) { "{0:0.###}" -f $effFps } else { 'unknown' }
# Build ffmpeg arguments
$ffArgs = @('-y', '-i', $sourceFull)
if ($audioFull) {
$ffArgs += @('-i', $audioFull)
}
if ($useFilterComplex) {
$ffArgs += @('-filter_complex', $filterComplex)
$videoMap = '[outv]'
}
else {
$ffArgs += @('-vf', $vf)
$videoMap = '0:v:0'
}
if ($FPS -gt 0) {
$ffArgs += @('-r', ("{0}" -f $FPS))
}
# Video encoding: prefer CRF if specified, otherwise preserve source bitrate, else fallback to -crf 8.
if ($CRF -ge 0) {
$ffArgs += @('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'slow', '-crf', "$CRF")
}
elseif ($inInfo.VideoBitRate -and $inInfo.VideoBitRate -match '^\d+$') {
$ffArgs += @('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'slow', '-b:v', $inInfo.VideoBitRate)
}
else {
$ffArgs += @('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'slow', '-crf', '8')
}
# Audio handling. Always emit -map for video so filter_complex output is wired correctly;
# audio source depends on -NoAudio / -Audio / source-has-audio.
if ($NoAudio) {
$ffArgs += @('-map', $videoMap, '-an')
}
elseif ($audioFull) {
# Map video from filter (or input 0) and audio from input 1, stop at shortest stream
$ffArgs += @('-map', $videoMap, '-map', '1:a:0', '-c:a', 'aac', '-b:a', '192k', '-shortest')
}
elseif ($inInfo.HasAudio) {
$ffArgs += @('-map', $videoMap, '-map', '0:a:0?', '-c:a', 'copy')
}
else {
$ffArgs += @('-map', $videoMap, '-an')
}
$ffArgs += @($targetFull)
Write-Host "Output: Filename=$([System.IO.Path]::GetFileName($targetFull)) Resolution=${outWidth}x${outHeight} FPS=$effFpsText Effect=$effectLower"
# Ensure target directory exists
$targetDir = [System.IO.Path]::GetDirectoryName($targetFull)
if ($targetDir -and -not (Test-Path -LiteralPath $targetDir)) {
if ($PSCmdlet.ShouldProcess($targetDir, 'Create output directory')) {
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
}
}
if ($PSCmdlet.ShouldProcess($targetFull, 'Create pillar-boxed video with ffmpeg')) {
$ffmpegOutput = & ffmpeg @ffArgs 2>&1
$ffmpegExit = $LASTEXITCODE
if ($ffmpegExit -ne 0) {
if (Test-Path -LiteralPath $targetFull) {
Remove-Item -LiteralPath $targetFull -Force -ErrorAction SilentlyContinue
}
# Use Write-Host (not Write-Error) so the full ffmpeg output is shown;
# under $ErrorActionPreference = 'Stop' a piped Write-Error would terminate
# on the first line, swallowing the rest of the diagnostic output and the
# final 'throw' below.
$ffmpegOutput | ForEach-Object { Write-Host $_ }
Write-Host "ffmpeg $($ffArgs -join ' ')"
throw "ffmpeg failed while creating pillar-boxed video: $($inInfo.FileName)"
}
$outInfo = Get-VideoInfo -Path $targetFull
$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 "Done : Filename=$($outInfo.FileName) Resolution=$outRes FPS=$outFpsText Quality=$outQuality Audio=$outAudioText"
}
else {
$formattedArgs = $ffArgs | ForEach-Object { if ($_ -match '\s') { "`"$_`"" } else { $_ } }
Write-Host "WhatIf: Would create pillar-boxed video with ffmpeg $($formattedArgs -join ' ')"
}
+9 -6
View File
@@ -40,8 +40,12 @@ function Get-VideoInfo {
$audioStream = $info.streams | Where-Object { $_.codec_type -eq 'audio' } | Select-Object -First 1
# Safe property accessor: returns $null when the property is absent (strict-mode friendly).
$get = { param($obj, $name) if ($obj -and ($obj.PSObject.Properties.Name -contains $name)) { $obj.$name } else { $null } }
$rFrameRate = & $get $videoStream 'r_frame_rate'
$fps = $null
if ($videoStream.r_frame_rate -and $videoStream.r_frame_rate -match '^(\d+)/(\d+)$') {
if ($rFrameRate -and $rFrameRate -match '^(\d+)/(\d+)$') {
$num = [double]$Matches[1]
$den = [double]$Matches[2]
if ($den -ne 0) {
@@ -49,10 +53,9 @@ function Get-VideoInfo {
}
}
$bitRate = $videoStream.bit_rate
if (-not $bitRate) {
$bitRate = $info.format.bit_rate
}
$videoBitRate = & $get $videoStream 'bit_rate'
$formatBitRate = & $get $info.format 'bit_rate'
$bitRate = if ($videoBitRate) { $videoBitRate } else { $formatBitRate }
[pscustomobject]@{
Path = $Path
@@ -62,7 +65,7 @@ function Get-VideoInfo {
Fps = $fps
BitRate = $bitRate
HasAudio = [bool]$audioStream
VideoBitRate = $videoStream.bit_rate
VideoBitRate = $videoBitRate
}
}
+9 -6
View File
@@ -40,8 +40,12 @@ function Get-VideoInfo {
$audioStream = $info.streams | Where-Object { $_.codec_type -eq 'audio' } | Select-Object -First 1
# Safe property accessor: returns $null when the property is absent (strict-mode friendly).
$get = { param($obj, $name) if ($obj -and ($obj.PSObject.Properties.Name -contains $name)) { $obj.$name } else { $null } }
$rFrameRate = & $get $videoStream 'r_frame_rate'
$fps = $null
if ($videoStream.r_frame_rate -and $videoStream.r_frame_rate -match '^(\d+)/(\d+)$') {
if ($rFrameRate -and $rFrameRate -match '^(\d+)/(\d+)$') {
$num = [double]$Matches[1]
$den = [double]$Matches[2]
if ($den -ne 0) {
@@ -49,10 +53,9 @@ function Get-VideoInfo {
}
}
$bitRate = $videoStream.bit_rate
if (-not $bitRate) {
$bitRate = $info.format.bit_rate
}
$videoBitRate = & $get $videoStream 'bit_rate'
$formatBitRate = & $get $info.format 'bit_rate'
$bitRate = if ($videoBitRate) { $videoBitRate } else { $formatBitRate }
[pscustomobject]@{
Path = $Path
@@ -62,7 +65,7 @@ function Get-VideoInfo {
Fps = $fps
BitRate = $bitRate
HasAudio = [bool]$audioStream
VideoBitRate = $videoStream.bit_rate
VideoBitRate = $videoBitRate
}
}
+23 -9
View File
@@ -6,7 +6,7 @@
.PARAMETER Target
Target directory for cropped video files.
.DESCRIPTION
Crops video clips from 1928x1076 or 1926x1076 to 1920x1080 using ffmpeg.
Crops video clips from 1928x1076 or 1926x1076 to 1920x1080, or from 1284x716 to 1280x720 using ffmpeg.
Supports -WhatIf and -Confirm for safe execution.
#>
[CmdletBinding(SupportsShouldProcess)]
@@ -40,8 +40,12 @@ function Get-VideoInfo {
$audioStream = $info.streams | Where-Object { $_.codec_type -eq 'audio' } | Select-Object -First 1
# Safe property accessor: returns $null when the property is absent (strict-mode friendly).
$get = { param($obj, $name) if ($obj -and ($obj.PSObject.Properties.Name -contains $name)) { $obj.$name } else { $null } }
$rFrameRate = & $get $videoStream 'r_frame_rate'
$fps = $null
if ($videoStream.r_frame_rate -and $videoStream.r_frame_rate -match '^(\d+)/(\d+)$') {
if ($rFrameRate -and $rFrameRate -match '^(\d+)/(\d+)$') {
$num = [double]$Matches[1]
$den = [double]$Matches[2]
if ($den -ne 0) {
@@ -49,10 +53,9 @@ function Get-VideoInfo {
}
}
$bitRate = $videoStream.bit_rate
if (-not $bitRate) {
$bitRate = $info.format.bit_rate
}
$videoBitRate = & $get $videoStream 'bit_rate'
$formatBitRate = & $get $info.format 'bit_rate'
$bitRate = if ($videoBitRate) { $videoBitRate } else { $formatBitRate }
[pscustomobject]@{
Path = $Path
@@ -62,7 +65,7 @@ function Get-VideoInfo {
Fps = $fps
BitRate = $bitRate
HasAudio = [bool]$audioStream
VideoBitRate = $videoStream.bit_rate
VideoBitRate = $videoBitRate
}
}
@@ -110,7 +113,8 @@ foreach ($video in $videos) {
$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)
($inInfo.Width -eq 1926 -and $inInfo.Height -eq 1076) -or
($inInfo.Width -eq 1284 -and $inInfo.Height -eq 716)
if (-not $isValidResolution) {
Write-Host "Skipping: $($inInfo.FileName) (resolution $inRes)"
@@ -129,10 +133,20 @@ foreach ($video in $videos) {
$outCropPath = Join-Path $targetFull ($video.BaseName + '.crop.mp4')
$outFinalPath = Join-Path $targetFull $video.Name
# Determine crop parameters based on input resolution
if ($inInfo.Width -eq 1284 -and $inInfo.Height -eq 716) {
# 1284x716: expand to 720 height, then crop to 1280x720
$videoFilter = 'scale=-2:720,crop=1280:720:(in_w-1280)/2:(in_h-720)/2'
}
else {
# 1928x1076 or 1926x1076: expand to 1080 height, then crop to 1920x1080
$videoFilter = 'scale=-2:1080,crop=1920:1080:(in_w-1920)/2:(in_h-1080)/2'
}
$ffArgs = @(
'-y',
'-i', $inputPath,
'-vf', 'scale=-2:1080,crop=1920:1080:(in_w-1920)/2:(in_h-1080)/2'
'-vf', $videoFilter
)
if ($inInfo.HasAudio) {
+88 -19
View File
@@ -3,6 +3,8 @@
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.
Supports Windows wildcards (* and ?) to match multiple files. When wildcards are used,
metadata is extracted and displayed for each matching file sequentially.
.PARAMETER Full
When present, outputs the parsed comment metadata as JSON.
.DESCRIPTION
@@ -23,17 +25,39 @@ $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
# Resolve video paths, supporting wildcards
$videoPaths = @()
if ($Video -match '[*?]') {
# Wildcard pattern - use Get-ChildItem to resolve
$resolvedFiles = Get-ChildItem -Path $Video -File -ErrorAction SilentlyContinue
if (-not $resolvedFiles) {
Write-Error ('No files matched wildcard pattern: {0}' -f $Video)
exit 1
}
$videoPaths = $resolvedFiles | Select-Object -ExpandProperty FullName
} else {
# Single file path (literal)
if (-not (Test-Path -LiteralPath $Video)) {
Write-Error ('Video file not found: {0}' -f $Video)
exit 1
}
$videoPaths = @($Video)
}
$PSNativeCommandUseErrorActionPreference = $false
$firstFile = $true
foreach ($VideoPath in $videoPaths) {
# Add separator between files (but not before the first one)
if (-not $firstFile) {
Write-Output ''
Write-Output '========================================'
Write-Output ''
}
$firstFile = $false
try {
$ffprobeOutput = & ffprobe -v quiet -print_format json -show_format -- $VideoPath 2>&1
$ffprobeOutput = & ffprobe -v quiet -print_format json -show_format -show_streams -- "$VideoPath" 2>&1
$ffprobeExit = $LASTEXITCODE
if ($ffprobeExit -ne 0) {
@@ -56,6 +80,34 @@ try {
$commentJson = $raw.format.tags.comment
# Extract the actual (encoded) resolution and frame rate from the first video
# stream reported by ffprobe. These may differ from the values stored in the
# JSON comment metadata (e.g. WanGP/LTX records "720x1280" but the file is
# actually encoded at 704x1280 after the model's internal padding/cropping).
$actualResolution = $null
$actualFps = $null
$get = { param($obj, $name) if ($obj -and ($obj.PSObject.Properties.Name -contains $name)) { $obj.$name } else { $null } }
if ($raw.PSObject.Properties.Name -contains 'streams') {
$videoStream = $raw.streams | Where-Object { $_.codec_type -eq 'video' } | Select-Object -First 1
if ($videoStream) {
$w = & $get $videoStream 'width'
$h = & $get $videoStream 'height'
if ($w -and $h) {
$actualResolution = '{0}x{1}' -f [int]$w, [int]$h
}
$rfr = & $get $videoStream 'r_frame_rate'
if ($rfr -and $rfr -match '^(\d+)/(\d+)$') {
$num = [double]$matches[1]
$den = [double]$matches[2]
if ($den -ne 0) {
$actualFps = [Math]::Round($num / $den, 3)
}
}
}
}
try {
$parsedComment = $commentJson | ConvertFrom-Json
}
@@ -71,7 +123,7 @@ try {
}
Write-Output '----------------------------------------'
Write-Output ('File Name: {0}' -f $Video)
Write-Output ('File Name: {0}' -f [System.IO.Path]::GetFileName($VideoPath))
$iniPath = Join-Path $PSScriptRoot 'Get-VideoInfo.ini'
if (-not (Test-Path -LiteralPath $iniPath)) {
@@ -123,22 +175,37 @@ try {
}
}
elseif ($jsonFieldName -eq 'video_length') {
$fps = 24.0
$fieldExists = $parsedComment.PSObject.Properties.Name -contains 'force_fps'
if ($fieldExists) {
$fps = [double]$parsedComment.force_fps
}
# Always use the real frame rate reported by ffprobe for the
# duration calculation. The JSON metadata's force_fps may be a
# non-numeric mode marker (e.g. "control") or simply not match
# the actual encoded frame rate.
$fps = if ($actualFps) { $actualFps } else { 24.0 }
$frames = [int]$fieldValue
$secondsAt24fps = [Math]::Round($frames / $fps, 1)
$valueDisplay = ('{0} frames ({1}s, {2} fps)' -f $frames, $secondsAt24fps, $fps)
$durationSeconds = [Math]::Round($frames / $fps, 1)
$valueDisplay = ('{0} frames ({1}s, {2} fps)' -f $frames, $durationSeconds, $fps)
}
elseif ($fieldValue -is [array]) {
$valueDisplay = ($fieldValue | ConvertTo-Json -Compress -Depth 10)
if ($fieldValue.Count -gt 1) {
# Pretty-print array values as JSON, prefixed with a newline so the
# multi-line output starts cleanly under the field label.
$jsonArray = $fieldValue | ConvertTo-Json -Depth 10
$valueDisplay = [Environment]::NewLine + $jsonArray
}
else {
$jsonArray = $fieldValue | ConvertTo-Json -Depth 10 -Compress
$valueDisplay = $jsonArray
}
}
else {
$valueDisplay = $fieldValue
# If the value looks like a WxH resolution string and ffprobe
# reports a different actual encoded resolution, surface both.
if ($actualResolution `
-and ($fieldValue -is [string]) `
-and ($fieldValue -match '^\d+x\d+$') `
-and ($fieldValue -ne $actualResolution)) {
$valueDisplay = "$fieldValue (real: $actualResolution)"
}
}
}
@@ -148,6 +215,8 @@ try {
Write-Output '----------------------------------------'
}
catch {
Write-Error ('Unexpected error: {0}' -f $_.Exception.Message)
exit 1
Write-Error ('Unexpected error processing {0}: {1}' -f $VideoPath, $_.Exception.Message)
# Continue to next file instead of exiting
continue
}
}
+36 -7
View File
@@ -41,12 +41,17 @@
.NOTES
Requires:
- rife-ncnn-vulkan.exe in the system PATH
- rife-ncnn-vulkan.exe (TNTwise fork, RIFE 4.26) in the system PATH
- The RIFE_HOME environment variable set to the rife-ncnn-vulkan install directory
(the same directory that is on PATH; it must contain the rife-v4.26 model folder)
- The NCNN_GPU_ID environment variable set to the desired GPU device ID
(-1 for CPU, 0/1/2/etc. for GPU) passed to rife-ncnn-vulkan via the -g flag
- 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.
The script locates rife-ncnn-vulkan.exe from PATH and resolves the rife-v4.26
model directory from %RIFE_HOME%\rife-v4.26. The GPU device is selected from
%NCNN_GPU_ID%.
Supports -WhatIf and -Confirm for safe execution.
#>
@@ -161,15 +166,39 @@ if (-not $rifeExe) {
}
$rifeExePath = $rifeExe.Source
$rifeDir = Split-Path -Parent $rifeExePath
$rifeModelPath = Join-Path $rifeDir "rife-v4"
# Resolve the model directory via RIFE_HOME (required by the TNTwise fork's
# install layout, see mkdocs/RIFE.md). RIFE_HOME must point to the install
# folder containing the rife-v4.26 model directory.
$rifeHome = $env:RIFE_HOME
if (-not $rifeHome) {
throw "RIFE_HOME environment variable is not set. Set it to the rife-ncnn-vulkan install directory (the same folder that is on PATH)."
}
$rifeHome = $rifeHome.TrimEnd('\', '/')
if (-not (Test-Path -LiteralPath $rifeHome -PathType Container)) {
throw "RIFE_HOME directory does not exist: $rifeHome"
}
$rifeModelPath = Join-Path $rifeHome "rife-v4.26"
if (-not (Test-Path -LiteralPath $rifeModelPath -PathType Container)) {
throw "RIFE model directory not found at: $rifeModelPath"
throw "RIFE model directory not found at: $rifeModelPath. Ensure the rife-v4.26 model folder ships under %RIFE_HOME%."
}
# Resolve the GPU device ID for the -g flag from NCNN_GPU_ID.
$gpuId = $env:NCNN_GPU_ID
if (-not $gpuId) {
throw "NCNN_GPU_ID environment variable is not set. Set it to -1 (CPU) or 0/1/2/etc. (GPU device index)."
}
if ($gpuId -notmatch '^-?\d+$') {
throw "NCNN_GPU_ID must be an integer (-1 for CPU, 0+ for GPU). Current value: $gpuId"
}
Write-Host "Found rife-ncnn-vulkan.exe at: $rifeExePath"
Write-Host "Using RIFE_HOME: $rifeHome"
Write-Host "Using model path: $rifeModelPath"
Write-Host "Using GPU device (-g): $gpuId"
# --- Prepare TempDir ---
@@ -190,7 +219,7 @@ if (Test-Path -LiteralPath $tempDirPath) {
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
& $rifeExePath -0 $FirstFilename -1 $LastFilename -i $InputTempDir -o $TempDir -n $Count -m $rifeModelPath -g $gpuId
if ($LASTEXITCODE -ne 0) {
throw "rife-ncnn-vulkan.exe failed with exit code $LASTEXITCODE"