0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1
JPPINTO
  • Home
  • Blog
  • Certifications
  • About
  • Contact
  • Shop
  • Gallery
  • Current Setup
Contact

Search

July 7, 2026 / Family, PowerShell, Travel

Turning Our Atlanta Vacation Photos into a Video with PowerShell

Tags: atlanta, ffmpeg, georgia aquarium, photo automation, powershell, video
Featured image for 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
Post Views: 29
<- Bulk Create Cloudflare Origin CA Certificates with PowerShell

Categories

  • Active Directory (5)
  • AI (2)
  • Amazon Cloud Services (1)
  • AWS (2)
  • Blazor (1)
  • C# (C-Sharp) (3)
  • CI/CD Pipelines (1)
  • Cloud (1)
  • Cloudflare (2)
  • Containers (4)
  • Deployment (2)
  • Development (4)
  • Docker (3)
  • Family (1)
  • General (5)
  • IIS 6.0 (4)
  • IIS 7.0 (10)
  • IIS 8.0 (1)
  • Infrastructure as Code (IaC) (1)
  • Kubernetes (3)
  • Linux (9)
  • Microsoft 365 (2)
  • MySQL (1)
  • Office 2010 (1)
  • PHP (1)
  • PowerShell (11)
  • Productivity (1)
  • Servers (9)
  • SharePoint 2007 (8)
  • SharePoint 2010 (19)
  • SharePoint 2013 (2)
  • SharePoint Online (1)
  • SMTP (4)
  • SQL Server 2008 (1)
  • SQL Server 2008 R2 (1)
  • SQL Server 2012 (2)
  • SQL Server 2019 (1)
  • SSL (1)
  • Travel (1)
  • Troubleshooting (1)
  • Ubuntu (9)
  • Uncategorized (1)
  • URL Rewrite (2)
  • Visual Studio 2019 (1)
  • Visual Studio Code (1)
  • Windows 10 (7)
  • Windows 2003 (9)
  • Windows 2008 (18)
  • Windows 2012 (6)
  • Windows 7 (3)
  • Windows Firewall (1)
  • Windows Vista (1)
  • WordPress (3)
  • WP-CLI (3)

Recent Posts

  • Turning Our Atlanta Vacation Photos into a Video with PowerShell
  • Bulk Create Cloudflare Origin CA Certificates with PowerShell
  • Test a Cloudflare Global API Key Connection with PowerShell
  • Entering .com in Bucket Names Causes SSL Errors
  • Transfer S3 Contents Between Buckets with PowerShell

Advertisement

Tags

ai coding agents aws bash cloudflare cloud storage developer workflow dev to production externalize blob externalize sharepoint data filezilla server full installation http redirect https IIS iis7 iis 7 installation IIS installation index server configuration installing cumulative updates linux load balance central administration microsoft 365 nginx powerpoint powershell redirect http to https s3 server setup sharepoint 2010 cumulative updates sharepoint 2010 farm build sharepoint 2010 farm configuration sharepoint 2010 farm installation sharepoint data externalization SMTP ssl storagepoint ubuntu web server windows Windows 7 windows firewall configuration windows server 2008 wordpress wp-cli x86
© 2026 JPPinto.com. All rights reserved.