Add image-to-video mode to Clone-Video.ps1 for building videos from static images with audio duration matching, supporting .png, .jpg, .jpeg, .webp, .bmp, .tiff, .tif, and .gif formats with -Size, -FPS, -CRF, and -LTX parameters, using ffmpeg's stillimage tune for libx264 and -shortest flag for audio synchronization, while prohibiting -NoAudio and ignoring -StartFrame, -EndFrame, -Frames, -Sequence, -Metadata, and -Reverse in this mode

This commit is contained in:
2026-08-02 09:01:28 -04:00
parent 56ddfc4371
commit 85ab88adeb
+149
View File
@@ -15,10 +15,16 @@
- -Sequence and -Metadata are not allowed in this mode. - -Sequence and -Metadata are not allowed in this mode.
- Defaults: 24 FPS, CRF 8, no audio (unless -Audio is provided). - Defaults: 24 FPS, CRF 8, no audio (unless -Audio is provided).
- -Size, -FPS, -CRF, -Audio, -NoAudio, -StartFrame, -EndFrame, -Frames, -Reverse and -LTX are all honoured. - -Size, -FPS, -CRF, -Audio, -NoAudio, -StartFrame, -EndFrame, -Frames, -Reverse and -LTX are all honoured.
When Source is a supported image file (.png, .jpg, .jpeg, .webp, .bmp, .tiff, .tif, .gif) and Audio is provided, the script builds a video of the static image for the duration of the audio file.
- -NoAudio is not allowed in this mode.
- Defaults: 24 FPS, CRF 8, source image dimensions.
- -Size, -FPS, -CRF and -LTX are honoured.
- -StartFrame, -EndFrame, -Frames, -Sequence, -Metadata and -Reverse are ignored.
.PARAMETER Source .PARAMETER Source
Path to the input video file, or a directory containing PNG files with sequence numbers (e.g. frame_0000.png). 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 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. For PNG sequence directories: the script builds a video from the PNG files sorted alphabetically; -Sequence and -Metadata are not allowed in this mode.
For supported image files (.png, .jpg, .jpeg, .webp, .bmp, .tiff, .tif, .gif): when -Audio is provided, builds a video of the static image for the audio duration; -NoAudio is not allowed.
.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.
.PARAMETER StartFrame .PARAMETER StartFrame
@@ -50,6 +56,7 @@
Forces a video re-encode, since removing the audio stream requires re-muxing the output file. 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.
When Source is a supported image file, Audio determines the output video duration.
.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
@@ -71,6 +78,7 @@
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.
When Source is a directory, the script builds a video from the PNG image sequence found inside. When Source is a directory, the script builds a video from the PNG image sequence found inside.
When Source is a supported image file and Audio is provided, builds a video of the static image for the audio duration.
Supports -WhatIf and -Confirm for safe execution. Supports -WhatIf and -Confirm for safe execution.
#> #>
[CmdletBinding(SupportsShouldProcess)] [CmdletBinding(SupportsShouldProcess)]
@@ -265,6 +273,147 @@ if (-not [string]::IsNullOrWhiteSpace($Size)) {
$PSNativeCommandUseErrorActionPreference = $false $PSNativeCommandUseErrorActionPreference = $false
# Image-to-video mode: when $Source is a supported image file and $Audio is provided.
# Builds a video of the static image for the duration of the audio file.
$supportedImageExts = @('.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.tif', '.gif')
$sourceExt = [System.IO.Path]::GetExtension($SourcePath).ToLowerInvariant()
if (($supportedImageExts -contains $sourceExt) -and $AudioPath) {
if ($NoAudio) {
Write-Error '-NoAudio cannot be used in image-to-video mode'
exit 1
}
$imgSrcWidth = 0
$imgSrcHeight = 0
$imgDimOut = & 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) {
$imgDimLine = $imgDimOut | Select-Object -First 1
if ($imgDimLine -and ([string]$imgDimLine).Trim() -match '^(\d+)x(\d+)$') {
$imgSrcWidth = [int]$Matches[1]
$imgSrcHeight = [int]$Matches[2]
}
}
$imgFPS = if ($FPS -gt 0) { $FPS } else { 24 }
$imgCRF = if ($CRF -ge 0) { $CRF } else { 8 }
$imgOutWidth = $scaleWidth
$imgOutHeight = $scaleHeight
if ($standardSizeHeight -gt 0 -and -not $LTX -and $imgSrcWidth -gt 0 -and $imgSrcHeight -gt 0) {
if ($imgSrcWidth -ge $imgSrcHeight) {
$imgOutHeight = $standardSizeHeight
$imgOutWidth = [int][Math]::Round([double]$imgSrcWidth * $standardSizeHeight / $imgSrcHeight)
if ($imgOutWidth % 2) { $imgOutWidth++ }
} else {
$imgOutWidth = $standardSizeHeight
$imgOutHeight = [int][Math]::Round([double]$imgSrcHeight * $standardSizeHeight / $imgSrcWidth)
if ($imgOutHeight % 2) { $imgOutHeight++ }
}
}
if ($LTX) {
$imgLtxKey = $Size
if ($imgLtxKey -eq 'HD') { $imgLtxKey = '1080p' }
$imgLtxMap = @{
'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 $imgLtxMap.ContainsKey($imgLtxKey)) {
Write-Error '-LTX requires -Size to be one of: 480p, 720p, 1080p, HD'
exit 1
}
if ($imgSrcWidth -le 0 -or $imgSrcHeight -le 0) {
Write-Error 'Could not determine image dimensions; -LTX cannot pick an aspect-ratio bucket.'
exit 1
}
$imgAR = [double]$imgSrcWidth / [double]$imgSrcHeight
$imgBest = $null
$imgBestDiff = [double]::MaxValue
foreach ($imgEntry in $imgLtxMap[$imgLtxKey]) {
$imgR = [double]$imgEntry.W / [double]$imgEntry.H
$imgD = [Math]::Abs($imgR - $imgAR)
if ($imgD -lt $imgBestDiff) { $imgBestDiff = $imgD; $imgBest = $imgEntry }
}
$imgOutWidth = $imgBest.W
$imgOutHeight = $imgBest.H
}
if ($imgOutWidth -eq 0 -and $imgSrcWidth -gt 0) {
$imgOutWidth = $imgSrcWidth
$imgOutHeight = $imgSrcHeight
}
if ($imgOutWidth % 2) { $imgOutWidth++ }
if ($imgOutHeight % 2) { $imgOutHeight++ }
$imgFfmpegArgs = @(
'-y'
'-loop', '1'
'-i', $SourcePath
'-i', $AudioPath
'-r', $imgFPS.ToString('0.######', [System.Globalization.CultureInfo]::InvariantCulture)
)
if ($imgOutWidth -gt 0) {
$imgFfmpegArgs += @('-vf', ('scale={0}:{1}' -f $imgOutWidth, $imgOutHeight))
}
$imgFfmpegArgs += $targetVideoEncodeArgs
if ($targetVideoCodec -eq 'libx264') {
$imgFfmpegArgs += @('-tune', 'stillimage')
}
$imgFfmpegArgs += @('-crf', $imgCRF.ToString())
if ($targetVideoCodec -eq 'libvpx-vp9') { $imgFfmpegArgs += @('-b:v', '0') }
$imgExtAudioMismatch = ($externalAudioCodec -and ($audioCopyCodecs -notcontains $externalAudioCodec))
if ($imgExtAudioMismatch) {
$imgFfmpegArgs += @('-map', '0:v', '-map', '1:a', '-c:a', $targetAudioCodec, '-b:a', $targetAudioBitrate)
} else {
$imgFfmpegArgs += @('-map', '0:v', '-map', '1:a', '-c:a', 'copy')
}
$imgFfmpegArgs += @('-shortest')
$imgFfmpegArgs += @('-map_metadata', '-1')
$imgFfmpegArgs += @('--', $TargetPath)
Write-Host ('Building video from image: {0}' -f $SourcePath)
Write-Host (' Target container: {0} (video={1})' -f $targetContainer, $targetVideoCodec)
Write-Host (' FPS: {0}' -f $imgFPS)
if ($imgOutWidth -gt 0) {
if ($LTX) {
Write-Host (' Size: {0}x{1} (LTX-2)' -f $imgOutWidth, $imgOutHeight)
} else {
Write-Host (' Size: {0}x{1}' -f $imgOutWidth, $imgOutHeight)
}
}
Write-Host (' CRF: {0}' -f $imgCRF)
Write-Host (' Audio: {0}' -f $AudioPath)
Write-Host (' Target: {0}' -f $TargetPath)
if ($PSCmdlet.ShouldProcess($TargetPath, 'Build video from image')) {
$imgOutput = & ffmpeg @imgFfmpegArgs 2>&1
$imgExit = $LASTEXITCODE
if ($imgExit -ne 0) {
Write-Host 'ffmpeg error output:' -ForegroundColor Red
$imgOutput | ForEach-Object { Write-Host $_ }
Write-Error ('ffmpeg failed to build video from image with exit code: {0}' -f $imgExit)
exit 1
}
Write-Host ('Video built successfully to: {0}' -f $TargetPath)
}
exit 0
}
# PNG image sequence mode: when $Source is a directory containing PNG files # PNG image sequence mode: when $Source is a directory containing PNG files
if (Test-Path -LiteralPath $SourcePath -PathType Container) { if (Test-Path -LiteralPath $SourcePath -PathType Container) {
if (-not [string]::IsNullOrWhiteSpace($Sequence)) { if (-not [string]::IsNullOrWhiteSpace($Sequence)) {