Private/ConvertTo-BsnAsciicast.ps1

<#
.SYNOPSIS
    Converts captured terminal output into an asciicast v2 (.cast) file.
 
.DESCRIPTION
    Writes the asciicast v2 header plus output ('o') event lines
    (https://github.com/asciinema/asciinema/blob/develop/asciicast-v2.md).
 
    Two parameter sets control event timing:
 
    - Timing (default): util-linux script --log-timing entries, each line
      '<+delta seconds> <byte count>' paired against the --log-out stream.
      Produces timing-exact events.
    - Approximated: no timing source (BSD script on macOS); output is split
      into line-based chunks whose timestamps are spread evenly across the
      measured session duration.
 
    The file is written as UTF-8 without BOM with LF line endings, matching
    the asciicast specification. All number formatting is culture-invariant.
#>

function ConvertTo-BsnAsciicast {
    [CmdletBinding(DefaultParameterSetName = 'Timing')]
    param(
        [Parameter(Mandatory = $true)]
        [string]$OutputPath,

        [Parameter(ParameterSetName = 'Timing')]
        [string]$TimingPath,

        [Parameter(ParameterSetName = 'Approximated')]
        [double]$DurationSec = 0,

        [Parameter(Mandatory = $true)]
        [string]$CastPath,

        [int]$Width = 80,

        [int]$Height = 24,

        [string]$ShellName = 'shell',

    [ValidateSet(2, 3)]
    [int]$CastVersion = 2,

    [int]$ExitCode = 0
    )

    $invariantCulture = [System.Globalization.CultureInfo]::InvariantCulture
    $outputBytes = [System.IO.File]::ReadAllBytes($OutputPath)

    # Build the event plan: @{ Time; Offset; Count } triplets.
    $events = [System.Collections.Generic.List[object]]::new()

    if ($PSCmdlet.ParameterSetName -eq 'Timing') {
        $elapsed = 0.0
        $offset = 0
        foreach ($timingLine in [System.IO.File]::ReadAllLines($TimingPath)) {
            if ($timingLine -match '^\+?(\d+\.?\d*)\s+(\d+)\s*$') {
                $elapsed += [double]::Parse($Matches[1], $invariantCulture)
                $count = [int]$Matches[2]
                if (($count -gt 0) -and ($offset -lt $outputBytes.Length)) {
                    if (($offset + $count) -gt $outputBytes.Length) {
                        $count = $outputBytes.Length - $offset
                    }
                    $events.Add([pscustomobject]@{ Time = $elapsed; Offset = $offset; Count = $count })
                    $offset += $count
                }
            }
        }
        if ($offset -lt $outputBytes.Length) {
            $events.Add([pscustomobject]@{ Time = $elapsed; Offset = $offset; Count = $outputBytes.Length - $offset })
        }
    }
    else {
        # Approximated: one event per line, spread evenly across the duration.
        $lineBreaks = [System.Collections.Generic.List[int]]::new()
        for ($index = 0; $index -lt $outputBytes.Length; $index++) {
            if ($outputBytes[$index] -eq 10) {
                $lineBreaks.Add($index)
            }
        }

        $chunkCount = $lineBreaks.Count
        if (($lineBreaks.Count -eq 0) -or ($lineBreaks[$lineBreaks.Count - 1] -ne ($outputBytes.Length - 1))) {
            $chunkCount++
        }
        $interval = if ($chunkCount -gt 1) { ($DurationSec / ($chunkCount - 1)) } else { 0 }

        $eventTime = 0.0
        $offset = 0
        foreach ($breakIndex in $lineBreaks) {
            $events.Add([pscustomobject]@{ Time = $eventTime; Offset = $offset; Count = ($breakIndex - $offset + 1) })
            $offset = $breakIndex + 1
            $eventTime += $interval
        }
        if ($offset -lt $outputBytes.Length) {
            $events.Add([pscustomobject]@{ Time = $eventTime; Offset = $offset; Count = $outputBytes.Length - $offset })
        }
    }

    $timestamp = [long]([System.DateTime]::UtcNow - [System.DateTime]::new(1970, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc)).TotalSeconds
    $decoder = [System.Text.Encoding]::UTF8.GetDecoder()

    $writer = [System.IO.StreamWriter]::new($CastPath, $false, [System.Text.UTF8Encoding]::new($false))
    $writer.NewLine = "`n"
    try {
        if ($CastVersion -eq 3) {
            $writer.WriteLine(('{{"version": 3, "term": {{"cols": {0}, "rows": {1}, "type": "xterm-256color"}}, "timestamp": {2}, "command": "{3}", "title": "Bsn.Host interactive transcript", "env": {{"SHELL": "{3}", "TERM": "xterm-256color"}}}}' -f `
                    $Width, $Height, $timestamp, $ShellName))
        }
        else {
            $writer.WriteLine(('{{"version": 2, "width": {0}, "height": {1}, "timestamp": {2}, "title": "Bsn.Host interactive transcript", "env": {{"SHELL": "{3}", "TERM": "xterm-256color"}}}}' -f `
                    $Width, $Height, $timestamp, $ShellName))
        }

        $previousTime = 0.0
        foreach ($castEvent in $events) {
            $charBuffer = [char[]]::new($castEvent.Count)
            $charCount = $decoder.GetChars($outputBytes, $castEvent.Offset, $castEvent.Count, $charBuffer, 0)
            $eventText = [string]::new($charBuffer, 0, $charCount)
            $jsonText = $eventText | ConvertTo-Json -Compress

            $formattedTime = if ($CastVersion -eq 3) {
                # v3: interval since the previous event, millisecond precision (spec recommendation).
                $interval = [math]::Round($castEvent.Time - $previousTime, 3)
                if ($interval -lt 0) { $interval = 0 }
                $previousTime = $castEvent.Time
                $interval.ToString('0.###', $invariantCulture)
            }
            else {
                $castEvent.Time.ToString('0.######', $invariantCulture)
            }

            $writer.WriteLine(('[{0}, "o", {1}]' -f $formattedTime, $jsonText))
        }

        if ($CastVersion -eq 3) {
            # v3 recordings are finalized with an 'x' exit-status event.
            $writer.WriteLine(('[0, "x", "{0}"]' -f $ExitCode))
        }
    }
    finally {
        $writer.Dispose()
    }
}