public/Add-IntroToRecording.ps1


function Add-IntroToRecording {
    <#
.SYNOPSIS
    Stitches an intro video into a Zoom recording at a given timestamp.

.DESCRIPTION
    Inserts the intro clip into a recording at -Timestamp. By default everything in the recording is kept:
    the part before the timestamp, then the intro, then the rest of the recording. Use -TrimBefore to throw
    away everything before the timestamp (handy for cutting the "waiting for people to join" chatter), and
    -EndTimestamp to cut the tail of the recording.

    The intro is automatically scaled/padded, frame-rate matched and audio-resampled to match the recording,
    so it works with 720p, 1080p, or odd Zoom resolutions. Requires ffmpeg and ffprobe on PATH
    (choco install ffmpeg).

.PARAMETER RecordingPath
    Path to the Zoom recording (mp4/m4a/mov/mkv).

.PARAMETER Timestamp
    Where in the recording the intro should be inserted. Accepts hh:mm:ss, mm:ss, hh:mm:ss.fff, or plain seconds.

.PARAMETER IntroPath
    Path to the intro video. Defaults to psoh_intro.mp4 in the module root.

.PARAMETER OutputPath
    Where to write the result. Defaults to <yyyy-MM-dd>_with_intro.mp4 using the meeting date, next to the recording.

.PARAMETER TrimBefore
    Discard the part of the recording before -Timestamp so the video opens with the intro.

.PARAMETER EndTimestamp
    Optional. Cut the recording at this point (measured in the original recording's time).

.PARAMETER MeetingDate
    The meeting date stamped under "psoh.io" in the intro. If omitted, it is read from a Zoom-style file name
    (GMTyyyyMMdd-HHmmss..., converted from UTC to local time); otherwise the recording's last-modified date is used.

.PARAMETER DateFormat
    A .NET format string for the date. Default 'MMMM d, yyyy' (e.g. October 30, 2026).
    Anything in single quotes is literal, e.g. "'Recorded' MMMM d, yyyy".

.PARAMETER NoDate
    Don't stamp a date on the intro.

.PARAMETER FontPath
    Font used for the date. Defaults to Poppins Light if installed (matches the intro), then Segoe UI Light, then Arial.

.PARAMETER FadeSeconds
    Length of the fade between the recording and the intro. Default 0.5. Use 0 for hard cuts.

.EXAMPLE
    Add-IntroToRecording -RecordingPath .\GMT20261030.mp4 -Timestamp 00:04:12 -TrimBefore

    Drops the first 4m12s of pre-meeting chatter, opens with the intro, then plays the meeting.

.EXAMPLE
    $stitchParams = @{
        RecordingPath = '.\meeting.mp4'
        Timestamp = '3:05'
        MeetingDate = '2026-11-12'
        TrimBefore = $true
    }
    Add-IntroToRecording @stitchParams

    Stamps "November 12, 2026" on the intro.

.EXAMPLE
    Add-IntroToRecording -RecordingPath .\meeting.mp4 -Timestamp 12:30 -EndTimestamp 1:31:05

    Keeps the welcome/announcements, plays the intro at 12:30 (e.g. right before the main talk), and cuts after 1:31:05.
#>

[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory, Position = 0)]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
    [string]
    $RecordingPath,

    [Parameter(Mandatory, Position = 1)]
    [string]
    $Timestamp,

    [Parameter(Mandatory)]
    [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
    [string]
    $IntroPath,

    [Parameter()]
    [string]
    $OutputPath,

    [Parameter()]
    [switch]
    $TrimBefore,

    [Parameter()]
    [string]
    $EndTimestamp,

    [Parameter()]
    [datetime]
    $MeetingDate,

    [Parameter()]
    [string]
    $DateFormat = 'MMMM d, yyyy',

    [Parameter()]
    [switch]
    $NoDate,

    [Parameter()]
    [string]
    $FontPath,

    [Parameter()]
    [ValidateRange(0, 5)]
    [double]
    $FadeSeconds = 0.5,

    [Parameter()]
    [ValidateRange(0, 51)]
    [int]
    $Crf = 20,

    [Parameter()]
    [ValidateSet('ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow')]
    [string]
    $Preset = 'medium'
)

$ErrorActionPreference = 'Stop'
$inv = [System.Globalization.CultureInfo]::InvariantCulture

# --- Pre-flight --------------------------------------------------------------------------------
foreach ($tool in 'ffmpeg', 'ffprobe') {
    if (-not (Get-Command -Name $tool -ErrorAction SilentlyContinue)) {
        throw "$tool was not found on PATH. Install it with: Install-ZoomIntroPrereqs"
    }
}

$RecordingPath = (Resolve-Path -LiteralPath $RecordingPath).ProviderPath
$IntroPath = (Resolve-Path -LiteralPath $IntroPath).ProviderPath

if (-not $PSBoundParameters.ContainsKey('MeetingDate')) {
    $MeetingDate = Get-MeetingDate -Path $RecordingPath
    Write-Verbose -Message ("MeetingDate not supplied, using {0:yyyy-MM-dd} from the recording" -f $MeetingDate)
}

if (-not $OutputPath) {
    $outSplat = @{
        Path      = Split-Path -Path $RecordingPath -Parent
        ChildPath = '{0}_with_intro.mp4' -f $MeetingDate.ToString('yyyy-MM-dd', $inv)
    }
    $OutputPath = Join-Path @outSplat
}
$OutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)

$rec = Get-MediaInfo -Path $RecordingPath
$intro = Get-MediaInfo -Path $IntroPath

$start = ConvertTo-Seconds -Value $Timestamp
$end = if ($EndTimestamp) { ConvertTo-Seconds -Value $EndTimestamp } else { $rec.Duration }

if ($start -lt 0 -or $start -ge $rec.Duration) {
    throw ("Timestamp {0}s is outside the recording (length {1}s)." -f (Format-Seconds -Seconds $start), (Format-Seconds -Seconds $rec.Duration))
}
if ($end -le $start) { throw 'EndTimestamp must be after Timestamp.' }
$end = [math]::Min($end, $rec.Duration)

# Even dimensions are required for yuv420p / H.264
$w = $rec.Width - ($rec.Width % 2)
$h = $rec.Height - ($rec.Height % 2)
$fps = Format-Seconds -Seconds $rec.Fps
$sr = $rec.SampleRate
$fade = Format-Seconds -Seconds $FadeSeconds
$keepBefore = (-not $TrimBefore) -and ($start -gt 0.05)

# --- Meeting date overlay ----------------------------------------------------------------------
$dateText = $null
$workDir = $null
$dateFilter = ''
if (-not $NoDate) {
    $dateText = $MeetingDate.ToString($DateFormat)
    if (-not $FontPath) { $FontPath = Find-DateFont }
    $FontPath = (Resolve-Path -LiteralPath $FontPath).ProviderPath

    # ffmpeg filter options choke on drive-letter colons, commas and apostrophes, so the font and the
    # date text are placed in a temp folder under plain names and ffmpeg is run from that folder.
    # (WhatIf = $false so these temp-file steps still run under -WhatIf)
    $workSplat = @{
        Path      = [System.IO.Path]::GetTempPath()
        ChildPath = 'meeting_intro_' + [guid]::NewGuid().ToString('N')
    }
    $dirSplat = @{
        ItemType = 'Directory'
        Path     = Join-Path @workSplat
        WhatIf   = $false
    }
    $workDir = (New-Item @dirSplat).FullName
    $copySplat = @{
        LiteralPath = $FontPath
        Destination = Join-Path -Path $workDir -ChildPath ('datefont' + [System.IO.Path]::GetExtension($FontPath))
        WhatIf      = $false
    }
    Copy-Item @copySplat
    $fontName = Split-Path -Path $copySplat.Destination -Leaf
    $dateFile = Join-Path -Path $workDir -ChildPath 'date.txt'
    $writeSplat = @{
        LiteralPath = $dateFile
        Value       = $dateText
        Encoding    = 'utf8NoBOM'
        NoNewline   = $true
        WhatIf      = $false
    }
    if ($PSVersionTable.PSVersion.Major -lt 6) {
        [System.IO.File]::WriteAllText($dateFile, $dateText, [System.Text.UTF8Encoding]::new($false))
    }
    else {
        Set-Content @writeSplat
    }

    # Timing matches the intro's built-in animation: fades in at 5.8s, out with the fade to black at 8.2s
    $alpha = "'if(lt(t,5.8),0,min(1,min((t-5.8)/0.7,(9-t)/0.8)))'"
    $cleanupSplat = @{
        LiteralPath = $workDir
        Recurse     = $true
        WhatIf      = $false
        ErrorAction = 'SilentlyContinue'
    }
    $dateFilter = "drawtext=fontfile=${fontName}:textfile=date.txt:fontsize=34:fontcolor=0xBEC8DC:x=(w-text_w)/2:y=970-th/2:alpha=${alpha},"
}

Write-Verbose -Message ("Recording: {0}x{1} @ {2}fps, audio {3}Hz, {4}s" -f $rec.Width, $rec.Height, $fps, $sr, (Format-Seconds -Seconds $rec.Duration))
Write-Verbose -Message ("Intro at {0}s, recording kept {1} -> {2}s" -f (Format-Seconds -Seconds $start), $(if ($keepBefore) { '0' } else { Format-Seconds -Seconds $start }), (Format-Seconds -Seconds $end))

# --- Build the ffmpeg command ------------------------------------------------------------------
$norm = "scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=${fps},format=yuv420p"
$anorm = "aresample=${sr},aformat=sample_fmts=fltp:channel_layouts=stereo"

$ffArgs = [System.Collections.Generic.List[string]]::new()
$ffArgs.AddRange([string[]]@('-hide_banner', '-y', '-loglevel', 'error', '-stats'))

# Input 0: recording. When trimming the front we seek on input, which is much faster on long recordings.
$offset = 0
if (-not $keepBefore -and $start -gt 0) {
    $ffArgs.AddRange([string[]]@('-ss', (Format-Seconds -Seconds $start)))
    $offset = $start
}
$ffArgs.AddRange([string[]]@('-i', $RecordingPath, '-i', $IntroPath))

# Input 2: silence, only if the recording has no audio track
if (-not $rec.HasAudio) {
    $ffArgs.AddRange([string[]]@('-f', 'lavfi', '-i', "anullsrc=r=${sr}:cl=stereo"))
}
$recAudio = if ($rec.HasAudio) { '0:a' } else { '2:a' }

$filters = [System.Collections.Generic.List[string]]::new()
$segments = [System.Collections.Generic.List[string]]::new()

# Intro (normalised to match the recording)
$introAudio = if ($intro.HasAudio) { "[1:a]$anorm[ia]" } else { "anullsrc=r=${sr}:cl=stereo,atrim=0:$(Format-Seconds -Seconds $intro.Duration),$anorm[ia]" }
$filters.Add("[1:v]$dateFilter$norm[iv]")
$filters.Add($introAudio)

$afterStart = Format-Seconds -Seconds ($start - $offset)
$afterEnd = Format-Seconds -Seconds ($end - $offset)
$afterLen = Format-Seconds -Seconds ($end - $start)
$fadeOutAt = Format-Seconds -Seconds ([math]::Max(0, $start - $FadeSeconds))

if ($keepBefore) {
    $filters.Add("[0:v]$norm,split=2[v0][v1]")
    $filters.Add("[${recAudio}]$anorm,asplit=2[a0][a1]")
    $beforeV = "[v0]trim=0:$(Format-Seconds -Seconds $start),setpts=PTS-STARTPTS"
    $beforeA = "[a0]atrim=0:$(Format-Seconds -Seconds $start),asetpts=PTS-STARTPTS"
    if ($FadeSeconds -gt 0) {
        $beforeV += ",fade=t=out:st=${fadeOutAt}:d=${fade}"
        $beforeA += ",afade=t=out:st=${fadeOutAt}:d=${fade}"
    }
    $filters.Add("$beforeV[bv]")
    $filters.Add("$beforeA[ba]")
    $segments.Add('[bv][ba]')
    $srcV = '[v1]'
    $srcA = '[a1]'
}
else {
    $srcV = "[0:v]$norm,"
    $srcA = "[${recAudio}]$anorm,"
}
$afterV = "${srcV}trim=${afterStart}:${afterEnd},setpts=PTS-STARTPTS"
$afterA = "${srcA}atrim=${afterStart}:${afterEnd},asetpts=PTS-STARTPTS"
if ($FadeSeconds -gt 0) {
    $afterV += ",fade=t=in:st=0:d=${fade}"
    $afterA += ",afade=t=in:st=0:d=${fade}"
}
$filters.Add("$afterV[av]")
$filters.Add("$afterA[aa]")

$segments.Add('[iv][ia]')
$segments.Add('[av][aa]')
$filters.Add(('{0}concat=n={1}:v=1:a=1[outv][outa]' -f ($segments -join ''), $segments.Count))

$ffArgs.AddRange([string[]]@(
    '-filter_complex', ($filters -join ';')
    '-map', '[outv]', '-map', '[outa]'
    '-c:v', 'libx264', '-preset', $Preset, '-crf', "$Crf", '-pix_fmt', 'yuv420p'
    '-c:a', 'aac', '-b:a', '160k', '-ar', "$sr"
    '-movflags', '+faststart'
    $OutputPath
))

Write-Verbose -Message ("ffmpeg " + ($ffArgs -join ' '))

if (-not $PSCmdlet.ShouldProcess($OutputPath, "Insert intro dated '$dateText' at $Timestamp")) {
    if ($workDir) { Remove-Item @cleanupSplat }
}
else {
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    if ($workDir) { Push-Location -LiteralPath $workDir }
    try {
        & ffmpeg @ffArgs
        if ($LASTEXITCODE -ne 0) { throw "ffmpeg failed with exit code $LASTEXITCODE." }
    }
    finally {
        if ($workDir) {
            Pop-Location
            Remove-Item @cleanupSplat
        }
    }
    $sw.Stop()

    $final = Get-MediaInfo -Path $OutputPath
    [pscustomobject]@{
        OutputPath    = $OutputPath
        Duration      = [TimeSpan]::FromSeconds($final.Duration).ToString('hh\:mm\:ss')
        Resolution    = '{0}x{1}' -f $final.Width, $final.Height
        MeetingDate   = $dateText
        IntroStartsAt = [TimeSpan]::FromSeconds($(if ($keepBefore) { $start } else { 0 })).ToString('hh\:mm\:ss')
        Elapsed       = $sw.Elapsed.ToString('hh\:mm\:ss')
    }
}
}