Add PNG image sequence mode to Clone-Video.ps1 to build videos from directories of PNG files with alphabetical ordering, support for -Reverse, -StartFrame, -EndFrame, -Size, -FPS, -CRF, -Audio, -NoAudio, and -LTX parameters, with restrictions preventing -Sequence and -Metadata usage in this mode

This commit is contained in:
2026-07-06 23:21:40 -04:00
parent f69f9b367c
commit 7ef52e2ca1
+203 -2
View File
@@ -9,9 +9,15 @@
When Sequence is provided and EndFrame is not -1:
- First, the frame at EndFrame index is saved as a PNG image (using the Sequence value as filename).
- After, the video is cloned (copied) from StartFrame to EndFrame-1 (excluding the extracted frame).
When Source is a directory containing PNG files with sequence numbers, the script builds a video
from the image sequence, ordered alphabetically (or reverse alphabetically if -Reverse is specified).
- -Sequence and -Metadata are not allowed in this mode.
- Defaults: 24 FPS, CRF 8, no audio (unless -Audio is provided).
- -Size, -FPS, -CRF, -Audio, -NoAudio, -StartFrame, -EndFrame, -Reverse and -LTX are all honoured.
.PARAMETER Source
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.
Path to the input video file, or a directory containing PNG files with sequence numbers (e.g. frame_0000.png).
For video files: can be absolute or relative to current directory. If the path has no extension, .mp4, .webm and .mkv are tried in turn.
For PNG sequence directories: the script builds a video from the PNG files sorted alphabetically; -Sequence and -Metadata are not allowed in this mode.
.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
@@ -56,6 +62,7 @@
.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.
When Source is a directory, the script builds a video from the PNG image sequence found inside.
Supports -WhatIf and -Confirm for safe execution.
#>
[CmdletBinding(SupportsShouldProcess)]
@@ -240,6 +247,200 @@ if (-not [string]::IsNullOrWhiteSpace($Size)) {
$PSNativeCommandUseErrorActionPreference = $false
# PNG image sequence mode: when $Source is a directory containing PNG files
if (Test-Path -LiteralPath $SourcePath -PathType Container) {
if (-not [string]::IsNullOrWhiteSpace($Sequence)) {
Write-Error '-Sequence cannot be used when -Source is a PNG image directory.'
exit 1
}
if ($Metadata) {
Write-Error '-Metadata cannot be used when -Source is a PNG image directory.'
exit 1
}
$pngFiles = @(Get-ChildItem -LiteralPath $SourcePath -Filter '*.png' | Sort-Object Name)
if ($pngFiles.Count -eq 0) {
Write-Error ('No PNG files found in directory: {0}' -f $SourcePath)
exit 1
}
$maxPngIndex = $pngFiles.Count - 1
if ($StartFrame -lt 0 -or $StartFrame -gt $maxPngIndex) {
Write-Error ('StartFrame {0} is out of range. Valid range: 0 to {1}' -f $StartFrame, $maxPngIndex)
exit 1
}
$seqEndFrame = if ($EndFrame -eq -1) { $maxPngIndex } else {
if ($EndFrame -lt $StartFrame -or $EndFrame -gt $maxPngIndex) {
Write-Error ('EndFrame {0} is out of range. Valid range: {1} to {2}' -f $EndFrame, $StartFrame, $maxPngIndex)
exit 1
}
$EndFrame
}
$pngFiles = @($pngFiles[$StartFrame..$seqEndFrame])
if ($Reverse) {
$pngFiles = @($pngFiles | Sort-Object Name -Descending)
}
$seqFPS = if ($FPS -gt 0) { $FPS } else { 24 }
$seqCRF = if ($CRF -ge 0) { $CRF } else { 8 }
$firstPngPath = $pngFiles[0].FullName
$seqSrcWidth = 0
$seqSrcHeight = 0
$seqDimOut = & ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x -- $firstPngPath 2>&1
if ($LASTEXITCODE -eq 0) {
$seqDimLine = $seqDimOut | Select-Object -First 1
if ($seqDimLine -and ([string]$seqDimLine).Trim() -match '^(\d+)x(\d+)$') {
$seqSrcWidth = [int]$Matches[1]
$seqSrcHeight = [int]$Matches[2]
}
}
$seqOutWidth = $scaleWidth
$seqOutHeight = $scaleHeight
if ($standardSizeHeight -gt 0 -and -not $LTX -and $seqSrcWidth -gt 0 -and $seqSrcHeight -gt 0) {
if ($seqSrcWidth -ge $seqSrcHeight) {
$seqOutHeight = $standardSizeHeight
$seqOutWidth = [int][Math]::Round([double]$seqSrcWidth * $standardSizeHeight / $seqSrcHeight)
if ($seqOutWidth % 2) { $seqOutWidth++ }
} else {
$seqOutWidth = $standardSizeHeight
$seqOutHeight = [int][Math]::Round([double]$seqSrcHeight * $standardSizeHeight / $seqSrcWidth)
if ($seqOutHeight % 2) { $seqOutHeight++ }
}
}
if ($LTX) {
$seqLtxKey = $Size
if ($seqLtxKey -eq 'HD') { $seqLtxKey = '1080p' }
$seqLtxMap = @{
'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 $seqLtxMap.ContainsKey($seqLtxKey)) {
Write-Error '-LTX requires -Size to be one of: 480p, 720p, 1080p, HD'
exit 1
}
if ($seqSrcWidth -le 0 -or $seqSrcHeight -le 0) {
Write-Error 'Could not determine PNG dimensions; -LTX cannot pick an aspect-ratio bucket.'
exit 1
}
$seqAR = [double]$seqSrcWidth / [double]$seqSrcHeight
$seqBest = $null
$seqBestDiff = [double]::MaxValue
foreach ($seqEntry in $seqLtxMap[$seqLtxKey]) {
$seqR = [double]$seqEntry.W / [double]$seqEntry.H
$seqD = [Math]::Abs($seqR - $seqAR)
if ($seqD -lt $seqBestDiff) { $seqBestDiff = $seqD; $seqBest = $seqEntry }
}
$seqOutWidth = $seqBest.W
$seqOutHeight = $seqBest.H
}
if ($seqOutWidth -eq 0 -and $seqSrcWidth -gt 0) {
$seqOutWidth = $seqSrcWidth
$seqOutHeight = $seqSrcHeight
}
if ($seqOutWidth % 2) { $seqOutWidth++ }
if ($seqOutHeight % 2) { $seqOutHeight++ }
$concatTempFile = [System.IO.Path]::GetTempFileName()
try {
$frameDuration = (1.0 / $seqFPS).ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture)
$concatLines = @('ffconcat version 1.0')
foreach ($pf in $pngFiles) {
$pfPath = $pf.FullName.Replace('\', '/')
$concatLines += "file '$pfPath'"
$concatLines += "duration $frameDuration"
}
$concatLines += "file '$($pngFiles[-1].FullName.Replace('\', '/'))'"
[System.IO.File]::WriteAllLines($concatTempFile, $concatLines, [System.Text.UTF8Encoding]::new($false))
$seqFfmpegArgs = @(
'-y'
'-f', 'concat'
'-safe', '0'
'-i', $concatTempFile
)
if ($AudioPath) {
$seqFfmpegArgs += @('-i', $AudioPath)
}
$seqFfmpegArgs += @('-r', $seqFPS.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture))
if ($seqOutWidth -gt 0) {
$seqFfmpegArgs += @('-vf', ('scale={0}:{1}' -f $seqOutWidth, $seqOutHeight))
}
$seqFfmpegArgs += $targetVideoEncodeArgs
$seqFfmpegArgs += @('-crf', $seqCRF.ToString())
if ($targetVideoCodec -eq 'libvpx-vp9') { $seqFfmpegArgs += @('-b:v', '0') }
$seqExtAudioMismatch = ($externalAudioCodec -and ($audioCopyCodecs -notcontains $externalAudioCodec))
if ($NoAudio -or -not $AudioPath) {
$seqFfmpegArgs += @('-an')
} elseif ($seqExtAudioMismatch) {
$seqFfmpegArgs += @('-map', '0:v', '-map', '1:a', '-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate, '-shortest')
} else {
$seqFfmpegArgs += @('-map', '0:v', '-map', '1:a', '-c:a', 'copy', '-shortest')
}
$seqFfmpegArgs += @('-map_metadata', '-1')
$seqFfmpegArgs += @('--', $TargetPath)
Write-Host ('Building video from PNG sequence: {0}' -f $SourcePath)
Write-Host (' Target container: {0} (video={1})' -f $targetContainer, $targetVideoCodec)
Write-Host (' PNG files: {0} (first={1} last={2})' -f $pngFiles.Count, $pngFiles[0].Name, $pngFiles[-1].Name)
Write-Host (' FPS: {0}' -f $seqFPS)
if ($seqOutWidth -gt 0) {
if ($LTX) {
Write-Host (' Size: {0}x{1} (LTX-2)' -f $seqOutWidth, $seqOutHeight)
} else {
Write-Host (' Size: {0}x{1}' -f $seqOutWidth, $seqOutHeight)
}
}
Write-Host (' CRF: {0}' -f $seqCRF)
if ($Reverse) { Write-Host ' Order: reversed (descending alphabetical)' }
if ($AudioPath) {
Write-Host (' Audio: {0}' -f $AudioPath)
} else {
Write-Host ' Audio: none'
}
Write-Host (' Target: {0}' -f $TargetPath)
if ($PSCmdlet.ShouldProcess($TargetPath, 'Build video from PNG sequence')) {
$seqOutput = & ffmpeg @seqFfmpegArgs 2>&1
$seqExit = $LASTEXITCODE
if ($seqExit -ne 0) {
Write-Host 'ffmpeg error output:' -ForegroundColor Red
$seqOutput | ForEach-Object { Write-Host $_ }
Write-Error ('ffmpeg failed to build video from PNG sequence with exit code: {0}' -f $seqExit)
exit 1
}
Write-Host ('Video built successfully to: {0}' -f $TargetPath)
}
} finally {
if (Test-Path -LiteralPath $concatTempFile) {
Remove-Item -LiteralPath $concatTempFile -Force
}
}
exit 0
}
$ffprobeFpsOutput = & ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 -- $SourcePath 2>&1
$ffprobeExit = $LASTEXITCODE