From 7ef52e2ca161d30cc2fa034d5ef7e8e10bdae18b Mon Sep 17 00:00:00 2001 From: Claude Toupin Date: Mon, 6 Jul 2026 23:21:40 -0400 Subject: [PATCH] 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 --- Clone-Video.ps1 | 205 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 203 insertions(+), 2 deletions(-) diff --git a/Clone-Video.ps1 b/Clone-Video.ps1 index d6d96c1..e18937b 100644 --- a/Clone-Video.ps1 +++ b/Clone-Video.ps1 @@ -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