Compare commits
17 Commits
d20189537f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8addeb0db1 | |||
| fb4f371450 | |||
| 1e58558308 | |||
| 46335f2cb1 | |||
| a057ab0d1d | |||
| 9cbe54d8d3 | |||
| fcfa99dddc | |||
| 201cc2625f | |||
| a687bb5348 | |||
| edf9f6b874 | |||
| fab3020dd0 | |||
| 6b5a67dddd | |||
| ad31100d64 | |||
| 5bf1b5caae | |||
| 567af452f1 | |||
| d9c0eb3303 | |||
| 1e78cc67ae |
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Clone-Image.ps1" %*
|
||||
+492
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Create-PillarBox.ps1" %*
|
||||
@@ -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, high‑quality Gaussian‑blurred 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user