Last active
December 21, 2025 19:04
-
-
Save FaySmash/0199afe5ecf1c7a313e2ff9bba9258b8 to your computer and use it in GitHub Desktop.
A PowerShell script which can create 1 second video collage aka animated mosiac, a video file made out of a lot of smaller video files. It takes a folder of source videos and a CSV like text file to position all the videos in a grid. Made to use in conjunction with my fork of image-collage-maker which creates such a CSV file. Code mostly generat…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <#================================================================================================================ | |
| Create‑GridVideo.ps1 | |
| -------------------- | |
| • Takes a text file that lists image‑files + X/Y coordinates. | |
| • Swaps the .jpg extension to .mp4 (the video must already exist next to the image). | |
| • Groups the videos by X‑coordinate → one *column* video per X value. | |
| • Each column video is a black canvas (width = final‑width / columns, height = final‑height) | |
| onto which every clip of that column is over‑laid at its Y‑position. | |
| • All column videos are exactly the same length as the source clips (1 s in the example). | |
| • Afterwards the column videos are stacked side‑by‑side (hstack) to produce the final grid. | |
| • The whole pipeline uses libx265 (HEVC) - no H.264 level limits. | |
| • -ScaleFactor <float> scales **both** the column canvases *and* the final canvas. | |
| Example: -ScaleFactor 0.5 ⇒ 160 × 9000 → 80 × 4500 per column, | |
| final 16000 × 9000 → 8000 × 4500. | |
| ---------------------------------------------------- | |
| USAGE | |
| .\Create‑GridVideo.ps1 -ListFile "C:\path\to\list.txt" ` | |
| -OutputFile "C:\out\grid.mp4" ` | |
| -ScaleFactor 0.5 # optional, defaults to 1.0 | |
| ================================================================================================================#> | |
| [CmdletBinding()] | |
| param( | |
| [Parameter(Mandatory=$true)] | |
| [string]$ListFile, | |
| [Parameter(Mandatory=$true)] | |
| [string]$OutputFile, | |
| # Scale applied to **every** canvas (columns and final output) | |
| [ValidateRange(0.1,10.0)] | |
| [double]$ScaleFactor = 0.5, | |
| # Number of characters that fit safely in a Windows command line. | |
| # 7000 leaves head‑room under the 8191‑character limit. | |
| [int]$BatchSize = 7000 | |
| ) | |
| Set-Location $PSScriptRoot | |
| # ------------------------------------------------- | |
| # 0️⃣ Basic sanity checks | |
| # ------------------------------------------------- | |
| if (-not (Test-Path $ListFile)) { Throw "List file not found: $ListFile" } | |
| if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) { | |
| Throw "ffmpeg not found in PATH - install it from https://ffmpeg.org/download.html" | |
| } | |
| $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path | |
| $tempDir = Join-Path $scriptDir "temp_grid_video" | |
| $null = New-Item -ItemType Directory -Force -Path $tempDir | |
| # ------------------------------------------------- | |
| # 1️⃣ Read and parse the list (skip the header line) | |
| # ------------------------------------------------- | |
| Write-Host "Reading list file..." | |
| $rawLines = Get-Content -Path $ListFile | | |
| Where-Object { $_ -and $_ -notmatch '^Grid dimension' } | |
| $entries = foreach ($line in $rawLines) { | |
| $parts = $line -split ';' | |
| if ($parts.Count -ne 3) { Continue } | |
| $imagePath = $parts[0].Trim() | |
| $x = [int]$parts[1].Trim() | |
| $y = [int]$parts[2].Trim() | |
| $videoPath = [IO.Path]::ChangeExtension($imagePath, '.mp4') | |
| if (-not (Test-Path $videoPath)) { | |
| Write-Warning "Corresponding video not found: $videoPath - skipping entry" | |
| Continue | |
| } | |
| [pscustomobject]@{ | |
| ImagePath = $imagePath | |
| VideoPath = $videoPath | |
| X = $x | |
| Y = $y | |
| } | |
| } | |
| if ($entries.Count -eq 0) { Throw "No valid entries found in $ListFile" } | |
| # ------------------------------------------------- | |
| # 2️⃣ Determine the *real* canvas size (width × height) | |
| # ------------------------------------------------- | |
| # Probe the first video - we assume every clip has the same size. | |
| $probe = ffprobe -v error -select_streams v:0 -show_entries stream=width,height ` | |
| -of csv=p=0 "$($entries[0].VideoPath)" 2>$null | |
| if (-not $probe) { Throw "Unable to probe the first video for size." } | |
| $vidW, $vidH = $probe -split ',' | |
| # ------------------------------------------------- | |
| # Save the native clip size – they will be used later | |
| # ------------------------------------------------- | |
| $ClipW = [int]$vidW | |
| $ClipH = [int]$vidH | |
| $maxX = ($entries | Measure-Object -Property X -Maximum).Maximum | |
| $maxY = ($entries | Measure-Object -Property Y -Maximum).Maximum | |
| $canvasW = $maxX + $vidW # right‑most edge + one clip width | |
| $canvasH = $maxY + $vidH # bottom edge + one clip height | |
| Write-Host "Base canvas (unscaled): ${canvasW}x${canvasH}" | |
| # ------------------------------------------------- | |
| # 3️⃣ Apply the ScaleFactor to everything | |
| # ------------------------------------------------- | |
| $scaledCanvasW = [int]([math]::Round($canvasW * $ScaleFactor)) | |
| $scaledCanvasH = [int]([math]::Round($canvasH * $ScaleFactor)) | |
| Write-Host "Scaled canvas: ${scaledCanvasW}x${scaledCanvasH} (ScaleFactor = $ScaleFactor)" | |
| # ------------------------------------------------- | |
| # 4️⃣ Determine columns (group by X) and column width | |
| # ------------------------------------------------- | |
| $distinctX = $entries.X | Sort-Object -Unique | |
| $colCount = $distinctX.Count | |
| Write-Host "Number of columns (distinct X values): $colCount" | |
| # Width of ONE column in the *real* canvas (before scaling) | |
| $colWidthReal = [int]([math]::Round($canvasW / $colCount)) | |
| # Scaled column width - this is the width that will actually be rendered. | |
| $colWidth = [int]([math]::Round($colWidthReal * $ScaleFactor)) | |
| Write-Host "Column width (scaled): $colWidth px" | |
| # ------------------------------------------------- | |
| # 5️⃣ Helper - build a single ffmpeg command that renders ONE column video | |
| # ------------------------------------------------- | |
| function Build-ColumnCommand { | |
| param( | |
| [int]$ColumnIndex, # only for naming the temp file | |
| [array]$ColumnEntries # all clips that belong to this column | |
| ) | |
| # ------------------------------------------------- | |
| # 5.1 INPUT arguments (one per clip) | |
| # ------------------------------------------------- | |
| $inputArgs = @() | |
| foreach ($e in $ColumnEntries) { | |
| $inputArgs += '-i', "`"$($e.VideoPath)`"" | |
| } | |
| # ------------------------------------------------- | |
| # 5.2 CALCULATE the *scaled* size of a single clip | |
| # (the same size is used for every clip in every column) | |
| # ------------------------------------------------- | |
| $scaledClipW = [int]([math]::Round($ClipW * $ScaleFactor)) | |
| $scaledClipH = [int]([math]::Round($ClipH * $ScaleFactor)) | |
| # ------------------------------------------------- | |
| # 5.3 FILTER COMPLEX for this column | |
| # ------------------------------------------------- | |
| # a) start with a black canvas of the (already‑scaled) column size | |
| $filter = "color=size=$colWidth" + "x" + "$scaledCanvasH" + ":rate=30:color=black[base]" | |
| # b) **scale every input clip** - give each a unique label [s0], [s1], … | |
| if ($ColumnEntries.Count -gt 0) { | |
| for ($i = 0; $i -lt $ColumnEntries.Count; $i++) { | |
| $filter += ";[${i}:v]scale=$scaledClipW" + ":" + "$scaledClipH[s$i]" | |
| } | |
| # c) first overlay - use the first *scaled* clip [s0] | |
| $first = $ColumnEntries[0] | |
| $y0 = [int]([math]::Round($first.Y * $ScaleFactor)) | |
| $filter += ";[base][s0]overlay=x=0:y=$y0[tmp0]" | |
| # d) remaining overlays - use the already‑scaled streams [s1] … [sN] | |
| for ($i = 1; $i -lt $ColumnEntries.Count; $i++) { | |
| $e = $ColumnEntries[$i] | |
| $yPos = [int]([math]::Round($e.Y * $ScaleFactor)) | |
| $prevTmp = "tmp$($i-1)" | |
| $currTmp = "tmp$i" | |
| $filter += ";[$prevTmp][s$i]overlay=x=0:y=$yPos[$currTmp]" | |
| } | |
| $finalLabel = "[tmp$($ColumnEntries.Count-1)]" | |
| } | |
| else { | |
| $finalLabel = "[base]" | |
| } | |
| # ------------------------------------------------- | |
| # 5.4 ENCODER (HEVC - libx265) | |
| # ------------------------------------------------- | |
| $enc = @( | |
| '-c:v','libx265', | |
| '-preset','slow', | |
| '-x265-params','profile=main', | |
| '-pix_fmt','yuv420p' | |
| ) | |
| # ------------------------------------------------- | |
| # 5.5 OUTPUT (force exactly 1‑second duration) | |
| # ------------------------------------------------- | |
| $outTmp = Join-Path $tempDir "col_$ColumnIndex.mp4" | |
| $args = @( | |
| $inputArgs | |
| '-filter_complex', $filter | |
| '-map', $finalLabel | |
| $enc | |
| '-t','1' # keep the same length as the source clips | |
| '-y', "`"$outTmp`"" | |
| ) | |
| return $args, $outTmp | |
| } | |
| # ------------------------------------------------- | |
| # 6️⃣ Build the list of column‑wise entry groups | |
| # ------------------------------------------------- | |
| $columns = @() | |
| foreach ($xVal in $distinctX) { | |
| $colEntries = $entries | | |
| Where-Object { $_.X -eq $xVal } | | |
| Sort-Object -Property Y | |
| $columns += ,$colEntries | |
| } | |
| # ------------------------------------------------- | |
| # 7️⃣ Render each column (one temporary video per column) | |
| # ------------------------------------------------- | |
| # Write-Host "Rendering $colCount column videos..." | |
| $columnFiles = @() | |
| for ($colIdx = 0; $colIdx -lt $colCount; $colIdx++) { | |
| $colEntries = $columns[$colIdx] | |
| Write-Host " Column $colIdx → $($colEntries.Count) clips" | |
| $ffArgs, $outTmp = Build-ColumnCommand -ColumnIndex $colIdx -ColumnEntries $colEntries | |
| $proc = Start-Process -FilePath ffmpeg -ArgumentList $ffArgs ` | |
| -NoNewWindow -Wait -PassThru | |
| if ($proc.ExitCode -ne 0) { | |
| Throw "ffmpeg failed while rendering column $colIdx (exit code $($proc.ExitCode))." | |
| } | |
| $columnFiles += $outTmp | |
| } | |
| # ------------------------------------------------- | |
| # 8️⃣ Stack all column videos side‑by‑side (final grid) | |
| # ------------------------------------------------- | |
| Write-Host "Stacking $($columnFiles.Count) column videos into the final grid..." | |
| # ---------- INPUT ---------- | |
| $finalInputArgs = @() | |
| foreach ($colFile in $columnFiles) { | |
| $finalInputArgs += '-i', "`"$colFile`"" | |
| } | |
| # ---------- FILTER COMPLEX ---------- | |
| # Build a clean hstack filter: [0:v][1:v]...[N‑1:v]hstack=inputs=N[stack] | |
| # Use sub‑expression syntax ${i} to force correct expansion. | |
| $streamLabels = @() | |
| for ($i = 0; $i -lt $columnFiles.Count; $i++) { | |
| $streamLabels += "[${i}:v]" | |
| } | |
| $hstackFilter = ($streamLabels -join '') + "hstack=inputs=$($columnFiles.Count)[stack]" | |
| # ---------- ENCODER ---------- | |
| $finalEnc = @( | |
| '-c:v','libx265', | |
| '-preset','slow', | |
| '-x265-params','profile=main', | |
| '-pix_fmt','yuv420p' | |
| ) | |
| # ---------- OUTPUT ---------- | |
| $finalArgs = @( | |
| $finalInputArgs | |
| '-filter_complex', $hstackFilter | |
| '-map', '[stack]' | |
| $finalEnc | |
| '-t','1' # keep the video exactly 1 second long | |
| '-y', "`"$OutputFile`"" | |
| ) | |
| $proc = Start-Process -FilePath ffmpeg -ArgumentList $finalArgs ` | |
| -NoNewWindow -Wait -PassThru | |
| if ($proc.ExitCode -ne 0) { | |
| Throw "ffmpeg failed while stacking columns (exit code $($proc.ExitCode))." | |
| } | |
| Write-Host "`n✅ Finished! Final video saved to: $OutputFile" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment