Compare commits

...

15 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
8 changed files with 1012 additions and 96 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
}
+191 -45
View File
@@ -23,16 +23,32 @@
.PARAMETER Size .PARAMETER Size
Size of the output video. Uses the input video's size if not specified. 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". 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 .PARAMETER FPS
Frames per second for the output video. Uses the input video's FPS if not specified. 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 .PARAMETER NoAudio
When present, excludes audio from the cloned video. 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 .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. 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 .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. 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 .PARAMETER Metadata
When present, copies available JSON metadata from the source video file to the target video file. 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 .DESCRIPTION
This script uses ffmpeg to clone (copy) a video file with optional frame range and audio exclusion. 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. When Sequence is provided, it extracts a specific frame to PNG and then clones the video accordingly.
@@ -71,7 +87,13 @@ param(
[int]$CRF = -1, [int]$CRF = -1,
[Parameter(Mandatory = $false)] [Parameter(Mandatory = $false)]
[switch]$Metadata [switch]$Metadata,
[Parameter(Mandatory = $false)]
[switch]$Reverse,
[Parameter(Mandatory = $false)]
[switch]$LTX
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
@@ -104,8 +126,47 @@ if (-not (Test-Path -LiteralPath $SourcePath)) {
$TargetPath = $Target $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' $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 $TargetDir = Split-Path -Parent $TargetPath
@@ -129,12 +190,15 @@ if (-not [string]::IsNullOrWhiteSpace($Audio)) {
exit 1 exit 1
} }
# Probe the external audio file's codec so a non-MP4-compatible codec # Probe the external audio file's codec so a codec that's not compatible with
# (e.g. Opus from a YouTube/DASH download) is transcoded to AAC instead # the target container (e.g. Opus into MP4, or AAC into WebM) is transcoded
# of being stream-copied into the MP4 container. # 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 $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) { if ($LASTEXITCODE -eq 0) {
$externalAudioCodec = ($ffprobeExtAudioOutput | Select-Object -First 1).Trim().ToLowerInvariant() $extAudioLine = $ffprobeExtAudioOutput | Select-Object -First 1
if ($extAudioLine) {
$externalAudioCodec = ([string]$extAudioLine).Trim().ToLowerInvariant()
}
} }
} }
@@ -176,7 +240,8 @@ if ($ffprobeExit -ne 0) {
exit 1 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+$') { if (-not $fpsRatio -or $fpsRatio -notmatch '^\d+/\d+$') {
Write-Error ('Could not determine frame rate for: {0}' -f $SourcePath) Write-Error ('Could not determine frame rate for: {0}' -f $SourcePath)
@@ -196,7 +261,8 @@ if ($ffprobeExit -ne 0) {
exit 1 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+$') { if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') {
Write-Error ('Could not determine frame count for: {0}' -f $SourcePath) Write-Error ('Could not determine frame count for: {0}' -f $SourcePath)
@@ -206,40 +272,107 @@ if (-not $totalFrames -or $totalFrames -notmatch '^\d+$') {
$totalFramesInt = [int]$totalFrames $totalFramesInt = [int]$totalFrames
$maxFrameIndex = $totalFramesInt - 1 $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 $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 $ffprobeExit = $LASTEXITCODE
$videoBitrate = $null $videoBitrate = $null
if ($ffprobeExit -eq 0) { if ($ffprobeExit -eq 0) {
$bitrateValue = ($ffprobeBitrateOutput | Select-Object -First 1).Trim() $bitrateLine = $ffprobeBitrateOutput | Select-Object -First 1
if ($bitrateLine) {
$bitrateValue = ([string]$bitrateLine).Trim()
if ($bitrateValue -and $bitrateValue -match '^\d+$') { if ($bitrateValue -and $bitrateValue -match '^\d+$') {
$videoBitrate = $bitrateValue $videoBitrate = $bitrateValue
} }
}
} }
# Probe source video and audio codecs. The MP4 container only supports a limited set # Probe source video and audio codecs. When the source codec isn't on the
# of codecs: when the source is VP9/VP8/AV1 video or Opus/Vorbis/etc. audio (typical # target container's stream-copy list, the script will transcode to the
# YouTube/Google DASH downloads), we must transcode rather than stream-copy. # 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 $ffprobeVideoCodecOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
$sourceVideoCodec = '' $sourceVideoCodec = ''
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
$sourceVideoCodec = ($ffprobeVideoCodecOutput | Select-Object -First 1).Trim().ToLowerInvariant() $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 $ffprobeAudioCodecOutput = & ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
$sourceAudioCodec = '' $sourceAudioCodec = ''
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
$sourceAudioCodec = ($ffprobeAudioCodecOutput | Select-Object -First 1).Trim().ToLowerInvariant() $aCodecLine = $ffprobeAudioCodecOutput | Select-Object -First 1
if ($aCodecLine) {
$sourceAudioCodec = ([string]$aCodecLine).Trim().ToLowerInvariant()
}
} }
# A non-H.264 source forces a video re-encode regardless of trimming/scaling/FPS changes. # A source codec not on the target container's stream-copy list forces a re-encode
$videoCodecMismatch = ($sourceVideoCodec -and $sourceVideoCodec -ne 'h264') # regardless of trimming/scaling/FPS changes.
$videoCodecMismatch = ($sourceVideoCodec -and ($videoCopyCodecs -notcontains $sourceVideoCodec))
# MP4-container-compatible audio codecs. Anything else (opus, vorbis, flac, ...) must $audioCodecMismatch = ($sourceAudioCodec -and ($audioCopyCodecs -notcontains $sourceAudioCodec))
# be transcoded to AAC when stream-copying audio. $externalAudioCodecMismatch = ($externalAudioCodec -and ($audioCopyCodecs -notcontains $externalAudioCodec))
$mp4AudioCodecs = @('aac', 'mp3', 'ac3', 'eac3', 'alac')
$audioCodecMismatch = ($sourceAudioCodec -and ($mp4AudioCodecs -notcontains $sourceAudioCodec))
$externalAudioCodecMismatch = ($externalAudioCodec -and ($mp4AudioCodecs -notcontains $externalAudioCodec))
if ($StartFrame -lt 0 -or $StartFrame -gt $maxFrameIndex) { if ($StartFrame -lt 0 -or $StartFrame -gt $maxFrameIndex) {
Write-Error ('StartFrame {0} is out of range. Valid range: 0 to {1}' -f $StartFrame, $maxFrameIndex) Write-Error ('StartFrame {0} is out of range. Valid range: 0 to {1}' -f $StartFrame, $maxFrameIndex)
@@ -310,23 +443,31 @@ if (-not [string]::IsNullOrWhiteSpace($Sequence)) {
$frameCount = $actualEndFrame - $StartFrame + 1 $frameCount = $actualEndFrame - $StartFrame + 1
Write-Host ('Cloning video: {0}' -f $SourcePath) Write-Host ('Cloning video: {0}' -f $SourcePath)
Write-Host (' Target container: {0} (video={1} audio={2})' -f $targetContainer, $targetVideoCodec, $targetAudioCodec)
if ($sourceVideoCodec) { if ($sourceVideoCodec) {
Write-Host (' Source codec: video={0}{1} audio={2}{3}' -f ` Write-Host (' Source codec: video={0}{1} audio={2}{3}' -f `
$sourceVideoCodec, $(if ($videoCodecMismatch) { ' (will transcode to h264)' } else { '' }), ` $sourceVideoCodec, $(if ($videoCodecMismatch) { " (will transcode to $targetVideoCodec)" } else { '' }), `
$(if ($sourceAudioCodec) { $sourceAudioCodec } else { 'none' }), ` $(if ($sourceAudioCodec) { $sourceAudioCodec } else { 'none' }), `
$(if ($audioCodecMismatch) { ' (will transcode to aac)' } else { '' })) $(if ($audioCodecMismatch) { " (will transcode to $targetAudioCodec)" } else { '' }))
} }
if ($AudioPath -and $externalAudioCodec) { if ($AudioPath -and $externalAudioCodec) {
Write-Host (' External audio codec: {0}{1}' -f ` Write-Host (' External audio codec: {0}{1}' -f `
$externalAudioCodec, $(if ($externalAudioCodecMismatch) { ' (will transcode to aac)' } else { '' })) $externalAudioCodec, $(if ($externalAudioCodecMismatch) { " (will transcode to $targetAudioCodec)" } else { '' }))
} }
Write-Host (' Frame range: {0} to {1} ({2} frames)' -f $StartFrame, $actualEndFrame, $frameCount) Write-Host (' Frame range: {0} to {1} ({2} frames)' -f $StartFrame, $actualEndFrame, $frameCount)
if ($scaleWidth -gt 0) { if ($scaleWidth -gt 0) {
if ($LTX) {
Write-Host (' Size: {0}x{1} (LTX-2)' -f $scaleWidth, $scaleHeight)
} else {
Write-Host (' Size: {0}x{1}' -f $scaleWidth, $scaleHeight) Write-Host (' Size: {0}x{1}' -f $scaleWidth, $scaleHeight)
}
} }
if ($FPS -gt 0) { if ($FPS -gt 0) {
Write-Host (' FPS: {0}' -f $FPS) Write-Host (' FPS: {0}' -f $FPS)
} }
if ($Reverse) {
Write-Host ' Reverse: video reversed, audio unchanged'
}
Write-Host (' Target: {0}' -f $TargetPath) Write-Host (' Target: {0}' -f $TargetPath)
$ffmpegArgs = @( $ffmpegArgs = @(
@@ -338,7 +479,7 @@ if ($AudioPath) {
$ffmpegArgs += @('-i', $AudioPath) $ffmpegArgs += @('-i', $AudioPath)
} }
$needsReencode = $scaleWidth -gt 0 -or $FPS -gt 0 -or $videoCodecMismatch $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) { if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
$filterParts = @('select=between(n\,{0}\,{1})' -f $StartFrame, $actualEndFrame) $filterParts = @('select=between(n\,{0}\,{1})' -f $StartFrame, $actualEndFrame)
@@ -349,20 +490,24 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
if ($FPS -gt 0) { if ($FPS -gt 0) {
$filterParts += ('fps={0}' -f $FPS) $filterParts += ('fps={0}' -f $FPS)
} }
if ($Reverse) {
$filterParts += 'reverse'
}
$videoFilter = $filterParts -join ',' $videoFilter = $filterParts -join ','
$ffmpegArgs += @( $ffmpegArgs += @(
'-vf', $videoFilter '-vf', $videoFilter
'-vsync', 'vfr' '-vsync', 'vfr'
'-c:v', 'libx264'
'-pix_fmt', 'yuv420p'
'-preset', 'slow'
) )
$ffmpegArgs += $targetVideoEncodeArgs
if ($CRF -ge 0) { if ($CRF -ge 0) {
$ffmpegArgs += @('-crf', $CRF.ToString()) $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) { } elseif ($videoBitrate) {
$ffmpegArgs += @('-b:v', $videoBitrate) $ffmpegArgs += @('-b:v', $videoBitrate)
} else { } else {
$ffmpegArgs += @('-crf', '14') $ffmpegArgs += @('-crf', '14')
if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') }
} }
} elseif ($needsReencode) { } elseif ($needsReencode) {
$filterParts = @() $filterParts = @()
@@ -372,21 +517,22 @@ if ($StartFrame -gt 0 -or $actualEndFrame -lt $maxFrameIndex) {
if ($FPS -gt 0) { if ($FPS -gt 0) {
$filterParts += ('fps={0}' -f $FPS) $filterParts += ('fps={0}' -f $FPS)
} }
if ($Reverse) {
$filterParts += 'reverse'
}
if ($filterParts.Count -gt 0) { if ($filterParts.Count -gt 0) {
$videoFilter = $filterParts -join ',' $videoFilter = $filterParts -join ','
$ffmpegArgs += @('-vf', $videoFilter) $ffmpegArgs += @('-vf', $videoFilter)
} }
$ffmpegArgs += @( $ffmpegArgs += $targetVideoEncodeArgs
'-c:v', 'libx264'
'-pix_fmt', 'yuv420p'
'-preset', 'slow'
)
if ($CRF -ge 0) { if ($CRF -ge 0) {
$ffmpegArgs += @('-crf', $CRF.ToString()) $ffmpegArgs += @('-crf', $CRF.ToString())
if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') }
} elseif ($videoBitrate) { } elseif ($videoBitrate) {
$ffmpegArgs += @('-b:v', $videoBitrate) $ffmpegArgs += @('-b:v', $videoBitrate)
} else { } else {
$ffmpegArgs += @('-crf', '14') $ffmpegArgs += @('-crf', '14')
if ($targetVideoCodec -eq 'libvpx-vp9') { $ffmpegArgs += @('-b:v', '0') }
} }
} else { } else {
$ffmpegArgs += @('-c:v', 'copy') $ffmpegArgs += @('-c:v', 'copy')
@@ -404,15 +550,16 @@ if ($NoAudio) {
'-map', '0:v' '-map', '0:v'
'-map', '1:a' '-map', '1:a'
'-t', $durationStr '-t', $durationStr
'-c:a', 'aac' '-c:a', $targetAudioCodec
'-b:a', $targetAudioBitrate
) )
} elseif ($externalAudioCodecMismatch) { } elseif ($externalAudioCodecMismatch) {
# External audio (e.g. Opus from a YouTube/DASH WebM) cannot be stream-copied into MP4. # External audio cannot be stream-copied into the target container.
$ffmpegArgs += @( $ffmpegArgs += @(
'-map', '0:v' '-map', '0:v'
'-map', '1:a' '-map', '1:a'
'-c:a', 'aac' '-c:a', $targetAudioCodec
'-b:a', '192k' '-b:a', $targetAudioBitrate
'-shortest' '-shortest'
) )
} else { } else {
@@ -431,14 +578,13 @@ if ($NoAudio) {
$endTimeStr = $endTime.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture) $endTimeStr = $endTime.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture)
$audioFilter = "atrim=start=${startTimeStr}:end=${endTimeStr},asetpts=PTS-STARTPTS" $audioFilter = "atrim=start=${startTimeStr}:end=${endTimeStr},asetpts=PTS-STARTPTS"
$ffmpegArgs += @('-af', $audioFilter) $ffmpegArgs += @('-af', $audioFilter)
if ($audioCodecMismatch) { # The atrim filter forces an audio re-encode; explicitly set the target
# ffmpeg will already re-encode here (no -c:a copy), but force AAC explicitly # container's audio codec so the result is always container-appropriate
# so the output is MP4-compatible regardless of source codec. # (e.g. Opus for WebM, AAC for MP4/MKV) rather than ffmpeg's default.
$ffmpegArgs += @('-c:a', 'aac', '-b:a', '192k') $ffmpegArgs += @('-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate)
}
} elseif ($audioCodecMismatch) { } elseif ($audioCodecMismatch) {
# Source audio (e.g. Opus from a YouTube/DASH WebM) cannot be stream-copied into MP4. # Source audio is not on the target container's stream-copy list.
$ffmpegArgs += @('-c:a', 'aac', '-b:a', '192k') $ffmpegArgs += @('-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate)
} else { } else {
$ffmpegArgs += @('-c:a', 'copy') $ffmpegArgs += @('-c:a', 'copy')
} }
+124 -2
View File
@@ -53,6 +53,25 @@
.PARAMETER In4K .PARAMETER In4K
Generate comparison video in 4K UHD (3840x2160) resolution instead of 1K HD (1920x1080). 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 .EXAMPLE
.\Compare-Videos.ps1 -S1 original.mp4 -S2 enhanced.mp4 -Output comparison.mp4 .\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-Videos.ps1 -S1 original.mp4 -S2 enhanced.mp4 -Output comparison_4k.mp4 -In4K
Compare two videos in 4K UHD (3840x2160) resolution. 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)] [CmdletBinding(SupportsShouldProcess)]
param( param(
@@ -97,7 +121,9 @@ param(
[switch]$NoAudio, [switch]$NoAudio,
[switch]$In4K [switch]$In4K,
[switch]$Clip
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -132,6 +158,11 @@ $videoCount = 2
if ($S4) { $videoCount = 4 } if ($S4) { $videoCount = 4 }
elseif ($S3) { $videoCount = 3 } 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 --- # --- Detect orientation of S1 ---
Write-Host "Detecting orientation of S1..." -ForegroundColor Cyan 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 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 = "" $filterComplex = ""
$inputArgs = @("-i", $S1, "-i", $S2) $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 # Portrait mode scaling - calculate cell dimensions based on output resolution
$cellWidth2 = [int]($outputWidth * 0.6333 / 2) # ~608 for 1K, ~1216 for 4K $cellWidth2 = [int]($outputWidth * 0.6333 / 2) # ~608 for 1K, ~1216 for 4K
$cellWidth3 = [int]($outputWidth * 0.6333 / 2) # ~608 for 1K, ~1216 for 4K $cellWidth3 = [int]($outputWidth * 0.6333 / 2) # ~608 for 1K, ~1216 for 4K
+62 -17
View File
@@ -5,10 +5,16 @@
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. .mp4 extension is added if not provided.
.PARAMETER Target .PARAMETER Target
Path to the output video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided. 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 .PARAMETER Effect
The effect to apply to the side bars. Possible values are: The effect to apply to the side bars. Possible values are:
black: The side bars will be black. black: The side bars will be solid black (#000000).
white: The side bars will be white. 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. 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. 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. gaussian: Generates smooth, highquality Gaussianblurred side bars for a clean, professional broadcast-style background.
@@ -33,8 +39,8 @@ param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Source, [string]$Source,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $false)]
[string]$Target, [string]$Target = $null,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Effect, [string]$Effect,
@@ -183,13 +189,37 @@ function Resolve-Size {
return [pscustomobject]@{ Width = $w; Height = $h } return [pscustomobject]@{ Width = $w; Height = $h }
} }
# Append .mp4 extension if missing # 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)) { if (-not [System.IO.Path]::GetExtension($Source)) {
$Source = "$Source.mp4" $Source = "$Source.mp4"
} }
if (-not [System.IO.Path]::GetExtension($Target)) {
$Target = "$Target.mp4"
}
if (-not (Test-Path -LiteralPath $Source)) { if (-not (Test-Path -LiteralPath $Source)) {
throw "Source video not found: $Source" throw "Source video not found: $Source"
@@ -197,6 +227,20 @@ if (-not (Test-Path -LiteralPath $Source)) {
$sourceFull = (Resolve-Path -LiteralPath $Source).Path $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) # Resolve Target to an absolute path (may not exist yet)
if ([System.IO.Path]::IsPathRooted($Target)) { if ([System.IO.Path]::IsPathRooted($Target)) {
$targetFull = $Target $targetFull = $Target
@@ -219,12 +263,6 @@ if ($CRF -ne -1 -and ($CRF -lt 0 -or $CRF -gt 51)) {
throw "Invalid -CRF value: $CRF. Must be in range 0-51." throw "Invalid -CRF value: $CRF. Must be in range 0-51."
} }
$validEffects = @('black', 'white', 'standard', 'dark', 'gaussian')
$effectLower = $Effect.ToLower()
if ($effectLower -notin $validEffects) {
throw "Invalid -Effect value: $Effect. Must be one of: $($validEffects -join ', ')"
}
$inInfo = Get-VideoInfo -Path $sourceFull $inInfo = Get-VideoInfo -Path $sourceFull
$inRes = "{0}x{1}" -f $inInfo.Width, $inInfo.Height $inRes = "{0}x{1}" -f $inInfo.Width, $inInfo.Height
@@ -245,14 +283,21 @@ if ($outHeight % 2) { $outHeight++ }
$scaleFg = "scale=w=${outWidth}:h=${outHeight}:force_original_aspect_ratio=decrease" $scaleFg = "scale=w=${outWidth}:h=${outHeight}:force_original_aspect_ratio=decrease"
# Build the video filter graph depending on the requested -Effect. # Build the video filter graph depending on the requested -Effect.
# black/white : simple pad with the solid color (uses -vf) # 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) # standard : softly blurred background with a subtle zoom (uses -filter_complex)
# dark : blurred background with reduced brightness/saturation (uses -filter_complex) # dark : blurred background with reduced brightness/saturation (uses -filter_complex)
# gaussian : high-quality Gaussian-blurred background (uses -filter_complex) # gaussian : high-quality Gaussian-blurred background (uses -filter_complex)
$useFilterComplex = $effectLower -in @('standard', 'dark', 'gaussian') $useFilterComplex = $effectLower -in $blurEffects
if (-not $useFilterComplex) { if (-not $useFilterComplex) {
$padExpr = "pad=${outWidth}:${outHeight}:(ow-iw)/2:(oh-ih)/2:${effectLower}" 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" $vf = "${scaleFg},${padExpr},setsar=1"
} }
else { else {
+14 -3
View File
@@ -6,7 +6,7 @@
.PARAMETER Target .PARAMETER Target
Target directory for cropped video files. Target directory for cropped video files.
.DESCRIPTION .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. Supports -WhatIf and -Confirm for safe execution.
#> #>
[CmdletBinding(SupportsShouldProcess)] [CmdletBinding(SupportsShouldProcess)]
@@ -113,7 +113,8 @@ foreach ($video in $videos) {
$inAudioText = if ($inInfo.HasAudio) { 'yes' } else { 'no' } $inAudioText = if ($inInfo.HasAudio) { 'yes' } else { 'no' }
$isValidResolution = ($inInfo.Width -eq 1928 -and $inInfo.Height -eq 1076) -or $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) { if (-not $isValidResolution) {
Write-Host "Skipping: $($inInfo.FileName) (resolution $inRes)" Write-Host "Skipping: $($inInfo.FileName) (resolution $inRes)"
@@ -132,10 +133,20 @@ foreach ($video in $videos) {
$outCropPath = Join-Path $targetFull ($video.BaseName + '.crop.mp4') $outCropPath = Join-Path $targetFull ($video.BaseName + '.crop.mp4')
$outFinalPath = Join-Path $targetFull $video.Name $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 = @( $ffArgs = @(
'-y', '-y',
'-i', $inputPath, '-i', $inputPath,
'-vf', 'scale=-2:1080,crop=1920:1080:(in_w-1920)/2:(in_h-1080)/2' '-vf', $videoFilter
) )
if ($inInfo.HasAudio) { if ($inInfo.HasAudio) {
+87 -18
View File
@@ -3,6 +3,8 @@
Extracts and displays video metadata from the comment tag in a video file's format metadata. Extracts and displays video metadata from the comment tag in a video file's format metadata.
.PARAMETER Video .PARAMETER Video
Path to the input video file. Can be absolute or relative to current directory. 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 .PARAMETER Full
When present, outputs the parsed comment metadata as JSON. When present, outputs the parsed comment metadata as JSON.
.DESCRIPTION .DESCRIPTION
@@ -23,17 +25,39 @@ $ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8
$VideoPath = $Video # Resolve video paths, supporting wildcards
$videoPaths = @()
if (-not (Test-Path -LiteralPath $VideoPath)) { if ($Video -match '[*?]') {
Write-Error ('Video file not found: {0}' -f $VideoPath) # 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 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 $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 { 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 $ffprobeExit = $LASTEXITCODE
if ($ffprobeExit -ne 0) { if ($ffprobeExit -ne 0) {
@@ -56,6 +80,34 @@ try {
$commentJson = $raw.format.tags.comment $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 { try {
$parsedComment = $commentJson | ConvertFrom-Json $parsedComment = $commentJson | ConvertFrom-Json
} }
@@ -71,7 +123,7 @@ try {
} }
Write-Output '----------------------------------------' 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' $iniPath = Join-Path $PSScriptRoot 'Get-VideoInfo.ini'
if (-not (Test-Path -LiteralPath $iniPath)) { if (-not (Test-Path -LiteralPath $iniPath)) {
@@ -123,22 +175,37 @@ try {
} }
} }
elseif ($jsonFieldName -eq 'video_length') { elseif ($jsonFieldName -eq 'video_length') {
$fps = 24.0 # Always use the real frame rate reported by ffprobe for the
$fieldExists = $parsedComment.PSObject.Properties.Name -contains 'force_fps' # duration calculation. The JSON metadata's force_fps may be a
# non-numeric mode marker (e.g. "control") or simply not match
if ($fieldExists) { # the actual encoded frame rate.
$fps = [double]$parsedComment.force_fps $fps = if ($actualFps) { $actualFps } else { 24.0 }
}
$frames = [int]$fieldValue $frames = [int]$fieldValue
$secondsAt24fps = [Math]::Round($frames / $fps, 1) $durationSeconds = [Math]::Round($frames / $fps, 1)
$valueDisplay = ('{0} frames ({1}s, {2} fps)' -f $frames, $secondsAt24fps, $fps) $valueDisplay = ('{0} frames ({1}s, {2} fps)' -f $frames, $durationSeconds, $fps)
} }
elseif ($fieldValue -is [array]) { 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 { else {
$valueDisplay = $fieldValue $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 '----------------------------------------' Write-Output '----------------------------------------'
} }
catch { catch {
Write-Error ('Unexpected error: {0}' -f $_.Exception.Message) Write-Error ('Unexpected error processing {0}: {1}' -f $VideoPath, $_.Exception.Message)
exit 1 # Continue to next file instead of exiting
continue
}
} }
+36 -7
View File
@@ -41,12 +41,17 @@
.NOTES .NOTES
Requires: 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 - ffmpeg in the system PATH
- PowerShell 7 or later - PowerShell 7 or later
The script automatically locates rife-ncnn-vulkan.exe from PATH and expects The script locates rife-ncnn-vulkan.exe from PATH and resolves the rife-v4.26
the rife-v4 model directory to be in the same directory as the executable. model directory from %RIFE_HOME%\rife-v4.26. The GPU device is selected from
%NCNN_GPU_ID%.
Supports -WhatIf and -Confirm for safe execution. Supports -WhatIf and -Confirm for safe execution.
#> #>
@@ -161,15 +166,39 @@ if (-not $rifeExe) {
} }
$rifeExePath = $rifeExe.Source $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)) { 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 "Found rife-ncnn-vulkan.exe at: $rifeExePath"
Write-Host "Using RIFE_HOME: $rifeHome"
Write-Host "Using model path: $rifeModelPath" Write-Host "Using model path: $rifeModelPath"
Write-Host "Using GPU device (-g): $gpuId"
# --- Prepare TempDir --- # --- Prepare TempDir ---
@@ -190,7 +219,7 @@ if (Test-Path -LiteralPath $tempDirPath) {
if ($PSCmdlet.ShouldProcess($TempDir, "Generate interpolated frames with rife-ncnn-vulkan (Count=$Count)")) { if ($PSCmdlet.ShouldProcess($TempDir, "Generate interpolated frames with rife-ncnn-vulkan (Count=$Count)")) {
Write-Host "Executing rife-ncnn-vulkan.exe (Count=$Count, EndsAt=$EndsAt)..." 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) { if ($LASTEXITCODE -ne 0) {
throw "rife-ncnn-vulkan.exe failed with exit code $LASTEXITCODE" throw "rife-ncnn-vulkan.exe failed with exit code $LASTEXITCODE"