Last active
March 10, 2023 11:59
-
-
Save zett42/c7bbc4e272e0ee8ae02f23198b4ce095 to your computer and use it in GitHub Desktop.
Add chapters to a video file
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 | |
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