Skip to content

Instantly share code, notes, and snippets.

@h8rt3rmin8r
Last active August 18, 2026 05:26
Show Gist options
  • Select an option

  • Save h8rt3rmin8r/2c3ea4654e47684b2695653585aa4f5a to your computer and use it in GitHub Desktop.

Select an option

Save h8rt3rmin8r/2c3ea4654e47684b2695653585aa4f5a to your computer and use it in GitHub Desktop.
Remux browser-incompatible video containers to faststart MP4 in place when viewed in a browser.
<#
.SYNOPSIS
Remux browser-incompatible video containers to faststart MP4 in place.
.DESCRIPTION
Walks a target directory tree, probes every matching video file with
ffprobe, and identifies files whose container is not MP4. A common case
is an MPEG transport stream carrying an .mp4 extension: the codecs are
fine, the extension lies, and Chrome renders an empty player shell
because it cannot demux MPEG-TS natively.
Each non-compliant file is remuxed with ffmpeg using stream copy, so no
re-encoding occurs and no visual quality is lost. The output is written
to a local staging directory first, verified with a second ffprobe pass,
and only then copied back over the original. Staging locally matters
when the target is a network or object-storage mount: the +faststart
flag requires ffmpeg to seek backwards while finalizing, which many
mounts handle poorly or not at all.
Files that already report an MP4 container are left untouched. Files
whose video codec is not H.264 or whose audio codec is not AAC are
reported and skipped, because stream copy alone would not make them
browser-playable and transcoding is deliberately out of scope for this
script.
Every file is confined to the target tree. The resolved full path of
each candidate is compared against the resolved root before any work
begins, and reparse points (symlinks and junctions) are skipped, so a
link pointing outside the tree cannot pull the run out of scope.
Overwriting the original is destructive, so the script declares
SupportsShouldProcess and gates the remux-and-replace unit behind
$PSCmdlet.ShouldProcess. Running with -WhatIf performs the full probe
pass and reports exactly which files would be converted without
remuxing or writing anything, which makes it the intended way to audit
a collection before committing to the work.
Requires ffmpeg and ffprobe on PATH, or explicit paths supplied through
-FFmpegPath and -FFprobePath. Both must be native Windows builds if the
target is a mapped drive letter; a WSL-only ffmpeg will not resolve a
Windows drive path.
Exit codes: 0 success, 1 one or more files failed to convert or failed
post-conversion verification, 2 an environment precondition was not met
(target directory missing, ffmpeg or ffprobe not found, staging
directory not creatable).
.PARAMETER Path
Root directory to scan. The scan recurses into subdirectories but never
leaves this tree. Must exist. Mandatory, so the scope of a run is always
stated explicitly at the call site.
Alias: p
.PARAMETER Pattern
Glob pattern used to enumerate candidate files (passed to Get-ChildItem
-Filter). Deliberate wildcard use; the literal-path rule applies to the
handling of each resolved file, not to this enumeration filter.
Default: '*.mp4'.
Alias: f
.PARAMETER StagingPath
Local directory used to build converted files before they are copied
back over the originals. Should sit on fast local storage, not on the
mount being processed. Created if it does not exist.
Default: a 'remux-staging' folder under the user temp directory.
Alias: t
.PARAMETER FFmpegPath
Path to the ffmpeg executable, or a bare command name to resolve
through PATH.
Default: 'ffmpeg'.
Alias: m
.PARAMETER FFprobePath
Path to the ffprobe executable, or a bare command name to resolve
through PATH.
Default: 'ffprobe'.
Alias: r
.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
.\Convert-MediaContainer.ps1 -Path 'D:\Media\Lectures' -WhatIf
Audits the tree and reports the container and codecs of every .mp4
without converting or writing anything. Run this first.
.EXAMPLE
.\Convert-MediaContainer.ps1 -Path 'D:\Media\Lectures'
Converts every non-MP4 container under the tree, prompting before each
replacement because ConfirmImpact is High.
.EXAMPLE
.\Convert-MediaContainer.ps1 -Path 'D:\Media\Lectures\Series One' -Confirm:$false
Converts a single subfolder without prompting. Use once a -WhatIf pass
has confirmed the scope.
.EXAMPLE
.\Convert-MediaContainer.ps1 -Path 'D:\Media\Lectures' -StagingPath 'E:\staging' -Quiet
Stages conversions on a specific local volume and suppresses
informational output, leaving only warnings and errors.
.NOTES
External tools are launched through System.Diagnostics.ProcessStartInfo
rather than Start-Process. ProcessStartInfo.ArgumentList applies native
per-argument escaping, so paths containing spaces arrive intact, and it
carries no ShouldProcess semantics that would inherit -WhatIf and
suppress a read-only probe. The destructive gate sits around the
remux-and-replace unit, which is the only state-changing act.
Stream mapping is pinned to the first video and first audio stream
(-map 0:v:0 -map 0:a:0). MPEG-TS files frequently expose program
wrappers that duplicate stream entries, and pinning the mapping keeps
the output deterministic. Subtitle and secondary audio tracks are not
carried over.
The audio bitstream filter aac_adtstoasc converts the ADTS framing that
transport streams use into the ASC form MP4 expects. Recent ffmpeg
builds usually insert it automatically; declaring it explicitly makes
the failure loud rather than silent.
If the target sits on object storage with versioning enabled, the
overwrite creates a new version and the original remains recoverable
server-side. Verify that versioning is actually on before relying on it
as the rollback path.
#>
[CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='High',DefaultParameterSetName='Default')]
Param(
[Parameter(Mandatory=$true,ParameterSetName='Default')]
[Alias("p")]
[string]$Path,
[Parameter(Mandatory=$false,ParameterSetName='Default')]
[Alias("f")]
[string]$Pattern = '*.mp4',
[Parameter(Mandatory=$false,ParameterSetName='Default')]
[Alias("t")]
[string]$StagingPath = ([System.IO.Path]::Combine($env:TEMP, 'remux-staging')),
[Parameter(Mandatory=$false,ParameterSetName='Default')]
[Alias("m")]
[string]$FFmpegPath = 'ffmpeg',
[Parameter(Mandatory=$false,ParameterSetName='Default')]
[Alias("r")]
[string]$FFprobePath = 'ffprobe',
[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 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
}
function Resolve-ToolPath {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true,Position=0)]
[string]$Candidate
)
# An explicit path wins; otherwise resolve the bare name via PATH.
if ([System.IO.File]::Exists($Candidate)) {
return [System.IO.Path]::GetFullPath($Candidate)
}
$found = Get-Command -Name $Candidate -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($found) {
return $found.Source
}
return $null
}
function Invoke-Tool {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true,Position=0)]
[string]$FilePath,
[Parameter(Mandatory=$true,Position=1)]
[string[]]$Arguments
)
# Start-Process is deliberately avoided here. Its -ArgumentList
# joins array elements with spaces and does not quote elements that
# themselves contain spaces, so any path with a space arrives at the
# tool split into several arguments. ProcessStartInfo.ArgumentList
# applies the platform's native argument escaping instead, and it
# carries no ShouldProcess semantics to inherit -WhatIf from.
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = $FilePath
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
foreach ($argument in $Arguments) {
$psi.ArgumentList.Add($argument)
}
$proc = [System.Diagnostics.Process]::Start($psi)
# Read both streams before waiting. A tool that fills the stderr
# pipe buffer while the parent blocks on WaitForExit deadlocks.
$outTask = $proc.StandardOutput.ReadToEndAsync()
$errTask = $proc.StandardError.ReadToEndAsync()
$proc.WaitForExit()
$stdOut = $outTask.GetAwaiter().GetResult()
$stdErr = $errTask.GetAwaiter().GetResult()
$composed = ($Arguments | ForEach-Object {
if ($_ -match '\s') { '"' + $_ + '"' } else { $_ }
}) -join ' '
return [PSCustomObject]@{
ExitCode = $proc.ExitCode
StdOut = $stdOut
StdErr = $stdErr
Command = ("{0} {1}" -f $FilePath, $composed)
}
}
function Get-MediaProbe {
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true,Position=0)]
[string]$MediaPath,
[Parameter(Mandatory=$true)]
[string]$ProbeExe
)
# Always returns an object. Ok reports whether the probe succeeded,
# and Error carries the reason so the caller can report it rather
# than swallowing the failure behind a bare null.
$probeArgs = @(
'-v', 'error',
'-show_entries', 'format=format_name,duration',
'-show_entries', 'stream=codec_type,codec_name',
'-of', 'json',
$MediaPath
)
$failure = [PSCustomObject]@{
Ok = $false
Error = ''
FormatName = ''
Duration = 0.0
VideoCodec = ''
AudioCodec = ''
}
try {
$result = Invoke-Tool -FilePath $ProbeExe -Arguments $probeArgs
} catch {
$failure.Error = ("could not launch ffprobe: {0}" -f $_.Exception.Message)
return $failure
}
if ($result.ExitCode -ne 0) {
$failure.Error = ("ffprobe exit {0}: {1}" -f $result.ExitCode, $result.StdErr.Trim())
return $failure
}
if ([string]::IsNullOrWhiteSpace($result.StdOut)) {
$failure.Error = 'ffprobe returned no output'
return $failure
}
try {
$json = $result.StdOut | ConvertFrom-Json
} catch {
$failure.Error = ("could not parse ffprobe JSON: {0}" -f $_.Exception.Message)
return $failure
}
$videoCodec = ($json.streams | Where-Object { $_.codec_type -eq 'video' } |
Select-Object -First 1).codec_name
$audioCodec = ($json.streams | Where-Object { $_.codec_type -eq 'audio' } |
Select-Object -First 1).codec_name
$duration = 0.0
if ($json.format.duration) {
[double]::TryParse($json.format.duration, [ref]$duration) | Out-Null
}
return [PSCustomObject]@{
Ok = $true
Error = ''
FormatName = [string]$json.format.format_name
Duration = $duration
VideoCodec = [string]$videoCodec
AudioCodec = [string]$audioCodec
}
}
#_______________________________________________________________________________
## Declare Variables and Arrays
$script:LogQuiet = $false
$script:LogSilent = $false
$ThisScriptPath = $MyInvocation.MyCommand.Path
# ffprobe reports real MP4 files under this multiplexed demuxer name.
$Mp4FormatMarker = 'mp4'
# Stream copy only produces a browser-playable MP4 for these codecs.
$SupportedVideoCodecs = @('h264')
$SupportedAudioCodecs = @('aac')
# Duration drift tolerated between source and remuxed output, in
# seconds, or one percent of the source duration if that is larger.
$DurationToleranceSeconds = 2.0
#_______________________________________________________________________________
## 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: the target directory must exist
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
Write-Host ("FAIL: target directory not found: {0}" -f $Path) -ForegroundColor Red
exit 2
}
# Environment precondition: both tools must resolve
$ffmpegExe = Resolve-ToolPath $FFmpegPath
$ffprobeExe = Resolve-ToolPath $FFprobePath
if (-not $ffmpegExe) {
Write-Host ("FAIL: ffmpeg not found ('{0}'). Install a native Windows build and add it to PATH, or pass -FFmpegPath." -f $FFmpegPath) -ForegroundColor Red
exit 2
}
if (-not $ffprobeExe) {
Write-Host ("FAIL: ffprobe not found ('{0}'). Install a native Windows build and add it to PATH, or pass -FFprobePath." -f $FFprobePath) -ForegroundColor Red
exit 2
}
# Environment precondition: staging directory must be usable
try {
[System.IO.Directory]::CreateDirectory($StagingPath) | Out-Null
} catch {
Write-Host ("FAIL: cannot create staging directory {0}: {1}" -f $StagingPath, $_.Exception.Message) -ForegroundColor Red
exit 2
}
# Resolve the containment boundary once, with a trailing separator so a
# sibling directory sharing a name prefix cannot pass the check.
$rootFull = [System.IO.Path]::GetFullPath((Resolve-Path -LiteralPath $Path).Path)
$rootPrefix = $rootFull.TrimEnd([System.IO.Path]::DirectorySeparatorChar) +
[System.IO.Path]::DirectorySeparatorChar
Write-Log ("Root: {0}" -f $rootFull) -Level Info -Source 'Scan'
Write-Log ("Staging: {0}" -f $StagingPath) -Level Info -Source 'Scan'
Write-Log ("ffmpeg: {0}" -f $ffmpegExe) -Level Debug -Source 'Scan'
Write-Log ("ffprobe: {0}" -f $ffprobeExe) -Level Debug -Source 'Scan'
# Prove the tool actually runs and that argument quoting survives before
# walking the tree. A failure here is an environment problem, not a
# per-file one, and reporting it once beats reporting it per file.
$toolCheck = Invoke-Tool -FilePath $ffprobeExe -Arguments @('-version')
if ($toolCheck.ExitCode -ne 0) {
Write-Host ("FAIL: ffprobe is present but did not run cleanly (exit {0}): {1}" -f $toolCheck.ExitCode, $toolCheck.StdErr.Trim()) -ForegroundColor Red
exit 2
}
$candidates = Get-ChildItem -LiteralPath $rootFull -Filter $Pattern -File -Recurse
if (-not $candidates) {
Write-Log ("No files matching '{0}' found under the target tree." -f $Pattern) -Level Success -Source 'Scan'
exit 0
}
Write-Log ("Found {0} candidate file(s)." -f $candidates.Count) -Level Info -Source 'Scan'
$compliant = 0
$converted = 0
$skipped = 0
$failed = 0
$wouldConvert = 0
foreach ($file in $candidates) {
$sourcePath = $file.FullName
# Containment guard: never act on anything outside the root tree.
$fileFull = [System.IO.Path]::GetFullPath($sourcePath)
if (-not $fileFull.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
Write-Log ("Outside target tree, skipping: {0}" -f $fileFull) -Level Warn -Source 'Guard'
$skipped++
continue
}
# Reparse points can redirect outside the tree even when the path
# looks contained. Leave them alone.
if ($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
Write-Log ("Reparse point, skipping: {0}" -f $fileFull) -Level Warn -Source 'Guard'
$skipped++
continue
}
$probe = Get-MediaProbe -MediaPath $fileFull -ProbeExe $ffprobeExe
if (-not $probe.Ok) {
Write-Log ("Probe failed ({0}), skipping: {1}" -f $probe.Error, $fileFull) -Level Error -Source 'Probe'
$failed++
continue
}
# Already an MP4 container: nothing to do.
if ($probe.FormatName -like ("*{0}*" -f $Mp4FormatMarker)) {
Write-Log ("Compliant ({0}): {1}" -f $probe.FormatName, $file.Name) -Level Debug -Source 'Probe'
$compliant++
continue
}
# Stream copy cannot rescue codecs the browser will not decode.
if (($SupportedVideoCodecs -notcontains $probe.VideoCodec) -or
($SupportedAudioCodecs -notcontains $probe.AudioCodec)) {
Write-Log ("Unsupported codecs (video={0}, audio={1}), needs transcode not remux: {2}" -f $probe.VideoCodec, $probe.AudioCodec, $file.Name) -Level Warn -Source 'Probe'
$skipped++
continue
}
Write-Log ("Needs remux ({0}, {1}/{2}): {3}" -f $probe.FormatName, $probe.VideoCodec, $probe.AudioCodec, $file.Name) -Level Info -Source 'Probe'
if (-not $PSCmdlet.ShouldProcess($fileFull, 'Remux container to faststart MP4 and overwrite in place')) {
$wouldConvert++
continue
}
$stagedPath = [System.IO.Path]::Combine($StagingPath, ("remux-{0}.mp4" -f ([System.Guid]::NewGuid().ToString('N'))))
try {
$ffmpegArgs = @(
'-hide_banner',
'-nostdin',
'-loglevel', 'error',
'-y',
'-i', $fileFull,
'-map', '0:v:0',
'-map', '0:a:0',
'-c', 'copy',
'-bsf:a', 'aac_adtstoasc',
'-movflags', '+faststart',
'-f', 'mp4',
$stagedPath
)
$run = Invoke-Tool -FilePath $ffmpegExe -Arguments $ffmpegArgs
if ($run.ExitCode -ne 0) {
Write-Log ("ffmpeg exited {0} for {1}: {2}" -f $run.ExitCode, $file.Name, $run.StdErr.Trim()) -Level Error -Source 'Remux'
$failed++
continue
}
if (-not [System.IO.File]::Exists($stagedPath)) {
Write-Log ("ffmpeg reported success but produced no output: {0}" -f $file.Name) -Level Error -Source 'Remux'
$failed++
continue
}
# Verify the output before it is allowed to replace the source.
$verify = Get-MediaProbe -MediaPath $stagedPath -ProbeExe $ffprobeExe
if (-not $verify.Ok) {
Write-Log ("Output failed to probe ({0}), original left untouched: {1}" -f $verify.Error, $file.Name) -Level Error -Source 'Verify'
$failed++
continue
}
if ($verify.FormatName -notlike ("*{0}*" -f $Mp4FormatMarker)) {
Write-Log ("Output container is still '{0}', original left untouched: {1}" -f $verify.FormatName, $file.Name) -Level Error -Source 'Verify'
$failed++
continue
}
$tolerance = [Math]::Max($DurationToleranceSeconds, ($probe.Duration * 0.01))
$drift = [Math]::Abs($verify.Duration - $probe.Duration)
if ($drift -gt $tolerance) {
Write-Log ("Duration drift {0:N2}s exceeds tolerance {1:N2}s, original left untouched: {2}" -f $drift, $tolerance, $file.Name) -Level Error -Source 'Verify'
$failed++
continue
}
# Replace the original. On a versioned object store this creates
# a new version rather than destroying the previous bytes. The
# outer ShouldProcess gate already authorized this, so the copy
# does not prompt a second time.
Copy-Item -LiteralPath $stagedPath -Destination $fileFull -Force -Confirm:$false
$converted++
Write-Log ("Converted ({0:N2}s, drift {1:N2}s): {2}" -f $verify.Duration, $drift, $file.Name) -Level Success -Source 'Remux'
} catch {
$failed++
Write-Log ("Unhandled failure on {0}: {1}" -f $file.Name, $_.Exception.Message) -Level Error -Source 'Remux'
} finally {
if ([System.IO.File]::Exists($stagedPath)) {
Remove-Item -LiteralPath $stagedPath -Force `
-WhatIf:$false -Confirm:$false -ErrorAction SilentlyContinue
}
}
}
Write-Log ("Done. Already compliant {0}, converted {1}, skipped {2}, failed {3}." -f $compliant, $converted, $skipped, $failed) -Level Info -Source 'Summary'
if ($wouldConvert -gt 0) {
Write-Log ("{0} file(s) would be converted on a real run." -f $wouldConvert) -Level Info -Source 'Summary'
}
if ($failed -gt 0) {
exit 1
}
exit 0
#_______________________________________________________________________________
## End of script
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment