diff --git a/Clone-Video.ps1 b/Clone-Video.ps1 index e3031dd..d9b5d81 100644 --- a/Clone-Video.ps1 +++ b/Clone-Video.ps1 @@ -15,10 +15,16 @@ - -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, -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 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. + 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 Path to the output video file. Can be absolute or relative to current directory. .mp4 extension is added if not provided. .PARAMETER StartFrame @@ -50,6 +56,7 @@ Forces a video re-encode, since removing the audio stream requires re-muxing the output file. .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. + When Source is a supported image file, Audio determines the output video duration. .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. .PARAMETER Metadata @@ -71,6 +78,7 @@ 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. + 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. #> [CmdletBinding(SupportsShouldProcess)] @@ -265,6 +273,147 @@ if (-not [string]::IsNullOrWhiteSpace($Size)) { $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 if (Test-Path -LiteralPath $SourcePath -PathType Container) { if (-not [string]::IsNullOrWhiteSpace($Sequence)) {