Skip to content

Instantly share code, notes, and snippets.

@zett42
Last active March 10, 2023 11:59
Show Gist options
  • Save zett42/c7bbc4e272e0ee8ae02f23198b4ce095 to your computer and use it in GitHub Desktop.
Save zett42/c7bbc4e272e0ee8ae02f23198b4ce095 to your computer and use it in GitHub Desktop.
Add chapters to a video file
<#
.SYNOPSIS
Sets chapters to a video file using a timestamp file.
.DESCRIPTION
This function sets chapters to a video file using a timestamp file. The output file will have the same format and codec as the input file,
but with the chapters metadata added.
.PARAMETER Path
The path of the input video file.
.PARAMETER Destination
The path of the output video file. If not specified, the output file will be created in the same directory as the input file,
with a filename in the format "<input filename> (with chapters).<input extension>".
.PARAMETER TimestampPath
The path of the timestamp file. If not specified, the function will look for a file with the same name as the input file but with a .txt extension.
The timestamp file contains the start time and title of each chapter, separated by a space or a tab.
Example:
00:00:00 My first chapter
00:01:23 Another chapter
00:05:42 Yet another chapter
.PARAMETER HideProgress
Hide progress bar, which is shown by default.
.EXAMPLE
Set-ChaptersToVideo -Path "C:\Videos\example.mp4"
This command sets chapters to the video file "C:\Videos\example.mp4" using a timestamp file located at "C:\Videos\example.txt".
The output file will be created in the same directory as the input file, with a filename in the format "example (with chapters).mp4".
.EXAMPLE
Set-ChaptersToVideo -Path "C:\Videos\example.mp4" -Destination "C:\Videos\example_with_chapters.mp4" -TimestampPath "C:\Videos\example_chapters.txt"
This command sets chapters to the video file "C:\Videos\example.mp4" using a timestamp file located at "C:\Videos\example_chapters.txt".
The output file will be created at "C:\Videos\example_with_chapters.mp4".
.EXAMPLE
Set-ChaptersToVideo -Path "C:\Videos\example.mp4" -HideProgress
This command sets chapters to the video file "C:\Videos\example.mp4" using a timestamp file located at "C:\Videos\example.txt".
The progress messages will be hidden.
.NOTES
This function requires FFmpeg (https://ffmpeg.org/) to be installed and added to the PATH environment variable.
The function supports common parameters, including the following et al.:
-ErrorAction Specify the error handling mode. Pass 'Stop' to throw a script-terminating error in case of any error.
-Verbose Output detailed messages about what the script is doing.
-Debug Show additional debug information, like ffmpeg commands.
-WhatIf Show what the function would do, without taking any actions.
.LINK
https://gist.github.com/zett42/c7bbc4e272e0ee8ae02f23198b4ce095
#>
#requires -Version 7
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string] $Path,
[string] $Destination,
[string] $TimestampPath,
[switch] $HideProgress
)
Function Main {
if( $HideProgress ) { $ProgressPreference = 'SilentlyContinue' }
if( -not (Get-Command ffmpeg -EA Ignore) -or -not (Get-Command ffprobe -EA Ignore) ) {
throw [System.Management.Automation.ErrorRecord]::new(
'The "ffmpeg" and/or "ffprobe" command could not be found. Make sure FFmpeg (https://ffmpeg.org) is installed and its installation directory added to the PATH environment variable.',
'FFmpegNotFound',
[Management.Automation.ErrorCategory]::ObjectNotFound, $null
)
}
# Convert PSPath to filesystem path (to support paths that are located on a PowerShell drive)
$Path = Convert-Path -LiteralPath $Path
# Get basic info from input file
$vidInfo = ffprobe -v error -select_streams v -of json -show_entries format=duration -show_entries stream=r_frame_rate -sexagesimal $Path | ConvertFrom-Json
$totalDuration = [timespan] $vidInfo.format.duration
$frameRateNumerator, $frameRateDenominator = [int[]] ($vidInfo.streams.r_frame_rate -split '/')
$totalFrameCount = [int] ($totalDuration.TotalSeconds * $frameRateNumerator / $frameRateDenominator)
Write-Verbose "Total input duration: $totalDuration"
Write-Verbose "Input framerate: $frameRateNumerator/$frameRateDenominator"
Write-Verbose "Total frame count: $totalFrameCount"
if( -not $TimestampPath ) {
$TimestampPath = [IO.Path]::ChangeExtension( $Path, '.txt' )
}
# Parse the timestamp file
$tracks = Get-Content $TimestampPath | ConvertFrom-TimestampData
# As timestamp file contains only start times, we don't know duration of last chapter.
# Calculate it from total duration of input file.
if( $tracks.Count -gt 0 ) {
$tracks[ -1 ].Duration = $totalDuration - $tracks[ -1 ].StartTime
Write-Verbose ("Chapters to add:`n" + ($tracks | Format-Table | Out-String))
}
if( -not $Destination ) {
# Remove extension. NullString is required because PS passes $null as an empty string!
$Destination = [IO.Path]::ChangeExtension( $Path, [NullString]::Value ) + ' (with chapters)' + ([IO.FileInfo] $Path).Extension
}
if( $PSCmdlet.ShouldProcess( $Destination, 'Write new file with added chapters' )) {
# Save existing metadata from input file to temp file
$metaDataPath = Join-Path ([IO.Path]::GetTempPath()) ('ffMetadataOld-' + (New-Guid).ToString('n') + '.txt')
$ffmpegArgs = @(
'-hide_banner'
'-i', $Path
'-f', 'ffmetadata'
$metaDataPath
)
Invoke-FFmpeg $ffmpegArgs -Action 'Reading metadata from input file' -ActionTarget $Path
# Read metadata and remove any existing chapters
$metaData = Get-Content $metaDataPath | Remove-ChaptersFromMetadata
# Add new chapters to metadata
$metaData += New-ChaptersMetaData $tracks
# Save new metadata to temp file
$newMetaDataPath = Join-Path ([IO.Path]::GetTempPath()) ('ffMetadataNew-' + (New-Guid).ToString('n') + '.txt')
$metaData | Set-Content $newMetaDataPath -Encoding utf8
# Create destination file and get its resolved path (unless -WhatIf argument is given, then this pipeline won't run).
New-Item $Destination -Force | ForEach-Object { $Destination = $_.FullName }
# Write output file with modified metadata
$ffmpegArgs = @(
'-hide_banner'
'-y' # Overwrite output file
'-i', $Path
'-i', $newMetaDataPath
'-map_metadata', '1'
'-c', 'copy'
$Destination
)
Invoke-FFmpeg $ffmpegArgs -Action 'Writing output file' -ActionTarget $Destination -WriteProgress:$(-not $HideProgress) -TotalFrameCount $totalFrameCount
}
}
#------------------------------------------------------------------------------------------------------------------------------------------
# Parse timestamp data.
# Input is individual lins of text, such as from Get-Content without -Raw.
# Each line must consist of a timestamp (HH:MM:SS), one or more space or tab characters and a title.
Function ConvertFrom-TimestampData {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[string[]] $InputObject
)
begin {
$result = [Collections.ArrayList]::new()
}
process {
foreach( $line in $InputObject ) {
if( $line = $line.Trim() ) {
$startTime, $title = $line -split '\s+', 2
$null = $result.Add( [pscustomobject]@{
StartTime = [Timespan] $startTime
Duration = $null
Title = $title.Trim()
})
}
}
}
end {
if( $result.Count -ge 2 ) {
foreach( $i in 0..($result.Count - 2) ) {
$result[ $i ].Duration = $result[ $i + 1 ].StartTime - $result[ $i ].StartTime
}
}
$result
}
}
#------------------------------------------------------------------------------------------------------------------------------------------
# Call ffmpeg which is expected to be found via PATH environment variable.
# By default ffmpeg output isn't shown. To show ffmpeg output, call this script with -Verbose switch.
Function Invoke-FFmpeg {
[CmdletBinding()]
param (
[Parameter( Mandatory )]
[string[]] $FFmpegArgs,
[Parameter( Mandatory )]
[string] $Action,
[Parameter( Mandatory )]
[string] $ActionTarget,
[switch] $WriteProgress,
[int] $TotalFrameCount
)
Write-Verbose "$Action ($ActionTarget)"
$joinedArgs = $FFmpegArgs -replace '.*\s.*', '"$0"' -join ' '
Write-Debug "ffmpeg $joinedArgs"
$errorDetails = if( $VerbosePreference -eq 'Continue' ) {
# Output everything to verbose stream (no capturing).
ffmpeg @FFmpegArgs *>&1 | Write-Verbose
Write-Verbose ('-' * ($Host.UI.RawUI.BufferSize.Width - 'VERBOSE: '.Length))
}
else {
# Capture only error messages, if any.
ffmpeg -v error -progress pipe:1 @FFmpegArgs *>&1 | ForEach-Object {
if( $_ -match 'frame=(\d+)' ) {
if( $WriteProgress -and $TotalFrameCount -gt 0 ) {
$frameNum = [int] $matches[ 1 ]
$progress = [int] (1 + ($frameNum * 99 / $TotalFrameCount))
Write-Progress -Activity $Action -Status "$progress% - $(Split-Path -Leaf $ActionTarget)" -PercentComplete $progress
}
}
else {
$_
}
}
}
if( 0 -ne $LASTEXITCODE ) {
$errorMessage = "Operation failed: $Action"
if( $errorDetails ) {
$errorMessage += "`n$errorDetails"
}
throw [System.Management.Automation.ErrorRecord]::new(
$errorMessage,
'ffmpeg',
[Management.Automation.ErrorCategory]::OperationStopped,
$ActionTarget
)
}
}
#------------------------------------------------------------------------------------------------------------------------------------------
# Remove any existing chapters from FFmpeg metadata.
# Input is expected to be individual lines of text (such as from Get-Content without -Raw).
Function Remove-ChaptersFromMetadata {
[CmdletBinding()]
param (
[Parameter( Mandatory, ValueFromPipeline )]
[string[]] $InputObject
)
begin {
$section = $null
}
process {
foreach( $line in $InputObject ) {
if( $line -match '^\[([^\[]+)\]' ) {
$section = $matches[1]
}
if( $section -cne 'CHAPTER' ) {
$_ # Output
}
}
}
}
#------------------------------------------------------------------------------------------------------------------------------------------
# Generate chapters metadata from the timestamp data.
# Output are individual lines of text.
Function New-ChaptersMetaData {
[CmdletBinding()]
param (
[Parameter( Mandatory, ValueFromPipeline )]
[object[]] $InputObject
)
process {
foreach( $track in $InputObject ) {
"[CHAPTER]"
"TIMEBASE=1/1000"
"START=" + $track.StartTime.TotalMilliseconds
"END=" + ($track.StartTime + $track.Duration).TotalMilliseconds
"title=" + ($track.Title -replace '=', '\=' -replace ';', '\=' -replace '#', '\#' -replace '\\', '\\' )
}
}
}
#------------------------------------------------------------------------------------------------------------------------------------------
$oldErrorActionPreference = $ErrorActionPreference
# Set script-local ErrorActionPreference to 'Stop' to define our preferred way to handle errors internally, within this function.
$ErrorActionPreference = 'Stop'
try {
Main
}
catch {
# Finally we want to respect $ErrorActionPreference of the caller and -ErrorAction common parameter, so restore $ErrorActionPreference.
$ErrorActionPreference = $oldErrorActionPreference
$PSCmdlet.WriteError( $_ )
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment