Created
June 29, 2026 14:42
-
-
Save h8rt3rmin8r/a05e8941432303ae6e8319208f49dae6 to your computer and use it in GitHub Desktop.
Convert a WebVTT (.vtt) subtitle/caption file into a single mapped JSON document via Powershell
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Convert a WebVTT (.vtt) subtitle/caption file into a single mapped JSON | |
| document with a fixed, fully-documented schema. | |
| .DESCRIPTION | |
| Reads a WebVTT file, parses every structural element the format can carry | |
| (file header and metadata, STYLE / REGION / NOTE blocks, and cue blocks with | |
| their identifiers, timings, positioning settings, inline markup and | |
| word-level timestamp tags), and emits a JSON file beside the input. The | |
| output file name is identical to the input but with a .json extension; the | |
| output directory is the same as the input. | |
| The conversion is faithful and 1:1. Every cue in the source produces exactly | |
| one cue object in the output, in source order. Nothing is de-duplicated, | |
| re-timed, merged, or dropped. This matters for YouTube auto-caption files, | |
| which intentionally emit overlapping "rolling" cues (each cue repeats the | |
| previous visible line and appends the next line with per-word timing). Those | |
| duplicates are preserved as-is; the per-word timing is surfaced in each | |
| cue's "tokens" array. | |
| FIXED OUTPUT SCHEMA (every key is always present; absent data is rendered as | |
| an empty array, an empty object, or null, so downstream consumers can rely on | |
| a stable shape): | |
| schemaVersion : string - schema contract version emitted by this script. | |
| source : object - provenance of the conversion: | |
| fileName : string - input file name. | |
| filePath : string - absolute input path. | |
| byteOrderMark : bool - true if the input began with a UTF-8 BOM. | |
| lineEnding : string - LF | CRLF | CR | MIXED | NONE (as detected | |
| in the input). | |
| convertedAtUtc : string - ISO-8601 UTC timestamp of the conversion. | |
| converter : string - name of this script. | |
| header : object - the WebVTT file header: | |
| magic : string - always "WEBVTT". | |
| description : string - free text after "WEBVTT" on line 1, or null. | |
| metadata : object - parsed "Key: Value" / "Key=Value" header lines | |
| (for example Kind and Language), key->value. | |
| metadataRaw : array - the raw header metadata lines, verbatim. | |
| regions : array - REGION definition blocks, each: | |
| id : string - region id setting, or null. | |
| settings : object - all region settings, name->value. | |
| raw : string - the region block body, verbatim. | |
| afterCue : int - index of the last cue seen before this block | |
| (-1 if it preceded all cues). | |
| styles : array - STYLE blocks, each: | |
| css : string - the CSS payload of the block, verbatim. | |
| afterCue : int - positional context (see regions.afterCue). | |
| notes : array - standalone NOTE comment blocks, each: | |
| text : string - the note body (the word NOTE stripped). | |
| afterCue : int - positional context (see regions.afterCue). | |
| cues : array - the cue blocks in source order, each: | |
| index : int - 0-based ordinal among cues. | |
| identifier : string - the cue identifier line, or null. | |
| start : string - normalized start time "HH:MM:SS.mmm". | |
| end : string - normalized end time "HH:MM:SS.mmm". | |
| startSeconds : number - start time in seconds. | |
| endSeconds : number - end time in seconds. | |
| durationSeconds : number - endSeconds minus startSeconds. | |
| settings : object - parsed cue settings (align, position, line, | |
| size, vertical, region), name->value. | |
| settingsRaw : string - the raw settings string, or null. | |
| text : string - plain text: all markup tags and inline | |
| timestamp tags removed, HTML entities | |
| decoded, source lines joined with "\n". | |
| lines : array - the plain-text payload split per source | |
| line. | |
| rawPayload : string - the payload exactly as in the file (markup | |
| and inline timestamps intact), lines joined | |
| with "\n". | |
| voices : array - distinct <v> speaker names in the cue. | |
| tokens : array - word/phrase-level timing derived from inline | |
| <timestamp> tags, each { time, timeSeconds, | |
| text }. Empty when the cue carries no inline | |
| timing. | |
| stats : object - summary counters: | |
| cueCount : int - number of cues. | |
| regionCount : int - number of REGION blocks. | |
| styleCount : int - number of STYLE blocks. | |
| noteCount : int - number of standalone NOTE blocks. | |
| hasWordLevelTiming: bool - true if any cue produced tokens. | |
| mediaSpanSeconds : number - max(endSeconds) minus min(startSeconds), | |
| or 0 when there are no cues. | |
| The authoritative, published JSON Schema for this output (matching the | |
| emitted schemaVersion) is the formal contract for the shape above: | |
| https://schemas.shruggie.tech/data/webvtt-json.schema.json | |
| Side effects: writes one .json file to the input's directory. Refuses to | |
| overwrite an existing output unless -Force is supplied. Reads and writes the | |
| local filesystem only; performs no network access. Output is UTF-8 with no | |
| BOM and LF line endings. | |
| Requires PowerShell 7+ for deterministic ConvertTo-Json behavior (stable | |
| key ordering and correct empty/single-element array rendering). The script | |
| asserts the version and exits 2 if it is too low. | |
| Exit codes: 0 success (including a -WhatIf preview); 1 the output already | |
| exists and -Force was not supplied; 2 an environment precondition failed | |
| (input missing, unreadable, or not a WebVTT file). | |
| .PARAMETER Path | |
| Path to the input .vtt file. Handled literally, so names containing wildcard | |
| metacharacters or awkward whitespace are read verbatim. The output path is | |
| derived from this by replacing the extension with .json. | |
| Alias: p | |
| .PARAMETER Force | |
| Overwrite the derived .json output file if it already exists. Without this | |
| switch, an existing output causes the script to stop with exit code 1 and | |
| leave the existing file untouched. | |
| Alias: f | |
| .PARAMETER Quiet | |
| Suppress informational chatter (Info, Success, Debug). Warnings and errors | |
| still emit. | |
| Alias: q | |
| .PARAMETER Silent | |
| Suppress all log output including warnings. Genuine errors still reach the | |
| error stream. | |
| .PARAMETER Help | |
| Print this help text to the terminal. | |
| Alias: h | |
| .EXAMPLE | |
| .\ConvertFrom-WebVtt.ps1 -Path .\captions.vtt | |
| Converts captions.vtt to captions.json in the same directory. Fails if | |
| captions.json already exists. | |
| .EXAMPLE | |
| .\ConvertFrom-WebVtt.ps1 -Path .\captions.vtt -Force | |
| Converts and overwrites captions.json if it is already present. | |
| .EXAMPLE | |
| .\ConvertFrom-WebVtt.ps1 -Path '.\Who Controls [the] Internet.vtt' -Force -Quiet | |
| Converts a file whose name contains spaces and wildcard metacharacters, | |
| overwriting the output, with informational logging suppressed. | |
| .EXAMPLE | |
| .\ConvertFrom-WebVtt.ps1 -Path .\captions.vtt -WhatIf | |
| Parses the input and reports what would be written without creating the | |
| output file. | |
| .NOTES | |
| Schema version 1.0. The schema is a superset designed to round-trip the | |
| structural information of any conformant WebVTT file, not only the YouTube | |
| auto-caption shape: it accommodates an optional BOM and header description, | |
| arbitrary header metadata, STYLE / REGION / NOTE blocks, cue identifiers, | |
| hours-optional timestamps (HH:MM:SS.mmm or MM:SS.mmm), cue positioning | |
| settings, inline markup (<b> <i> <u> <c> <v> <lang> <ruby> and friends), | |
| HTML entities, and chapter or metadata payloads. Shapes that a given file | |
| does not use are represented as empty collections or null, never omitted. | |
| The output validates against the published schema linked below, whose | |
| schemaVersion matches the schemaVersion this script emits. | |
| .LINK | |
| https://schemas.shruggie.tech/data/webvtt-json.schema.json | |
| #> | |
| [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='Medium',DefaultParameterSetName='Default')] | |
| Param( | |
| [Parameter(Mandatory=$true,ParameterSetName='Default',Position=0)] | |
| [Alias("p")] | |
| [string]$Path, | |
| [Parameter(Mandatory=$false,ParameterSetName='Default')] | |
| [Alias("f")] | |
| [Switch]$Force, | |
| [Parameter(Mandatory=$false,ParameterSetName='Default')] | |
| [Alias("q")] | |
| [Switch]$Quiet, | |
| [Parameter(Mandatory=$false,ParameterSetName='Default')] | |
| [Switch]$Silent, | |
| [Parameter(Mandatory=$true,ParameterSetName='HelpText')] | |
| [Alias("h")] | |
| [Switch]$Help | |
| ) | |
| #_______________________________________________________________________________ | |
| ## Declare Functions | |
| function Assert-PSVersion { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$false)] | |
| [version]$Minimum = '7.0' | |
| ) | |
| $current = $PSVersionTable.PSVersion | |
| if ($current -lt $Minimum) { | |
| Write-Host ("ALERT: PowerShell {0}+ required; running {1}. Relaunch with 'pwsh'." -f $Minimum, $current) -ForegroundColor Red | |
| exit 2 | |
| } | |
| } | |
| function Write-Log { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$true,Position=0)] | |
| [string]$Message, | |
| [Parameter(Mandatory=$false)] | |
| [ValidateSet('Info','Success','Warn','Error','Debug')] | |
| [string]$Level = 'Info', | |
| [Parameter(Mandatory=$false)] | |
| [string]$Source = $null | |
| ) | |
| if ($script:LogSilent -and $Level -ne 'Error') { return } | |
| if ($script:LogQuiet -and (@('Info','Success','Debug') -contains $Level)) { return } | |
| $stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff') | |
| $tag = if ($Source) { "[$Source] " } else { '' } | |
| $label = $Level.ToUpper().PadRight(7) | |
| $color = switch ($Level) { | |
| 'Info' { 'Gray' } | |
| 'Success' { 'Green' } | |
| 'Warn' { 'Yellow' } | |
| 'Error' { 'Red' } | |
| 'Debug' { 'DarkGray' } | |
| } | |
| Write-Host ("{0} {1}{2} {3}" -f $stamp, $tag, $label, $Message) -ForegroundColor $color | |
| } | |
| # Convert a WebVTT timestamp (HH:MM:SS.mmm or MM:SS.mmm) to total seconds. | |
| function ConvertFrom-VttTimestamp { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$true)] | |
| [string]$Timestamp | |
| ) | |
| $parts = $Timestamp.Split(':') | |
| if ($parts.Count -eq 3) { | |
| $h = [double]$parts[0] | |
| $m = [double]$parts[1] | |
| $s = [double]$parts[2] | |
| } else { | |
| $h = 0.0 | |
| $m = [double]$parts[0] | |
| $s = [double]$parts[1] | |
| } | |
| return ($h * 3600.0) + ($m * 60.0) + $s | |
| } | |
| # Render total seconds back to a normalized HH:MM:SS.mmm timestamp. | |
| function ConvertTo-VttTimestamp { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$true)] | |
| [double]$Seconds | |
| ) | |
| $total = [int][math]::Floor($Seconds) | |
| $ms = [int][math]::Round(($Seconds - $total) * 1000.0) | |
| if ($ms -ge 1000) { $total += 1; $ms -= 1000 } | |
| $h = [int][math]::Floor($total / 3600) | |
| $m = [int][math]::Floor(($total % 3600) / 60) | |
| $s = [int]($total % 60) | |
| return ('{0:00}:{1:00}:{2:00}.{3:000}' -f $h, $m, $s, $ms) | |
| } | |
| # Strip every WebVTT/HTML tag and inline timestamp tag, then decode entities. | |
| function Remove-VttMarkup { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$false)] | |
| [AllowEmptyString()] | |
| [string]$Text = '' | |
| ) | |
| $stripped = [regex]::Replace($Text, '<[^>]*>', '') | |
| return [System.Net.WebUtility]::HtmlDecode($stripped) | |
| } | |
| # Extract distinct <v ...> speaker names from a raw cue payload. | |
| function Get-VttVoice { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$false)] | |
| [AllowEmptyString()] | |
| [string]$Text = '' | |
| ) | |
| $names = [System.Collections.Generic.List[string]]::new() | |
| foreach ($match in [regex]::Matches($Text, '<v([^>]*)>')) { | |
| $inner = $match.Groups[1].Value.Trim() | |
| # Drop leading class tokens (".loud") to leave the speaker label. | |
| $name = ($inner -split '\s+' | Where-Object { $_ -and ($_[0] -ne '.') }) -join ' ' | |
| $name = $name.Trim() | |
| if ($name -and -not $names.Contains($name)) { $names.Add($name) } | |
| } | |
| return ,$names.ToArray() | |
| } | |
| # Build word/phrase-level tokens from inline <timestamp> tags in a payload. | |
| # Each text segment is governed by the timestamp that precedes it; the first | |
| # segment (before any inline timestamp) inherits the cue start time. | |
| function Get-VttToken { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$true)] | |
| [AllowEmptyString()] | |
| [string]$RawPayload, | |
| [Parameter(Mandatory=$true)] | |
| [double]$CueStartSeconds | |
| ) | |
| $tokens = [System.Collections.Generic.List[object]]::new() | |
| $tsPattern = '<((?:\d{2,}:)?\d{2}:\d{2}\.\d{3})>' | |
| $tsMatches = [regex]::Matches($RawPayload, $tsPattern) | |
| if ($tsMatches.Count -eq 0) { | |
| return ,$tokens.ToArray() | |
| } | |
| $segmentStart = 0 | |
| $governingSeconds = $CueStartSeconds | |
| foreach ($match in $tsMatches) { | |
| $segment = $RawPayload.Substring($segmentStart, $match.Index - $segmentStart) | |
| $text = ((Remove-VttMarkup -Text $segment) -replace '\s+', ' ').Trim() | |
| if ($text.Length -gt 0) { | |
| $tokens.Add([pscustomobject][ordered]@{ | |
| time = ConvertTo-VttTimestamp -Seconds $governingSeconds | |
| timeSeconds = [math]::Round($governingSeconds, 3) | |
| text = $text | |
| }) | |
| } | |
| $governingSeconds = ConvertFrom-VttTimestamp -Timestamp $match.Groups[1].Value | |
| $segmentStart = $match.Index + $match.Length | |
| } | |
| # Trailing segment after the last timestamp. | |
| $segment = $RawPayload.Substring($segmentStart) | |
| $text = ((Remove-VttMarkup -Text $segment) -replace '\s+', ' ').Trim() | |
| if ($text.Length -gt 0) { | |
| $tokens.Add([pscustomobject][ordered]@{ | |
| time = ConvertTo-VttTimestamp -Seconds $governingSeconds | |
| timeSeconds = [math]::Round($governingSeconds, 3) | |
| text = $text | |
| }) | |
| } | |
| return ,$tokens.ToArray() | |
| } | |
| # Parse a whitespace-separated "name:value" settings string into an ordered map. | |
| function ConvertFrom-VttSettings { | |
| [CmdletBinding()] | |
| Param( | |
| [Parameter(Mandatory=$false)] | |
| [AllowEmptyString()] | |
| [string]$Text = '' | |
| ) | |
| $map = [ordered]@{} | |
| foreach ($item in ($Text -split '\s+')) { | |
| if (-not $item) { continue } | |
| $colon = $item.IndexOf(':') | |
| if ($colon -gt 0) { | |
| $map[$item.Substring(0, $colon)] = $item.Substring($colon + 1) | |
| } else { | |
| $map[$item] = $true | |
| } | |
| } | |
| return $map | |
| } | |
| #_______________________________________________________________________________ | |
| ## Declare Variables and Arrays | |
| $script:LogQuiet = $false | |
| $script:LogSilent = $false | |
| $ThisScriptPath = $MyInvocation.MyCommand.Path | |
| $SchemaVersion = '1.0' | |
| # A WebVTT timestamp: optional hours, then mm:ss.mmm. | |
| $TimestampCore = '(?:\d{2,}:)?\d{2}:\d{2}\.\d{3}' | |
| #_______________________________________________________________________________ | |
| ## Execute Operations | |
| # Catch help text requests | |
| if (($Help) -or ($PSCmdlet.ParameterSetName -eq 'HelpText')) { | |
| Get-Help $ThisScriptPath -Detailed | |
| exit 0 | |
| } | |
| # Wire suppression flags to the logging state | |
| if ($Quiet) { $script:LogQuiet = $true } | |
| if ($Silent) { $script:LogSilent = $true } | |
| # Environment precondition: require PowerShell 7+ | |
| Assert-PSVersion -Minimum '7.0' | |
| # Environment precondition: the input file must exist and be a file | |
| if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { | |
| Write-Host ("FAIL: input file not found: {0}" -f $Path) -ForegroundColor Red | |
| exit 2 | |
| } | |
| $inputItem = Get-Item -LiteralPath $Path | |
| $inputFull = $inputItem.FullName | |
| $outputFull = [System.IO.Path]::ChangeExtension($inputFull, '.json') | |
| # Guard: refuse to clobber an existing output unless -Force was supplied | |
| if ((Test-Path -LiteralPath $outputFull) -and (-not $Force)) { | |
| Write-Host ("FAIL: output already exists (use -Force to overwrite): {0}" -f $outputFull) -ForegroundColor Red | |
| exit 1 | |
| } | |
| Write-Log ("Reading {0}" -f $inputFull) -Level Info -Source 'Read' | |
| try { | |
| $bytes = [System.IO.File]::ReadAllBytes($inputFull) | |
| } catch { | |
| Write-Host ("FAIL: could not read input: {0}" -f $_.Exception.Message) -ForegroundColor Red | |
| exit 2 | |
| } | |
| # Detect and strip a leading UTF-8 BOM (EF BB BF) from the raw bytes, since | |
| # the higher-level text readers strip it silently and hide its presence. | |
| $hadBom = ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) | |
| $startIndex = if ($hadBom) { 3 } else { 0 } | |
| $rawText = [System.Text.Encoding]::UTF8.GetString($bytes, $startIndex, $bytes.Length - $startIndex) | |
| # Detect the source line ending before normalizing to LF | |
| $hasCrlf = $rawText.Contains("`r`n") | |
| $bareText = $rawText -replace "`r`n", "`n" | |
| $hasLoneCr = $bareText.Contains("`r") | |
| $hasLf = $bareText.Contains("`n") | |
| $lineEnding = | |
| if ($hasCrlf -and -not $hasLoneCr) { 'CRLF' } | |
| elseif ($hasCrlf -and $hasLoneCr) { 'MIXED' } | |
| elseif ($hasLoneCr -and -not $hasLf) { 'CR' } | |
| elseif ($hasLf) { 'LF' } | |
| else { 'NONE' } | |
| # Normalize all line endings to LF for parsing | |
| $normalized = $bareText -replace "`r", "`n" | |
| # Environment precondition: must be a WebVTT file | |
| if ($normalized -notmatch '^WEBVTT(\s|$)') { | |
| Write-Host "FAIL: input does not start with the WEBVTT signature; not a WebVTT file." -ForegroundColor Red | |
| exit 2 | |
| } | |
| Write-Log "Parsing WebVTT structure." -Level Info -Source 'Parse' | |
| # Split the body into blocks on empty lines (two or more consecutive | |
| # newlines). Per the WebVTT spec only a genuinely empty line terminates a | |
| # block; a whitespace-only line is payload content, which matters for | |
| # YouTube auto-captions whose cues carry space-only lines. | |
| $blocks = [regex]::Split($normalized, "\n{2,}") | |
| # Output accumulators | |
| $regions = [System.Collections.Generic.List[object]]::new() | |
| $styles = [System.Collections.Generic.List[object]]::new() | |
| $notes = [System.Collections.Generic.List[object]]::new() | |
| $cues = [System.Collections.Generic.List[object]]::new() | |
| $headerDescription = $null | |
| $headerMetadata = [ordered]@{} | |
| $headerMetadataRaw = [System.Collections.Generic.List[string]]::new() | |
| $cueIndex = 0 | |
| $anyTokens = $false | |
| $blockNumber = 0 | |
| foreach ($block in $blocks) { | |
| $body = $block.Trim("`n") | |
| if ($body.Trim().Length -eq 0) { continue } | |
| $blockNumber++ | |
| $lines = $body -split "\n" | |
| $firstLine = $lines[0] | |
| # The very first non-empty block is the file header | |
| if ($blockNumber -eq 1 -and $firstLine -match '^WEBVTT') { | |
| if ($firstLine -match '^WEBVTT[ \t]+(.*)$') { | |
| $headerDescription = $Matches[1].Trim() | |
| } | |
| foreach ($metaLine in ($lines | Select-Object -Skip 1)) { | |
| $trimmed = $metaLine.Trim() | |
| if ($trimmed.Length -eq 0) { continue } | |
| $headerMetadataRaw.Add($metaLine) | |
| if ($trimmed -match '^([^:=]+)[:=](.*)$') { | |
| $headerMetadata[$Matches[1].Trim()] = $Matches[2].Trim() | |
| } | |
| } | |
| continue | |
| } | |
| # NOTE comment block | |
| if ($firstLine -match '^NOTE(\s|$)') { | |
| $noteText = $body -replace '^NOTE[ \t]*', '' | |
| $notes.Add([pscustomobject][ordered]@{ | |
| text = $noteText.Trim() | |
| afterCue = $cueIndex - 1 | |
| }) | |
| continue | |
| } | |
| # STYLE block | |
| if ($firstLine -match '^STYLE(\s|$)') { | |
| $css = (($lines | Select-Object -Skip 1) -join "`n").Trim() | |
| $styles.Add([pscustomobject][ordered]@{ | |
| css = $css | |
| afterCue = $cueIndex - 1 | |
| }) | |
| continue | |
| } | |
| # REGION block | |
| if ($firstLine -match '^REGION(\s|$)') { | |
| $regionBody = (($lines | Select-Object -Skip 1) -join ' ').Trim() | |
| if ($regionBody.Length -eq 0 -and $firstLine -match '^REGION[ \t]+(.*)$') { | |
| $regionBody = $Matches[1].Trim() | |
| } | |
| $regionSettings = ConvertFrom-VttSettings -Text $regionBody | |
| $regionId = if ($regionSettings.Contains('id')) { $regionSettings['id'] } else { $null } | |
| $regions.Add([pscustomobject][ordered]@{ | |
| id = $regionId | |
| settings = $regionSettings | |
| raw = (($lines | Select-Object -Skip 1) -join "`n").Trim() | |
| afterCue = $cueIndex - 1 | |
| }) | |
| continue | |
| } | |
| # Locate the timing line within the block (cue blocks only) | |
| $timingLineIndex = -1 | |
| for ($i = 0; $i -lt $lines.Count; $i++) { | |
| if ($lines[$i] -match ('^\s*(' + $TimestampCore + ')\s*-->\s*(' + $TimestampCore + ')\s*(.*)$')) { | |
| $timingLineIndex = $i | |
| break | |
| } | |
| } | |
| # No timing line and not a known block type: skip defensively | |
| if ($timingLineIndex -lt 0) { | |
| Write-Log ("Skipping unrecognized block: {0}" -f ($firstLine.Substring(0, [math]::Min(40, $firstLine.Length)))) -Level Warn -Source 'Parse' | |
| continue | |
| } | |
| # Parse the timing line | |
| $null = $lines[$timingLineIndex] -match ('^\s*(' + $TimestampCore + ')\s*-->\s*(' + $TimestampCore + ')\s*(.*)$') | |
| $startRaw = $Matches[1] | |
| $endRaw = $Matches[2] | |
| $settingsRaw = $Matches[3].Trim() | |
| # The line(s) above the timing line, if any, form the identifier | |
| $identifier = $null | |
| if ($timingLineIndex -gt 0) { | |
| $identifier = (($lines[0..($timingLineIndex - 1)]) -join "`n").Trim() | |
| if ($identifier.Length -eq 0) { $identifier = $null } | |
| } | |
| # The line(s) below the timing line form the payload | |
| $payloadLines = @() | |
| if ($timingLineIndex -lt ($lines.Count - 1)) { | |
| $payloadLines = $lines[($timingLineIndex + 1)..($lines.Count - 1)] | |
| } | |
| $rawPayload = ($payloadLines -join "`n") | |
| $startSeconds = ConvertFrom-VttTimestamp -Timestamp $startRaw | |
| $endSeconds = ConvertFrom-VttTimestamp -Timestamp $endRaw | |
| $plainLines = @($payloadLines | ForEach-Object { (Remove-VttMarkup -Text $_) }) | |
| $plainText = ($plainLines -join "`n") | |
| $voices = Get-VttVoice -Text $rawPayload | |
| $tokens = Get-VttToken -RawPayload $rawPayload -CueStartSeconds $startSeconds | |
| if ($tokens.Count -gt 0) { $anyTokens = $true } | |
| $settingsMap = if ($settingsRaw.Length -gt 0) { ConvertFrom-VttSettings -Text $settingsRaw } else { [ordered]@{} } | |
| $cues.Add([pscustomobject][ordered]@{ | |
| index = $cueIndex | |
| identifier = $identifier | |
| start = ConvertTo-VttTimestamp -Seconds $startSeconds | |
| end = ConvertTo-VttTimestamp -Seconds $endSeconds | |
| startSeconds = [math]::Round($startSeconds, 3) | |
| endSeconds = [math]::Round($endSeconds, 3) | |
| durationSeconds = [math]::Round($endSeconds - $startSeconds, 3) | |
| settings = $settingsMap | |
| settingsRaw = if ($settingsRaw.Length -gt 0) { $settingsRaw } else { $null } | |
| text = $plainText | |
| lines = @($plainLines) | |
| rawPayload = $rawPayload | |
| voices = @($voices) | |
| tokens = @($tokens) | |
| }) | |
| $cueIndex++ | |
| } | |
| # Summary statistics | |
| $mediaSpan = 0.0 | |
| if ($cues.Count -gt 0) { | |
| $minStart = ($cues | Measure-Object -Property startSeconds -Minimum).Minimum | |
| $maxEnd = ($cues | Measure-Object -Property endSeconds -Maximum).Maximum | |
| $mediaSpan = [math]::Round($maxEnd - $minStart, 3) | |
| } | |
| # Assemble the fixed-schema document | |
| $document = [pscustomobject][ordered]@{ | |
| schemaVersion = $SchemaVersion | |
| source = [pscustomobject][ordered]@{ | |
| fileName = $inputItem.Name | |
| filePath = $inputFull | |
| byteOrderMark = $hadBom | |
| lineEnding = $lineEnding | |
| convertedAtUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') | |
| converter = 'ConvertFrom-WebVtt.ps1' | |
| } | |
| header = [pscustomobject][ordered]@{ | |
| magic = 'WEBVTT' | |
| description = $headerDescription | |
| metadata = $headerMetadata | |
| metadataRaw = @($headerMetadataRaw.ToArray()) | |
| } | |
| regions = @($regions.ToArray()) | |
| styles = @($styles.ToArray()) | |
| notes = @($notes.ToArray()) | |
| cues = @($cues.ToArray()) | |
| stats = [pscustomobject][ordered]@{ | |
| cueCount = $cues.Count | |
| regionCount = $regions.Count | |
| styleCount = $styles.Count | |
| noteCount = $notes.Count | |
| hasWordLevelTiming = $anyTokens | |
| mediaSpanSeconds = $mediaSpan | |
| } | |
| } | |
| Write-Log ("Parsed {0} cue(s), {1} region(s), {2} style(s), {3} note(s)." -f $cues.Count, $regions.Count, $styles.Count, $notes.Count) -Level Info -Source 'Parse' | |
| $json = $document | ConvertTo-Json -Depth 12 | |
| # Normalize to LF endings for the on-disk file | |
| $json = $json -replace "`r`n", "`n" | |
| # Gate the only state-changing action behind ShouldProcess | |
| if ($PSCmdlet.ShouldProcess($outputFull, 'Write JSON output')) { | |
| try { | |
| $utf8NoBom = New-Object System.Text.UTF8Encoding($false) | |
| [System.IO.File]::WriteAllText($outputFull, $json, $utf8NoBom) | |
| } catch { | |
| Write-Host ("FAIL: could not write output: {0}" -f $_.Exception.Message) -ForegroundColor Red | |
| exit 1 | |
| } | |
| Write-Log ("Wrote {0}" -f $outputFull) -Level Success -Source 'Write' | |
| } | |
| exit 0 | |
| #_______________________________________________________________________________ | |
| ## End of script |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment