Skip to content

Instantly share code, notes, and snippets.

@FaySmash
Last active December 21, 2025 19:04
Show Gist options
  • Select an option

  • Save FaySmash/0199afe5ecf1c7a313e2ff9bba9258b8 to your computer and use it in GitHub Desktop.

Select an option

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…
<#================================================================================================================
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