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:
+203
-2
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user