Turning Our Atlanta Vacation Photos into a Video with PowerShell
Over the July 4th holiday, my family and I took a trip to Atlanta. It was one of those vacations that had a little bit of everything: time together, a change of scenery, good food, busy sidewalks, and a few moments that immediately felt like they were going to become family stories.
The biggest highlight for us was visiting the Georgia Aquarium and doing a beluga whale encounter. It is hard to describe how memorable that kind of experience is until you are standing there with your family, close enough to take in the size, movement, and personality of such an amazing animal. It was easily one of the parts of the trip we will be talking about for a long time.
During the encounter, a photographer captured more than 200 photos of us. The full photo set was over 1 GB, which was great for image quality but not great for sharing on the web. When I reviewed the photos later, I noticed something interesting: they were sequential in nature. They did not feel like a random collection of snapshots. They felt almost like frames from a video.
That gave me an idea. Instead of only sharing the photos one at a time, I could first reduce the oversized image set into a more web-friendly format, then turn the sequence into an MP4 video and include it with this article. The final video is embedded below.
From Vacation Memory to Automation Project
This is one of my favorite kinds of small technical projects because it starts with a real-life moment instead of a lab exercise.
I had a folder full of vacation photos from the beluga whale encounter. The photos were already ordered by filename, and because the photographer captured them in quick succession, the set had natural motion. When viewed quickly, the images showed small changes in posture, expressions, and movement from one frame to the next.
The size of the folder mattered too. More than 1 GB of original images is fine for archiving, but it is too heavy for a blog post or quick family share. Before the video could be useful online, the images needed to be normalized, resized, and compressed into something more practical.
That is basically what video is: a sequence of images displayed at a steady frame rate. Once I saw the pattern, the next step was obvious. I wanted PowerShell to prepare the images and FFmpeg to turn them into a web-friendly MP4.
The goal was not to build a full video editor. I wanted a repeatable script that could:
- Read all the JPG images from the encounter folder.
- Sort them in the correct order.
- Detect whether each photo was vertical or horizontal.
- Respect the EXIF orientation stored in the image.
- Normalize the images into numbered video frames.
- Reduce the large source photos into web-sized output.
- Create an MP4 that would work well on the web.
- Clean up the temporary frame files when the video was finished.
Why PowerShell Worked Well Here
PowerShell is usually thought of as an IT automation tool, but it is also a great glue language for creative workflows. In this case, it let me combine file handling, image inspection, folder management, and command-line video generation in one script.
The script uses PowerShell to scan the source folder recursively for .jpg and .jpeg files. It reads each image with System.Drawing, checks the EXIF orientation value, and determines whether the display orientation is vertical or horizontal.
That orientation step matters because many cameras and phones store photos with orientation metadata instead of physically rotating the pixels. If you ignore that, a video can come out sideways or inconsistently sized. The script accounts for that by mapping the EXIF orientation to the correct RotateFlipType before saving normalized frames.
After the images are inspected, the script separates vertical and horizontal photos into different sets. For each set, it calculates an output size that fits within web-friendly limits while keeping dimensions even. This is the step that turns the oversized original photo collection into something much easier for a browser to download and play. Even-numbered dimensions are important for broad MP4 compatibility, especially when encoding with H.264.
Then the script creates a temporary frame folder and writes the images as sequential files like this:
frame_000001.jpg frame_000002.jpg frame_000003.jpg
That numbered frame sequence is exactly what FFmpeg expects when creating a video from still images.
How FFmpeg Creates the MP4
PowerShell handles the preparation work, and FFmpeg handles the video encoding.
The script calls FFmpeg with a frame pattern, a frame rate, a scale-and-pad filter, H.264 encoding, and yuv420p pixel format. The +faststart flag is also included so the MP4 is friendlier for web playback.
In practical terms, the command tells FFmpeg:
- Use the normalized image frames as input.
- Play them back at the configured frame rate.
- Scale each frame into the target video size.
- Pad the frame if needed so the output dimensions stay consistent.
- Encode the result as an MP4 that browsers can play.
For this vacation video, I used a frame rate of 8. That was fast enough to make the sequence feel animated, but slow enough to preserve the feeling that it came from still photos.
Configure the Script
At the top of animate.ps1, set the source folder to the folder that contains the vacation photos:
$SourceFolder = "C:\Users\User\Downloads\showtime_3july2026-bw50-210pm-kc-g1_2026-07-03_2055\3July2026 BW50 210pm KC G1"
Set the path to ffmpeg.exe:
$FfmpegExe = "C:\FFmpeg\ffmpeg-7.0.2-essentials_build\bin\ffmpeg.exe"
The script writes finished videos into an _AnimatedVideos folder under the source folder:
$OutputFolder = Join-Path $SourceFolder "_AnimatedVideos"
You can adjust the frame rate if you want the final video to move faster or slower:
$FrameRate = 8
You can also tune the MP4 compression settings:
$Mp4Crf = 28 $Mp4Preset = "medium"
Lower CRF values create higher-quality video with larger file sizes. Higher CRF values create smaller files with more compression. For web sharing, the mid-to-high 20s are usually a reasonable starting point.
How to Run the Script
Open PowerShell, change to the folder where you saved the script, and run it with the script path:
.\script_location\scriptname.ps1
For this example, if the script is saved in C:\Scripts\VacationVideo, run:
Set-Location C:\Scripts\VacationVideo .\animate.ps1
If PowerShell blocks the script from running, read Make Sure PowerShell Scripts Can Run on Windows for the execution policy commands that allow local scripts to run.
A Small Script for a Big Memory
The best part of this project is that the technology stayed in service of the memory. PowerShell and FFmpeg did the mechanical work, but the reason for writing the script was personal: I wanted a better way to share one of the most memorable moments from our Atlanta trip.
It also reminded me that automation does not always have to be about servers, reports, cloud resources, or administrative tasks. Sometimes it can help preserve a family vacation, turn a folder of photos into something more watchable, and make a special moment easier to revisit.
PowerShell Script
Clear-Host
Push-Location $PSScriptRoot
#region Configuration
$SourceFolder = "C:\Users\User\Downloads\showtime_3july2026-bw50-210pm-kc-g1_2026-07-03_2055\3July2026 BW50 210pm KC G1"
$FfmpegExe = "C:\FFmpeg\ffmpeg-7.0.2-essentials_build\bin\ffmpeg.exe"
$OutputFolder = Join-Path $SourceFolder "_AnimatedVideos"
$FrameRate = 8
# Web MP4 sizing
$HorizontalMp4MaxWidth = 1280
$VerticalMp4MaxHeight = 1280
# MP4 compression
# Lower CRF = better quality / bigger file.
# Good web range is usually 23-30.
$Mp4Crf = 28
$Mp4Preset = "medium"
$DeleteTempFramesWhenDone = $true
#endregion Configuration
#region Functions
function Test-RequiredPath {
param (
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[ValidateSet("File", "Folder")]
[string]$PathType
)
Write-Host "Checking $Name..."
if ($PathType -eq "File") {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "$Name was not found: $Path"
}
}
if ($PathType -eq "Folder") {
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
throw "$Name was not found: $Path"
}
}
Write-Host "$Name check completed."
}
function Get-JpegExifOrientation {
param (
[Parameter(Mandatory = $true)]
[System.Drawing.Image]$Image
)
$orientationId = 274
if (-not ($Image.PropertyIdList -contains $orientationId)) {
return 1
}
$propertyItem = $Image.GetPropertyItem($orientationId)
if ($null -eq $propertyItem) {
return 1
}
if ($null -eq $propertyItem.Value -or $propertyItem.Value.Length -lt 2) {
return 1
}
return [System.BitConverter]::ToUInt16($propertyItem.Value, 0)
}
function Get-RotateFlipType {
param (
[Parameter(Mandatory = $true)]
[int]$ExifOrientation
)
switch ($ExifOrientation) {
2 { return [System.Drawing.RotateFlipType]::RotateNoneFlipX }
3 { return [System.Drawing.RotateFlipType]::Rotate180FlipNone }
4 { return [System.Drawing.RotateFlipType]::Rotate180FlipX }
5 { return [System.Drawing.RotateFlipType]::Rotate90FlipX }
6 { return [System.Drawing.RotateFlipType]::Rotate90FlipNone }
7 { return [System.Drawing.RotateFlipType]::Rotate270FlipX }
8 { return [System.Drawing.RotateFlipType]::Rotate270FlipNone }
default { return [System.Drawing.RotateFlipType]::RotateNoneFlipNone }
}
}
function Get-EvenNumber {
param (
[Parameter(Mandatory = $true)]
[int]$Number
)
if ($Number -lt 2) {
return 2
}
if (($Number % 2) -eq 0) {
return $Number
}
return ($Number - 1)
}
function Get-ImageInfo {
param (
[Parameter(Mandatory = $true)]
[System.IO.FileInfo]$ImageFile
)
$image = $null
try {
$image = [System.Drawing.Image]::FromFile($ImageFile.FullName)
$exifOrientation = Get-JpegExifOrientation -Image $image
$displayWidth = $image.Width
$displayHeight = $image.Height
if ($exifOrientation -in @(5, 6, 7, 8)) {
$displayWidth = $image.Height
$displayHeight = $image.Width
}
$orientation = "Square"
if ($displayWidth -gt $displayHeight) {
$orientation = "Horizontal"
}
elseif ($displayHeight -gt $displayWidth) {
$orientation = "Vertical"
}
[PSCustomObject]@{
File = $ImageFile
FullName = $ImageFile.FullName
Name = $ImageFile.Name
RawWidth = $image.Width
RawHeight = $image.Height
DisplayWidth = $displayWidth
DisplayHeight = $displayHeight
ExifOrientation = $exifOrientation
Orientation = $orientation
Area = ($displayWidth * $displayHeight)
}
}
finally {
if ($null -ne $image) {
$image.Dispose()
}
}
}
function Get-ScaledSize {
param (
[Parameter(Mandatory = $true)]
[array]$Images,
[Parameter(Mandatory = $true)]
[ValidateSet("Horizontal", "Vertical")]
[string]$Orientation
)
Write-Host "Calculating MP4 size for $Orientation image set..."
$largestImage = $Images |
Sort-Object Area -Descending |
Select-Object -First 1
$sourceWidth = [double]$largestImage.DisplayWidth
$sourceHeight = [double]$largestImage.DisplayHeight
if ($Orientation -eq "Horizontal") {
if ($sourceWidth -le $HorizontalMp4MaxWidth) {
$targetWidth = [int]$sourceWidth
$targetHeight = [int]$sourceHeight
}
else {
$scale = $HorizontalMp4MaxWidth / $sourceWidth
$targetWidth = [int][Math]::Round($sourceWidth * $scale)
$targetHeight = [int][Math]::Round($sourceHeight * $scale)
}
}
else {
if ($sourceHeight -le $VerticalMp4MaxHeight) {
$targetWidth = [int]$sourceWidth
$targetHeight = [int]$sourceHeight
}
else {
$scale = $VerticalMp4MaxHeight / $sourceHeight
$targetWidth = [int][Math]::Round($sourceWidth * $scale)
$targetHeight = [int][Math]::Round($sourceHeight * $scale)
}
}
$targetWidth = Get-EvenNumber -Number $targetWidth
$targetHeight = Get-EvenNumber -Number $targetHeight
Write-Host "MP4 size calculated: $targetWidth x $targetHeight"
[PSCustomObject]@{
Width = $targetWidth
Height = $targetHeight
}
}
function New-FrameSequence {
param (
[Parameter(Mandatory = $true)]
[array]$Images,
[Parameter(Mandatory = $true)]
[string]$FrameFolder
)
Write-Host "Creating frame folder: $FrameFolder"
if (Test-Path -LiteralPath $FrameFolder -PathType Container) {
Remove-Item -LiteralPath $FrameFolder -Recurse -Force
}
New-Item -Path $FrameFolder -ItemType Directory -Force | Out-Null
if (-not (Test-Path -LiteralPath $FrameFolder -PathType Container)) {
throw "Frame folder was not created: $FrameFolder"
}
Write-Host "Frame folder created."
$counter = 1
foreach ($item in $Images) {
$sourceImage = $null
$bitmap = $null
try {
$frameName = "frame_{0:D6}.jpg" -f $counter
$framePath = Join-Path $FrameFolder $frameName
Write-Host "Creating normalized frame $counter of $($Images.Count): $($item.Name)"
$sourceImage = [System.Drawing.Image]::FromFile($item.FullName)
$bitmap = New-Object System.Drawing.Bitmap $sourceImage
$rotateFlipType = Get-RotateFlipType -ExifOrientation $item.ExifOrientation
$bitmap.RotateFlip($rotateFlipType)
$bitmap.Save($framePath, [System.Drawing.Imaging.ImageFormat]::Jpeg)
if (-not (Test-Path -LiteralPath $framePath -PathType Leaf)) {
throw "Frame was not created: $framePath"
}
$counter++
}
finally {
if ($null -ne $bitmap) {
$bitmap.Dispose()
}
if ($null -ne $sourceImage) {
$sourceImage.Dispose()
}
}
}
Write-Host "Frame sequence completed: $FrameFolder"
}
function New-Mp4FromFrames {
param (
[Parameter(Mandatory = $true)]
[string]$FrameFolder,
[Parameter(Mandatory = $true)]
[string]$OutputVideo,
[Parameter(Mandatory = $true)]
[int]$OutputWidth,
[Parameter(Mandatory = $true)]
[int]$OutputHeight
)
Write-Host "Creating web-sized MP4: $OutputVideo"
$framePattern = Join-Path $FrameFolder "frame_%06d.jpg"
$filter = "scale=$OutputWidth`:$OutputHeight`:force_original_aspect_ratio=decrease,pad=$OutputWidth`:$OutputHeight`:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,format=yuv420p"
& $FfmpegExe `
-y `
-hide_banner `
-loglevel error `
-framerate $FrameRate `
-i $framePattern `
-vf $filter `
-c:v libx264 `
-preset $Mp4Preset `
-crf $Mp4Crf `
-pix_fmt yuv420p `
-movflags +faststart `
$OutputVideo
if ($LASTEXITCODE -ne 0) {
throw "FFmpeg MP4 generation failed for: $OutputVideo"
}
if (-not (Test-Path -LiteralPath $OutputVideo -PathType Leaf)) {
throw "MP4 was not created: $OutputVideo"
}
Write-Host "MP4 created: $OutputVideo"
}
function New-ImageSetMp4 {
param (
[Parameter(Mandatory = $true)]
[array]$Images,
[Parameter(Mandatory = $true)]
[ValidateSet("Horizontal", "Vertical")]
[string]$Orientation,
[Parameter(Mandatory = $true)]
[string]$Name
)
Write-Host "Starting $Name MP4 process."
$mp4Size = Get-ScaledSize -Images $Images -Orientation $Orientation
$frameFolder = Join-Path $OutputFolder "_frames_$Name"
$outputVideo = Join-Path $OutputFolder "animated_$Name.mp4"
New-FrameSequence -Images $Images -FrameFolder $frameFolder
New-Mp4FromFrames `
-FrameFolder $frameFolder `
-OutputVideo $outputVideo `
-OutputWidth $mp4Size.Width `
-OutputHeight $mp4Size.Height
if ($DeleteTempFramesWhenDone -eq $true) {
Write-Host "Cleaning $Name temp frames..."
if (Test-Path -LiteralPath $frameFolder -PathType Container) {
Remove-Item -LiteralPath $frameFolder -Recurse -Force
}
Write-Host "$Name temp frames cleaned."
}
Write-Host "$Name MP4 process completed."
}
#endregion Functions
#region Start
try {
Write-Host "Starting web-sized MP4 creation."
Write-Host "Loading System.Drawing..."
Add-Type -AssemblyName System.Drawing
Write-Host "System.Drawing loaded."
Test-RequiredPath -Path $SourceFolder -Name "Source folder" -PathType Folder
Test-RequiredPath -Path $FfmpegExe -Name "FFmpeg executable" -PathType File
Write-Host "Checking output folder..."
if (-not (Test-Path -LiteralPath $OutputFolder -PathType Container)) {
New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null
}
if (-not (Test-Path -LiteralPath $OutputFolder -PathType Container)) {
throw "Output folder was not created: $OutputFolder"
}
Write-Host "Output folder ready: $OutputFolder"
Write-Host "Finding JPG images..."
$imageFiles = Get-ChildItem -LiteralPath $SourceFolder -File -Recurse |
Where-Object {
$_.Extension.ToLower() -in @(".jpg", ".jpeg") -and
$_.FullName -notlike "$OutputFolder*"
} |
Sort-Object DirectoryName, Name
if ($imageFiles.Count -eq 0) {
throw "No JPG images were found in: $SourceFolder"
}
Write-Host "JPG images found: $($imageFiles.Count)"
Write-Host "Reading image dimensions and EXIF orientation..."
$imageInfo = foreach ($imageFile in $imageFiles) {
Get-ImageInfo -ImageFile $imageFile
}
Write-Host "Image dimension and EXIF scan completed."
$verticalImages = @($imageInfo | Where-Object { $_.Orientation -eq "Vertical" })
$horizontalImages = @($imageInfo | Where-Object { $_.Orientation -eq "Horizontal" })
Write-Host "Vertical JPG images found: $($verticalImages.Count)"
Write-Host "Horizontal JPG images found: $($horizontalImages.Count)"
if ($verticalImages.Count -gt 0) {
New-ImageSetMp4 -Images $verticalImages -Orientation "Vertical" -Name "vertical"
}
else {
Write-Host "Skipping vertical set because no vertical JPG images were found."
}
if ($horizontalImages.Count -gt 0) {
New-ImageSetMp4 -Images $horizontalImages -Orientation "Horizontal" -Name "horizontal"
}
else {
Write-Host "Skipping horizontal set because no horizontal JPG images were found."
}
Write-Host "Web-sized MP4 creation completed."
Write-Host "Output folder: $OutputFolder"
}
catch {
Write-Host "ERROR: $($_.Exception.Message)"
exit 1
}
#endregion Start