Created
July 13, 2026 07:52
-
-
Save Geofferey/ca22e4a839bbd92af1da7d87bc97ac06 to your computer and use it in GitHub Desktop.
GStreamer-Basic-WHIP-GUI
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
| #requires -Version 5.1 | |
| <# | |
| .SYNOPSIS | |
| Minimal Windows GUI wrapper for a low-latency GStreamer WHIP desktop stream. | |
| .DESCRIPTION | |
| Uses the proven pipeline: | |
| D3D11 desktop capture -> D3D11 conversion -> NVENC H.264 -> WHIP | |
| Optional audio paths: | |
| Windows desktop loopback -> Opus | |
| Default microphone -> Opus | |
| Desktop + microphone -> audiomixer -> Opus | |
| The GUI launches gst-launch-1.0.exe as a separate process. Stop/Restart kills | |
| the complete process tree so capture, encoder, clocks, and WHIP state are rebuilt. | |
| #> | |
| Add-Type -AssemblyName System.Windows.Forms | |
| Add-Type -AssemblyName System.Drawing | |
| [System.Windows.Forms.Application]::EnableVisualStyles() | |
| $script:AppName = 'GStreamer Basic WHIP Streamer' | |
| $script:ConfigDirectory = Join-Path $env:APPDATA 'GStreamerBasicWhipStreamer' | |
| $script:ConfigPath = Join-Path $script:ConfigDirectory 'settings.json' | |
| $script:LogDirectory = Join-Path $env:LOCALAPPDATA 'GStreamerBasicWhipStreamer\Logs' | |
| $script:GstProcess = $null | |
| $script:StopRequested = $false | |
| $script:RestartAt = $null | |
| $script:StdOutPath = $null | |
| $script:StdErrPath = $null | |
| $script:StdOutPosition = [int64]0 | |
| $script:StdErrPosition = [int64]0 | |
| function Find-GstLaunch { | |
| $command = Get-Command 'gst-launch-1.0.exe' -ErrorAction SilentlyContinue | |
| if ($command) { | |
| return $command.Source | |
| } | |
| $candidates = @( | |
| (Join-Path $env:ProgramFiles 'gstreamer\1.0\msvc_x86_64\bin\gst-launch-1.0.exe'), | |
| (Join-Path $env:ProgramFiles 'gstreamer\1.0\mingw_x86_64\bin\gst-launch-1.0.exe'), | |
| (Join-Path ${env:ProgramFiles(x86)} 'gstreamer\1.0\msvc_x86_64\bin\gst-launch-1.0.exe'), | |
| (Join-Path ${env:ProgramFiles(x86)} 'gstreamer\1.0\mingw_x86_64\bin\gst-launch-1.0.exe') | |
| ) | |
| foreach ($candidate in $candidates) { | |
| if ($candidate -and (Test-Path -LiteralPath $candidate)) { | |
| return $candidate | |
| } | |
| } | |
| return '' | |
| } | |
| function Format-InvariantNumber { | |
| param( | |
| [Parameter(Mandatory)] | |
| [double]$Value, | |
| [string]$Format = '0.00' | |
| ) | |
| return $Value.ToString($Format, [System.Globalization.CultureInfo]::InvariantCulture) | |
| } | |
| function Quote-GstValue { | |
| param([Parameter(Mandatory)][string]$Value) | |
| $escaped = $Value.Replace('\', '\\').Replace('"', '\"') | |
| return '"' + $escaped + '"' | |
| } | |
| function Read-NewLogText { | |
| param( | |
| [string]$Path, | |
| [ref]$Position | |
| ) | |
| if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path)) { | |
| return '' | |
| } | |
| try { | |
| $stream = New-Object System.IO.FileStream( | |
| $Path, | |
| [System.IO.FileMode]::Open, | |
| [System.IO.FileAccess]::Read, | |
| [System.IO.FileShare]::ReadWrite | |
| ) | |
| try { | |
| if ($Position.Value -gt $stream.Length) { | |
| $Position.Value = [int64]0 | |
| } | |
| $null = $stream.Seek($Position.Value, [System.IO.SeekOrigin]::Begin) | |
| $reader = New-Object System.IO.StreamReader($stream) | |
| try { | |
| $text = $reader.ReadToEnd() | |
| $Position.Value = $stream.Position | |
| return $text | |
| } | |
| finally { | |
| $reader.Dispose() | |
| } | |
| } | |
| finally { | |
| $stream.Dispose() | |
| } | |
| } | |
| catch { | |
| return '' | |
| } | |
| } | |
| $form = New-Object System.Windows.Forms.Form | |
| $form.Text = $script:AppName | |
| $form.StartPosition = 'CenterScreen' | |
| $form.Size = New-Object System.Drawing.Size(930, 790) | |
| $form.MinimumSize = New-Object System.Drawing.Size(850, 700) | |
| $form.Font = New-Object System.Drawing.Font('Segoe UI', 9) | |
| $toolTip = New-Object System.Windows.Forms.ToolTip | |
| $toolTip.AutoPopDelay = 12000 | |
| $toolTip.InitialDelay = 400 | |
| $toolTip.ReshowDelay = 100 | |
| $settingsGroup = New-Object System.Windows.Forms.GroupBox | |
| $settingsGroup.Text = 'Stream Settings' | |
| $settingsGroup.Location = New-Object System.Drawing.Point(10, 10) | |
| $settingsGroup.Size = New-Object System.Drawing.Size(895, 315) | |
| $settingsGroup.Anchor = 'Top,Left,Right' | |
| $form.Controls.Add($settingsGroup) | |
| function Add-Label { | |
| param( | |
| [System.Windows.Forms.Control]$Parent, | |
| [string]$Text, | |
| [int]$X, | |
| [int]$Y, | |
| [int]$Width = 120 | |
| ) | |
| $label = New-Object System.Windows.Forms.Label | |
| $label.Text = $Text | |
| $label.Location = New-Object System.Drawing.Point($X, $Y) | |
| $label.Size = New-Object System.Drawing.Size($Width, 22) | |
| $label.TextAlign = 'MiddleLeft' | |
| $Parent.Controls.Add($label) | |
| return $label | |
| } | |
| $null = Add-Label $settingsGroup 'GStreamer executable' 15 25 130 | |
| $txtGstPath = New-Object System.Windows.Forms.TextBox | |
| $txtGstPath.Location = New-Object System.Drawing.Point(150, 25) | |
| $txtGstPath.Size = New-Object System.Drawing.Size(575, 23) | |
| $txtGstPath.Anchor = 'Top,Left,Right' | |
| $txtGstPath.Text = Find-GstLaunch | |
| $settingsGroup.Controls.Add($txtGstPath) | |
| $btnBrowseGst = New-Object System.Windows.Forms.Button | |
| $btnBrowseGst.Text = 'Browse...' | |
| $btnBrowseGst.Location = New-Object System.Drawing.Point(735, 23) | |
| $btnBrowseGst.Size = New-Object System.Drawing.Size(70, 27) | |
| $btnBrowseGst.Anchor = 'Top,Right' | |
| $settingsGroup.Controls.Add($btnBrowseGst) | |
| $btnCheckGst = New-Object System.Windows.Forms.Button | |
| $btnCheckGst.Text = 'Check' | |
| $btnCheckGst.Location = New-Object System.Drawing.Point(810, 23) | |
| $btnCheckGst.Size = New-Object System.Drawing.Size(65, 27) | |
| $btnCheckGst.Anchor = 'Top,Right' | |
| $settingsGroup.Controls.Add($btnCheckGst) | |
| $null = Add-Label $settingsGroup 'WHIP endpoint' 15 60 130 | |
| $txtWhipUrl = New-Object System.Windows.Forms.TextBox | |
| $txtWhipUrl.Location = New-Object System.Drawing.Point(150, 60) | |
| $txtWhipUrl.Size = New-Object System.Drawing.Size(725, 23) | |
| $txtWhipUrl.Anchor = 'Top,Left,Right' | |
| $txtWhipUrl.Text = 'http://10.0.0.25:8889/live/whip' | |
| $settingsGroup.Controls.Add($txtWhipUrl) | |
| $toolTip.SetToolTip($txtWhipUrl, 'MediaMTX example: http://server:8889/live/whip. Cloud providers may use an HTTPS publishing URL.') | |
| $null = Add-Label $settingsGroup 'Monitor index' 15 96 100 | |
| $numMonitor = New-Object System.Windows.Forms.NumericUpDown | |
| $numMonitor.Location = New-Object System.Drawing.Point(115, 96) | |
| $numMonitor.Size = New-Object System.Drawing.Size(65, 23) | |
| $numMonitor.Minimum = -1 | |
| $numMonitor.Maximum = 32 | |
| $numMonitor.Value = -1 | |
| $settingsGroup.Controls.Add($numMonitor) | |
| $toolTip.SetToolTip($numMonitor, '-1 uses the primary monitor. Other values select a monitor by GStreamer index.') | |
| $chkCursor = New-Object System.Windows.Forms.CheckBox | |
| $chkCursor.Text = 'Show cursor' | |
| $chkCursor.Location = New-Object System.Drawing.Point(195, 96) | |
| $chkCursor.Size = New-Object System.Drawing.Size(105, 23) | |
| $chkCursor.Checked = $true | |
| $settingsGroup.Controls.Add($chkCursor) | |
| $chkAutoRestart = New-Object System.Windows.Forms.CheckBox | |
| $chkAutoRestart.Text = 'Auto-restart if pipeline exits' | |
| $chkAutoRestart.Location = New-Object System.Drawing.Point(315, 96) | |
| $chkAutoRestart.Size = New-Object System.Drawing.Size(190, 23) | |
| $chkAutoRestart.Checked = $true | |
| $settingsGroup.Controls.Add($chkAutoRestart) | |
| $toolTip.SetToolTip($chkAutoRestart, 'Rebuilds the entire GStreamer process after an unexpected exit.') | |
| $chkVerbose = New-Object System.Windows.Forms.CheckBox | |
| $chkVerbose.Text = 'Verbose GStreamer output' | |
| $chkVerbose.Location = New-Object System.Drawing.Point(520, 96) | |
| $chkVerbose.Size = New-Object System.Drawing.Size(180, 23) | |
| $chkVerbose.Checked = $false | |
| $settingsGroup.Controls.Add($chkVerbose) | |
| $null = Add-Label $settingsGroup 'Width' 15 132 55 | |
| $numWidth = New-Object System.Windows.Forms.NumericUpDown | |
| $numWidth.Location = New-Object System.Drawing.Point(70, 132) | |
| $numWidth.Size = New-Object System.Drawing.Size(85, 23) | |
| $numWidth.Minimum = 320 | |
| $numWidth.Maximum = 7680 | |
| $numWidth.Increment = 16 | |
| $numWidth.Value = 1920 | |
| $settingsGroup.Controls.Add($numWidth) | |
| $null = Add-Label $settingsGroup 'Height' 170 132 55 | |
| $numHeight = New-Object System.Windows.Forms.NumericUpDown | |
| $numHeight.Location = New-Object System.Drawing.Point(225, 132) | |
| $numHeight.Size = New-Object System.Drawing.Size(85, 23) | |
| $numHeight.Minimum = 240 | |
| $numHeight.Maximum = 4320 | |
| $numHeight.Increment = 16 | |
| $numHeight.Value = 1080 | |
| $settingsGroup.Controls.Add($numHeight) | |
| $null = Add-Label $settingsGroup 'FPS' 330 132 40 | |
| $numFps = New-Object System.Windows.Forms.NumericUpDown | |
| $numFps.Location = New-Object System.Drawing.Point(370, 132) | |
| $numFps.Size = New-Object System.Drawing.Size(65, 23) | |
| $numFps.Minimum = 1 | |
| $numFps.Maximum = 240 | |
| $numFps.Value = 60 | |
| $settingsGroup.Controls.Add($numFps) | |
| $null = Add-Label $settingsGroup 'Video bitrate (kbps)' 455 132 125 | |
| $numVideoBitrate = New-Object System.Windows.Forms.NumericUpDown | |
| $numVideoBitrate.Location = New-Object System.Drawing.Point(585, 132) | |
| $numVideoBitrate.Size = New-Object System.Drawing.Size(95, 23) | |
| $numVideoBitrate.Minimum = 250 | |
| $numVideoBitrate.Maximum = 100000 | |
| $numVideoBitrate.Increment = 500 | |
| $numVideoBitrate.Value = 12000 | |
| $settingsGroup.Controls.Add($numVideoBitrate) | |
| $null = Add-Label $settingsGroup 'GOP (seconds)' 700 132 95 | |
| $numGopSeconds = New-Object System.Windows.Forms.NumericUpDown | |
| $numGopSeconds.Location = New-Object System.Drawing.Point(795, 132) | |
| $numGopSeconds.Size = New-Object System.Drawing.Size(65, 23) | |
| $numGopSeconds.Minimum = 1 | |
| $numGopSeconds.Maximum = 10 | |
| $numGopSeconds.Value = 1 | |
| $settingsGroup.Controls.Add($numGopSeconds) | |
| $null = Add-Label $settingsGroup 'NVENC preset' 15 168 90 | |
| $cmbPreset = New-Object System.Windows.Forms.ComboBox | |
| $cmbPreset.Location = New-Object System.Drawing.Point(105, 168) | |
| $cmbPreset.Size = New-Object System.Drawing.Size(80, 23) | |
| $cmbPreset.DropDownStyle = 'DropDownList' | |
| $null = $cmbPreset.Items.AddRange(@('p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7')) | |
| $cmbPreset.SelectedItem = 'p1' | |
| $settingsGroup.Controls.Add($cmbPreset) | |
| $toolTip.SetToolTip($cmbPreset, 'p1 is fastest/lowest latency. Higher values trade speed for compression quality.') | |
| $null = Add-Label $settingsGroup 'H.264 profile' 205 168 90 | |
| $cmbProfile = New-Object System.Windows.Forms.ComboBox | |
| $cmbProfile.Location = New-Object System.Drawing.Point(295, 168) | |
| $cmbProfile.Size = New-Object System.Drawing.Size(145, 23) | |
| $cmbProfile.DropDownStyle = 'DropDownList' | |
| $null = $cmbProfile.Items.AddRange(@('constrained-baseline', 'baseline', 'main', 'high')) | |
| $cmbProfile.SelectedItem = 'constrained-baseline' | |
| $settingsGroup.Controls.Add($cmbProfile) | |
| $null = Add-Label $settingsGroup 'Rate control' 460 168 80 | |
| $txtRateControl = New-Object System.Windows.Forms.TextBox | |
| $txtRateControl.Location = New-Object System.Drawing.Point(540, 168) | |
| $txtRateControl.Size = New-Object System.Drawing.Size(75, 23) | |
| $txtRateControl.Text = 'CBR' | |
| $txtRateControl.ReadOnly = $true | |
| $settingsGroup.Controls.Add($txtRateControl) | |
| $null = Add-Label $settingsGroup 'Queue policy' 635 168 80 | |
| $txtQueuePolicy = New-Object System.Windows.Forms.TextBox | |
| $txtQueuePolicy.Location = New-Object System.Drawing.Point(715, 168) | |
| $txtQueuePolicy.Size = New-Object System.Drawing.Size(145, 23) | |
| $txtQueuePolicy.Text = '2 frames, drop stale' | |
| $txtQueuePolicy.ReadOnly = $true | |
| $settingsGroup.Controls.Add($txtQueuePolicy) | |
| $chkDesktopAudio = New-Object System.Windows.Forms.CheckBox | |
| $chkDesktopAudio.Text = 'Desktop audio' | |
| $chkDesktopAudio.Location = New-Object System.Drawing.Point(15, 208) | |
| $chkDesktopAudio.Size = New-Object System.Drawing.Size(115, 23) | |
| $chkDesktopAudio.Checked = $true | |
| $settingsGroup.Controls.Add($chkDesktopAudio) | |
| $null = Add-Label $settingsGroup 'Volume %' 135 208 65 | |
| $numDesktopVolume = New-Object System.Windows.Forms.NumericUpDown | |
| $numDesktopVolume.Location = New-Object System.Drawing.Point(200, 208) | |
| $numDesktopVolume.Size = New-Object System.Drawing.Size(65, 23) | |
| $numDesktopVolume.Minimum = 0 | |
| $numDesktopVolume.Maximum = 200 | |
| $numDesktopVolume.Value = 100 | |
| $settingsGroup.Controls.Add($numDesktopVolume) | |
| $chkMic = New-Object System.Windows.Forms.CheckBox | |
| $chkMic.Text = 'Default microphone' | |
| $chkMic.Location = New-Object System.Drawing.Point(295, 208) | |
| $chkMic.Size = New-Object System.Drawing.Size(140, 23) | |
| $chkMic.Checked = $false | |
| $settingsGroup.Controls.Add($chkMic) | |
| $null = Add-Label $settingsGroup 'Volume %' 440 208 65 | |
| $numMicVolume = New-Object System.Windows.Forms.NumericUpDown | |
| $numMicVolume.Location = New-Object System.Drawing.Point(505, 208) | |
| $numMicVolume.Size = New-Object System.Drawing.Size(65, 23) | |
| $numMicVolume.Minimum = 0 | |
| $numMicVolume.Maximum = 200 | |
| $numMicVolume.Value = 100 | |
| $settingsGroup.Controls.Add($numMicVolume) | |
| $null = Add-Label $settingsGroup 'Opus bitrate (kbps)' 600 208 125 | |
| $numAudioBitrate = New-Object System.Windows.Forms.NumericUpDown | |
| $numAudioBitrate.Location = New-Object System.Drawing.Point(730, 208) | |
| $numAudioBitrate.Size = New-Object System.Drawing.Size(80, 23) | |
| $numAudioBitrate.Minimum = 32 | |
| $numAudioBitrate.Maximum = 512 | |
| $numAudioBitrate.Increment = 16 | |
| $numAudioBitrate.Value = 128 | |
| $settingsGroup.Controls.Add($numAudioBitrate) | |
| $audioNote = New-Object System.Windows.Forms.Label | |
| $audioNote.Text = 'Desktop audio uses WASAPI loopback. Microphone uses the Windows/GStreamer default capture device.' | |
| $audioNote.Location = New-Object System.Drawing.Point(15, 242) | |
| $audioNote.Size = New-Object System.Drawing.Size(845, 22) | |
| $audioNote.ForeColor = [System.Drawing.Color]::DimGray | |
| $settingsGroup.Controls.Add($audioNote) | |
| $latencyNote = New-Object System.Windows.Forms.Label | |
| $latencyNote.Text = 'Low-latency settings are enforced: D3D11 GPU path, NVENC ultra-low-latency, B-frame-free baseline caps, 10 ms Opus frames, and leaky queues.' | |
| $latencyNote.Location = New-Object System.Drawing.Point(15, 270) | |
| $latencyNote.Size = New-Object System.Drawing.Size(850, 30) | |
| $latencyNote.ForeColor = [System.Drawing.Color]::DimGray | |
| $settingsGroup.Controls.Add($latencyNote) | |
| $commandGroup = New-Object System.Windows.Forms.GroupBox | |
| $commandGroup.Text = 'Generated Command' | |
| $commandGroup.Location = New-Object System.Drawing.Point(10, 335) | |
| $commandGroup.Size = New-Object System.Drawing.Size(895, 118) | |
| $commandGroup.Anchor = 'Top,Left,Right' | |
| $form.Controls.Add($commandGroup) | |
| $txtCommand = New-Object System.Windows.Forms.TextBox | |
| $txtCommand.Location = New-Object System.Drawing.Point(12, 22) | |
| $txtCommand.Size = New-Object System.Drawing.Size(870, 82) | |
| $txtCommand.Multiline = $true | |
| $txtCommand.ScrollBars = 'Both' | |
| $txtCommand.WordWrap = $false | |
| $txtCommand.ReadOnly = $true | |
| $txtCommand.Font = New-Object System.Drawing.Font('Consolas', 8.5) | |
| $txtCommand.Anchor = 'Top,Bottom,Left,Right' | |
| $commandGroup.Controls.Add($txtCommand) | |
| $btnStart = New-Object System.Windows.Forms.Button | |
| $btnStart.Text = 'Start Stream' | |
| $btnStart.Location = New-Object System.Drawing.Point(10, 465) | |
| $btnStart.Size = New-Object System.Drawing.Size(120, 34) | |
| $btnStart.Font = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold) | |
| $form.Controls.Add($btnStart) | |
| $btnStop = New-Object System.Windows.Forms.Button | |
| $btnStop.Text = 'Stop' | |
| $btnStop.Location = New-Object System.Drawing.Point(140, 465) | |
| $btnStop.Size = New-Object System.Drawing.Size(90, 34) | |
| $btnStop.Enabled = $false | |
| $form.Controls.Add($btnStop) | |
| $btnRestart = New-Object System.Windows.Forms.Button | |
| $btnRestart.Text = 'Restart Pipeline' | |
| $btnRestart.Location = New-Object System.Drawing.Point(240, 465) | |
| $btnRestart.Size = New-Object System.Drawing.Size(125, 34) | |
| $btnRestart.Enabled = $false | |
| $form.Controls.Add($btnRestart) | |
| $btnCopyCommand = New-Object System.Windows.Forms.Button | |
| $btnCopyCommand.Text = 'Copy Command' | |
| $btnCopyCommand.Location = New-Object System.Drawing.Point(375, 465) | |
| $btnCopyCommand.Size = New-Object System.Drawing.Size(115, 34) | |
| $form.Controls.Add($btnCopyCommand) | |
| $btnClearLog = New-Object System.Windows.Forms.Button | |
| $btnClearLog.Text = 'Clear Log' | |
| $btnClearLog.Location = New-Object System.Drawing.Point(500, 465) | |
| $btnClearLog.Size = New-Object System.Drawing.Size(90, 34) | |
| $form.Controls.Add($btnClearLog) | |
| $statusLabel = New-Object System.Windows.Forms.Label | |
| $statusLabel.Text = 'Stopped' | |
| $statusLabel.Location = New-Object System.Drawing.Point(610, 470) | |
| $statusLabel.Size = New-Object System.Drawing.Size(295, 25) | |
| $statusLabel.TextAlign = 'MiddleRight' | |
| $statusLabel.Anchor = 'Top,Right' | |
| $statusLabel.Font = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold) | |
| $form.Controls.Add($statusLabel) | |
| $logGroup = New-Object System.Windows.Forms.GroupBox | |
| $logGroup.Text = 'GStreamer Output' | |
| $logGroup.Location = New-Object System.Drawing.Point(10, 510) | |
| $logGroup.Size = New-Object System.Drawing.Size(895, 230) | |
| $logGroup.Anchor = 'Top,Bottom,Left,Right' | |
| $form.Controls.Add($logGroup) | |
| $txtLog = New-Object System.Windows.Forms.TextBox | |
| $txtLog.Location = New-Object System.Drawing.Point(12, 22) | |
| $txtLog.Size = New-Object System.Drawing.Size(870, 195) | |
| $txtLog.Multiline = $true | |
| $txtLog.ScrollBars = 'Both' | |
| $txtLog.WordWrap = $false | |
| $txtLog.ReadOnly = $true | |
| $txtLog.Font = New-Object System.Drawing.Font('Consolas', 8.5) | |
| $txtLog.Anchor = 'Top,Bottom,Left,Right' | |
| $logGroup.Controls.Add($txtLog) | |
| function Append-Log { | |
| param([string]$Text) | |
| if ([string]::IsNullOrEmpty($Text)) { | |
| return | |
| } | |
| $txtLog.AppendText($Text) | |
| if (-not $Text.EndsWith([Environment]::NewLine)) { | |
| $txtLog.AppendText([Environment]::NewLine) | |
| } | |
| if ($txtLog.TextLength -gt 250000) { | |
| $txtLog.Text = $txtLog.Text.Substring($txtLog.TextLength - 180000) | |
| } | |
| $txtLog.SelectionStart = $txtLog.TextLength | |
| $txtLog.ScrollToCaret() | |
| } | |
| function Set-RunState { | |
| param([bool]$Running) | |
| $btnStart.Enabled = -not $Running | |
| $btnStop.Enabled = $Running | |
| $btnRestart.Enabled = $Running | |
| } | |
| function Build-GstArguments { | |
| $url = $txtWhipUrl.Text.Trim() | |
| $quotedUrl = Quote-GstValue $url | |
| $monitor = [int]$numMonitor.Value | |
| $cursor = if ($chkCursor.Checked) { 'true' } else { 'false' } | |
| $width = [int]$numWidth.Value | |
| $height = [int]$numHeight.Value | |
| $fps = [int]$numFps.Value | |
| $videoBitrate = [int]$numVideoBitrate.Value | |
| $gopSize = [Math]::Max(1, $fps * [int]$numGopSeconds.Value) | |
| $preset = [string]$cmbPreset.SelectedItem | |
| $profile = [string]$cmbProfile.SelectedItem | |
| $audioBitrate = [int]$numAudioBitrate.Value * 1000 | |
| $desktopVolume = Format-InvariantNumber ([double]$numDesktopVolume.Value / 100.0) | |
| $micVolume = Format-InvariantNumber ([double]$numMicVolume.Value / 100.0) | |
| $videoBranch = @( | |
| 'd3d11screencapturesrc' | |
| "monitor-index=$monitor" | |
| "show-cursor=$cursor" | |
| '!' | |
| "`"video/x-raw(memory:D3D11Memory),framerate=$fps/1`"" | |
| '!' | |
| 'd3d11convert' | |
| '!' | |
| "`"video/x-raw(memory:D3D11Memory),format=NV12,width=$width,height=$height,framerate=$fps/1`"" | |
| '!' | |
| 'queue' | |
| 'max-size-buffers=2' | |
| 'max-size-bytes=0' | |
| 'max-size-time=0' | |
| 'leaky=downstream' | |
| '!' | |
| 'nvd3d11h264enc' | |
| "bitrate=$videoBitrate" | |
| 'rc-mode=cbr' | |
| "preset=$preset" | |
| 'tune=ultra-low-latency' | |
| "gop-size=$gopSize" | |
| 'rc-lookahead=0' | |
| 'repeat-sequence-header=true' | |
| '!' | |
| 'h264parse' | |
| 'config-interval=-1' | |
| '!' | |
| "`"video/x-h264,profile=$profile,stream-format=byte-stream,alignment=au`"" | |
| ) -join ' ' | |
| $desktopEnabled = $chkDesktopAudio.Checked | |
| $micEnabled = $chkMic.Checked | |
| if (-not $desktopEnabled -and -not $micEnabled) { | |
| $pipeline = "$videoBranch ! whipclientsink video-caps=`"video/x-h264`" signaller::whip-endpoint=$quotedUrl" | |
| } | |
| elseif ($desktopEnabled -and -not $micEnabled) { | |
| $desktopBranch = @( | |
| 'wasapi2src' | |
| 'loopback=true' | |
| 'low-latency=true' | |
| '!' | |
| 'queue' | |
| 'max-size-buffers=4' | |
| 'max-size-bytes=0' | |
| 'max-size-time=0' | |
| 'leaky=downstream' | |
| '!' | |
| 'audioconvert' | |
| '!' | |
| 'audioresample' | |
| '!' | |
| '"audio/x-raw,format=S16LE,rate=48000,channels=2"' | |
| '!' | |
| 'volume' | |
| "volume=$desktopVolume" | |
| '!' | |
| 'opusenc' | |
| "bitrate=$audioBitrate" | |
| 'bitrate-type=cbr' | |
| 'frame-size=10' | |
| 'audio-type=restricted-lowdelay' | |
| '!' | |
| '"audio/x-opus"' | |
| '!' | |
| 'whip.audio_0' | |
| ) -join ' ' | |
| $pipeline = "whipclientsink name=whip video-caps=`"video/x-h264`" audio-caps=`"audio/x-opus`" signaller::whip-endpoint=$quotedUrl $videoBranch ! whip.video_0 $desktopBranch" | |
| } | |
| elseif (-not $desktopEnabled -and $micEnabled) { | |
| $micBranch = @( | |
| 'wasapi2src' | |
| 'low-latency=true' | |
| '!' | |
| 'queue' | |
| 'max-size-buffers=4' | |
| 'max-size-bytes=0' | |
| 'max-size-time=0' | |
| 'leaky=downstream' | |
| '!' | |
| 'audioconvert' | |
| '!' | |
| 'audioresample' | |
| '!' | |
| '"audio/x-raw,format=S16LE,rate=48000,channels=2"' | |
| '!' | |
| 'volume' | |
| "volume=$micVolume" | |
| '!' | |
| 'opusenc' | |
| "bitrate=$audioBitrate" | |
| 'bitrate-type=cbr' | |
| 'frame-size=10' | |
| 'audio-type=restricted-lowdelay' | |
| '!' | |
| '"audio/x-opus"' | |
| '!' | |
| 'whip.audio_0' | |
| ) -join ' ' | |
| $pipeline = "whipclientsink name=whip video-caps=`"video/x-h264`" audio-caps=`"audio/x-opus`" signaller::whip-endpoint=$quotedUrl $videoBranch ! whip.video_0 $micBranch" | |
| } | |
| else { | |
| $desktopMixBranch = @( | |
| 'wasapi2src' | |
| 'loopback=true' | |
| 'low-latency=true' | |
| '!' | |
| 'queue' | |
| 'max-size-buffers=8' | |
| 'max-size-bytes=0' | |
| 'max-size-time=0' | |
| 'leaky=downstream' | |
| '!' | |
| 'audioconvert' | |
| '!' | |
| 'audioresample' | |
| '!' | |
| '"audio/x-raw,format=F32LE,rate=48000,channels=2"' | |
| '!' | |
| 'volume' | |
| "volume=$desktopVolume" | |
| '!' | |
| 'mix.' | |
| ) -join ' ' | |
| $micMixBranch = @( | |
| 'wasapi2src' | |
| 'low-latency=true' | |
| '!' | |
| 'queue' | |
| 'max-size-buffers=8' | |
| 'max-size-bytes=0' | |
| 'max-size-time=0' | |
| 'leaky=downstream' | |
| '!' | |
| 'audioconvert' | |
| '!' | |
| 'audioresample' | |
| '!' | |
| '"audio/x-raw,format=F32LE,rate=48000,channels=2"' | |
| '!' | |
| 'volume' | |
| "volume=$micVolume" | |
| '!' | |
| 'mix.' | |
| ) -join ' ' | |
| $mixedOutput = @( | |
| 'mix.' | |
| '!' | |
| 'queue' | |
| 'max-size-buffers=8' | |
| 'max-size-bytes=0' | |
| 'max-size-time=0' | |
| 'leaky=downstream' | |
| '!' | |
| 'audioconvert' | |
| '!' | |
| '"audio/x-raw,format=S16LE,rate=48000,channels=2"' | |
| '!' | |
| 'opusenc' | |
| "bitrate=$audioBitrate" | |
| 'bitrate-type=cbr' | |
| 'frame-size=10' | |
| 'audio-type=restricted-lowdelay' | |
| '!' | |
| '"audio/x-opus"' | |
| '!' | |
| 'whip.audio_0' | |
| ) -join ' ' | |
| $pipeline = "whipclientsink name=whip video-caps=`"video/x-h264`" audio-caps=`"audio/x-opus`" signaller::whip-endpoint=$quotedUrl audiomixer name=mix $videoBranch ! whip.video_0 $desktopMixBranch $micMixBranch $mixedOutput" | |
| } | |
| $flags = '-e' | |
| if ($chkVerbose.Checked) { | |
| $flags += ' -v' | |
| } | |
| return "$flags $pipeline" | |
| } | |
| function Update-CommandPreview { | |
| try { | |
| $gstPath = $txtGstPath.Text.Trim() | |
| if ([string]::IsNullOrWhiteSpace($gstPath)) { | |
| $gstPath = 'gst-launch-1.0.exe' | |
| } | |
| $txtCommand.Text = '& ' + (Quote-GstValue $gstPath) + ' ' + (Build-GstArguments) | |
| } | |
| catch { | |
| $txtCommand.Text = "Unable to build command: $($_.Exception.Message)" | |
| } | |
| } | |
| function Save-Settings { | |
| try { | |
| if (-not (Test-Path -LiteralPath $script:ConfigDirectory)) { | |
| $null = New-Item -ItemType Directory -Path $script:ConfigDirectory -Force | |
| } | |
| $settings = [ordered]@{ | |
| GstPath = $txtGstPath.Text | |
| WhipUrl = $txtWhipUrl.Text | |
| MonitorIndex = [int]$numMonitor.Value | |
| ShowCursor = $chkCursor.Checked | |
| AutoRestart = $chkAutoRestart.Checked | |
| Verbose = $chkVerbose.Checked | |
| Width = [int]$numWidth.Value | |
| Height = [int]$numHeight.Value | |
| Fps = [int]$numFps.Value | |
| VideoBitrateKbps = [int]$numVideoBitrate.Value | |
| GopSeconds = [int]$numGopSeconds.Value | |
| Preset = [string]$cmbPreset.SelectedItem | |
| Profile = [string]$cmbProfile.SelectedItem | |
| DesktopAudio = $chkDesktopAudio.Checked | |
| DesktopVolume = [int]$numDesktopVolume.Value | |
| Microphone = $chkMic.Checked | |
| MicrophoneVolume = [int]$numMicVolume.Value | |
| AudioBitrateKbps = [int]$numAudioBitrate.Value | |
| } | |
| $settings | ConvertTo-Json | Set-Content -LiteralPath $script:ConfigPath -Encoding UTF8 | |
| } | |
| catch { | |
| Append-Log "Could not save settings: $($_.Exception.Message)" | |
| } | |
| } | |
| function Load-Settings { | |
| if (-not (Test-Path -LiteralPath $script:ConfigPath)) { | |
| return | |
| } | |
| try { | |
| $settings = Get-Content -LiteralPath $script:ConfigPath -Raw | ConvertFrom-Json | |
| if ($settings.GstPath) { $txtGstPath.Text = [string]$settings.GstPath } | |
| if ($settings.WhipUrl) { $txtWhipUrl.Text = [string]$settings.WhipUrl } | |
| if ($null -ne $settings.MonitorIndex) { $numMonitor.Value = [decimal]$settings.MonitorIndex } | |
| if ($null -ne $settings.ShowCursor) { $chkCursor.Checked = [bool]$settings.ShowCursor } | |
| if ($null -ne $settings.AutoRestart) { $chkAutoRestart.Checked = [bool]$settings.AutoRestart } | |
| if ($null -ne $settings.Verbose) { $chkVerbose.Checked = [bool]$settings.Verbose } | |
| if ($settings.Width) { $numWidth.Value = [decimal]$settings.Width } | |
| if ($settings.Height) { $numHeight.Value = [decimal]$settings.Height } | |
| if ($settings.Fps) { $numFps.Value = [decimal]$settings.Fps } | |
| if ($settings.VideoBitrateKbps) { $numVideoBitrate.Value = [decimal]$settings.VideoBitrateKbps } | |
| if ($settings.GopSeconds) { $numGopSeconds.Value = [decimal]$settings.GopSeconds } | |
| if ($settings.Preset -and $cmbPreset.Items.Contains([string]$settings.Preset)) { $cmbPreset.SelectedItem = [string]$settings.Preset } | |
| if ($settings.Profile -and $cmbProfile.Items.Contains([string]$settings.Profile)) { $cmbProfile.SelectedItem = [string]$settings.Profile } | |
| if ($null -ne $settings.DesktopAudio) { $chkDesktopAudio.Checked = [bool]$settings.DesktopAudio } | |
| if ($null -ne $settings.DesktopVolume) { $numDesktopVolume.Value = [decimal]$settings.DesktopVolume } | |
| if ($null -ne $settings.Microphone) { $chkMic.Checked = [bool]$settings.Microphone } | |
| if ($null -ne $settings.MicrophoneVolume) { $numMicVolume.Value = [decimal]$settings.MicrophoneVolume } | |
| if ($settings.AudioBitrateKbps) { $numAudioBitrate.Value = [decimal]$settings.AudioBitrateKbps } | |
| } | |
| catch { | |
| Append-Log "Could not load settings: $($_.Exception.Message)" | |
| } | |
| } | |
| function Validate-Configuration { | |
| $gstPath = $txtGstPath.Text.Trim() | |
| if ([string]::IsNullOrWhiteSpace($gstPath) -or -not (Test-Path -LiteralPath $gstPath)) { | |
| [System.Windows.Forms.MessageBox]::Show( | |
| 'Select a valid gst-launch-1.0.exe path.', | |
| $script:AppName, | |
| 'OK', | |
| 'Warning' | |
| ) | Out-Null | |
| return $false | |
| } | |
| $url = $txtWhipUrl.Text.Trim() | |
| if ($url -notmatch '^https?://') { | |
| [System.Windows.Forms.MessageBox]::Show( | |
| 'The WHIP endpoint must begin with http:// or https://.', | |
| $script:AppName, | |
| 'OK', | |
| 'Warning' | |
| ) | Out-Null | |
| return $false | |
| } | |
| return $true | |
| } | |
| function Start-GstStream { | |
| if ($script:GstProcess -and -not $script:GstProcess.HasExited) { | |
| return | |
| } | |
| if (-not (Validate-Configuration)) { | |
| return | |
| } | |
| Save-Settings | |
| if (-not (Test-Path -LiteralPath $script:LogDirectory)) { | |
| $null = New-Item -ItemType Directory -Path $script:LogDirectory -Force | |
| } | |
| $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' | |
| $script:StdOutPath = Join-Path $script:LogDirectory "gst-$stamp-out.log" | |
| $script:StdErrPath = Join-Path $script:LogDirectory "gst-$stamp-err.log" | |
| $script:StdOutPosition = [int64]0 | |
| $script:StdErrPosition = [int64]0 | |
| $script:StopRequested = $false | |
| $script:RestartAt = $null | |
| $gstPath = $txtGstPath.Text.Trim() | |
| $arguments = Build-GstArguments | |
| Append-Log "[$(Get-Date -Format 'HH:mm:ss')] Starting full GStreamer pipeline..." | |
| Append-Log "Executable: $gstPath" | |
| Append-Log "Arguments: $arguments" | |
| try { | |
| $script:GstProcess = Start-Process -FilePath $gstPath -ArgumentList $arguments -RedirectStandardOutput $script:StdOutPath -RedirectStandardError $script:StdErrPath -WindowStyle Hidden -PassThru | |
| $statusLabel.Text = "Streaming — PID $($script:GstProcess.Id)" | |
| $statusLabel.ForeColor = [System.Drawing.Color]::DarkGreen | |
| Set-RunState $true | |
| } | |
| catch { | |
| $script:GstProcess = $null | |
| $statusLabel.Text = 'Start failed' | |
| $statusLabel.ForeColor = [System.Drawing.Color]::DarkRed | |
| Set-RunState $false | |
| Append-Log "START ERROR: $($_.Exception.Message)" | |
| } | |
| } | |
| function Stop-GstStream { | |
| param([switch]$Restart) | |
| $script:StopRequested = $true | |
| if ($Restart) { | |
| $script:RestartAt = (Get-Date).AddMilliseconds(800) | |
| } | |
| else { | |
| $script:RestartAt = $null | |
| } | |
| if ($script:GstProcess -and -not $script:GstProcess.HasExited) { | |
| $statusLabel.Text = 'Stopping...' | |
| $statusLabel.ForeColor = [System.Drawing.Color]::DarkOrange | |
| Append-Log "[$(Get-Date -Format 'HH:mm:ss')] Stopping complete process tree..." | |
| try { | |
| $taskKillArguments = "/PID $($script:GstProcess.Id) /T /F" | |
| $null = Start-Process -FilePath 'taskkill.exe' -ArgumentList $taskKillArguments -WindowStyle Hidden -Wait -PassThru | |
| } | |
| catch { | |
| try { | |
| $script:GstProcess.Kill() | |
| } | |
| catch { | |
| Append-Log "STOP ERROR: $($_.Exception.Message)" | |
| } | |
| } | |
| } | |
| elseif ($Restart) { | |
| $script:GstProcess = $null | |
| } | |
| else { | |
| $script:GstProcess = $null | |
| $statusLabel.Text = 'Stopped' | |
| $statusLabel.ForeColor = [System.Drawing.Color]::Black | |
| Set-RunState $false | |
| } | |
| } | |
| function Test-GStreamerElements { | |
| $gstPath = $txtGstPath.Text.Trim() | |
| if ([string]::IsNullOrWhiteSpace($gstPath) -or -not (Test-Path -LiteralPath $gstPath)) { | |
| [System.Windows.Forms.MessageBox]::Show('Select a valid gst-launch-1.0.exe first.', $script:AppName, 'OK', 'Warning') | Out-Null | |
| return | |
| } | |
| $inspectPath = Join-Path (Split-Path -Parent $gstPath) 'gst-inspect-1.0.exe' | |
| if (-not (Test-Path -LiteralPath $inspectPath)) { | |
| [System.Windows.Forms.MessageBox]::Show('gst-inspect-1.0.exe was not found beside gst-launch-1.0.exe.', $script:AppName, 'OK', 'Warning') | Out-Null | |
| return | |
| } | |
| $elements = @( | |
| 'd3d11screencapturesrc', | |
| 'd3d11convert', | |
| 'nvd3d11h264enc', | |
| 'h264parse', | |
| 'whipclientsink', | |
| 'wasapi2src', | |
| 'audioconvert', | |
| 'audioresample', | |
| 'opusenc', | |
| 'audiomixer', | |
| 'volume' | |
| ) | |
| $missing = New-Object System.Collections.Generic.List[string] | |
| $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor | |
| try { | |
| foreach ($element in $elements) { | |
| & $inspectPath $element *> $null | |
| if ($LASTEXITCODE -ne 0) { | |
| $missing.Add($element) | |
| } | |
| } | |
| } | |
| finally { | |
| $form.Cursor = [System.Windows.Forms.Cursors]::Default | |
| } | |
| if ($missing.Count -eq 0) { | |
| [System.Windows.Forms.MessageBox]::Show('All required GStreamer elements were found.', $script:AppName, 'OK', 'Information') | Out-Null | |
| } | |
| else { | |
| [System.Windows.Forms.MessageBox]::Show( | |
| "Missing GStreamer elements:`r`n`r`n$($missing -join "`r`n")", | |
| $script:AppName, | |
| 'OK', | |
| 'Error' | |
| ) | Out-Null | |
| } | |
| } | |
| $previewHandler = { | |
| Update-CommandPreview | |
| } | |
| $txtGstPath.Add_TextChanged($previewHandler) | |
| $txtWhipUrl.Add_TextChanged($previewHandler) | |
| $numMonitor.Add_ValueChanged($previewHandler) | |
| $chkCursor.Add_CheckedChanged($previewHandler) | |
| $chkAutoRestart.Add_CheckedChanged($previewHandler) | |
| $chkVerbose.Add_CheckedChanged($previewHandler) | |
| $numWidth.Add_ValueChanged($previewHandler) | |
| $numHeight.Add_ValueChanged($previewHandler) | |
| $numFps.Add_ValueChanged($previewHandler) | |
| $numVideoBitrate.Add_ValueChanged($previewHandler) | |
| $numGopSeconds.Add_ValueChanged($previewHandler) | |
| $cmbPreset.Add_SelectedIndexChanged($previewHandler) | |
| $cmbProfile.Add_SelectedIndexChanged($previewHandler) | |
| $chkDesktopAudio.Add_CheckedChanged($previewHandler) | |
| $numDesktopVolume.Add_ValueChanged($previewHandler) | |
| $chkMic.Add_CheckedChanged($previewHandler) | |
| $numMicVolume.Add_ValueChanged($previewHandler) | |
| $numAudioBitrate.Add_ValueChanged($previewHandler) | |
| $btnBrowseGst.Add_Click({ | |
| $dialog = New-Object System.Windows.Forms.OpenFileDialog | |
| $dialog.Title = 'Select gst-launch-1.0.exe' | |
| $dialog.Filter = 'GStreamer launcher (gst-launch-1.0.exe)|gst-launch-1.0.exe|Executable files (*.exe)|*.exe|All files (*.*)|*.*' | |
| if ($txtGstPath.Text -and (Test-Path -LiteralPath $txtGstPath.Text)) { | |
| $dialog.InitialDirectory = Split-Path -Parent $txtGstPath.Text | |
| $dialog.FileName = Split-Path -Leaf $txtGstPath.Text | |
| } | |
| if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { | |
| $txtGstPath.Text = $dialog.FileName | |
| } | |
| $dialog.Dispose() | |
| }) | |
| $btnCheckGst.Add_Click({ Test-GStreamerElements }) | |
| $btnStart.Add_Click({ Start-GstStream }) | |
| $btnStop.Add_Click({ Stop-GstStream }) | |
| $btnRestart.Add_Click({ Stop-GstStream -Restart }) | |
| $btnCopyCommand.Add_Click({ | |
| try { | |
| [System.Windows.Forms.Clipboard]::SetText($txtCommand.Text) | |
| $statusLabel.Text = 'Command copied' | |
| $statusLabel.ForeColor = [System.Drawing.Color]::DarkBlue | |
| } | |
| catch { | |
| Append-Log "Clipboard error: $($_.Exception.Message)" | |
| } | |
| }) | |
| $btnClearLog.Add_Click({ $txtLog.Clear() }) | |
| $pollTimer = New-Object System.Windows.Forms.Timer | |
| $pollTimer.Interval = 400 | |
| $pollTimer.Add_Tick({ | |
| $stdoutText = Read-NewLogText -Path $script:StdOutPath -Position ([ref]$script:StdOutPosition) | |
| if ($stdoutText) { Append-Log $stdoutText } | |
| $stderrText = Read-NewLogText -Path $script:StdErrPath -Position ([ref]$script:StdErrPosition) | |
| if ($stderrText) { Append-Log $stderrText } | |
| if ($script:GstProcess -and $script:GstProcess.HasExited) { | |
| $exitCode = $script:GstProcess.ExitCode | |
| $wasRequested = $script:StopRequested | |
| try { $script:GstProcess.Dispose() } catch {} | |
| $script:GstProcess = $null | |
| Set-RunState $false | |
| if ($wasRequested) { | |
| $statusLabel.Text = 'Stopped' | |
| $statusLabel.ForeColor = [System.Drawing.Color]::Black | |
| Append-Log "[$(Get-Date -Format 'HH:mm:ss')] Pipeline stopped." | |
| } | |
| else { | |
| $statusLabel.Text = "Pipeline exited — code $exitCode" | |
| $statusLabel.ForeColor = [System.Drawing.Color]::DarkRed | |
| Append-Log "[$(Get-Date -Format 'HH:mm:ss')] Pipeline exited unexpectedly with code $exitCode." | |
| if ($chkAutoRestart.Checked) { | |
| $script:RestartAt = (Get-Date).AddSeconds(2) | |
| Append-Log 'Automatic full restart scheduled in 2 seconds.' | |
| } | |
| } | |
| $script:StopRequested = $false | |
| } | |
| if (-not $script:GstProcess -and $script:RestartAt -and (Get-Date) -ge $script:RestartAt) { | |
| $script:RestartAt = $null | |
| Start-GstStream | |
| } | |
| }) | |
| $pollTimer.Start() | |
| $form.Add_Shown({ | |
| Load-Settings | |
| Update-CommandPreview | |
| }) | |
| $form.Add_FormClosing({ | |
| Save-Settings | |
| $chkAutoRestart.Checked = $false | |
| $script:RestartAt = $null | |
| if ($script:GstProcess -and -not $script:GstProcess.HasExited) { | |
| Stop-GstStream | |
| } | |
| $pollTimer.Stop() | |
| }) | |
| [void]$form.ShowDialog() |
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
| @echo off | |
| powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -File "%~dp0GStreamer-Basic-WHIP-GUI.ps1" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment