feat: enhance WebRTC pipeline with improved logging and error handling

This commit is contained in:
gari 2025-04-13 12:47:47 +02:00
parent 7147f3f13b
commit f103ddb7a8
5 changed files with 88 additions and 70 deletions

View file

@ -95,24 +95,24 @@ func (api *API) previewHandler(c echo.Context) error {
// on-negotiation-needed: Triggered when webrtcbin needs to create an offer or answer. // on-negotiation-needed: Triggered when webrtcbin needs to create an offer or answer.
// In our case (server receiving an offer), this fires after the remote description is set. // In our case (server receiving an offer), this fires after the remote description is set.
previewPipeline.webrtcbin.Connect("on-negotiation-needed", func(self *gst.Element) { if _, err := previewPipeline.webrtcbin.Connect("on-negotiation-needed", func(self *gst.Element) {
log.Info().Msg("Negotiation needed, creating answer") log.Info().Msg("Negotiation needed, creating answer")
// Create Answer // Create Answer
promise, err := self.Emit("create-answer") promise := gst.NewPromise()
_, err := self.Emit("create-answer", nil, promise)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to emit create-answer") log.Error().Err(err).Msg("Failed to emit create-answer")
return return
} }
if promise == nil {
log.Error().Msg("Emit create-answer returned nil promise")
return
}
// Handle the promise result (asynchronously) // Handle the promise result (asynchronously)
promise.(*gst.Promise).Interrupt() // Interrupt any previous waits promise.Interrupt() // Interrupt any previous waits
promise.(*gst.Promise).Await(c.Request().Context()) // Wait for the answer to be created reply, err := promise.Await(c.Request().Context()) // Wait for the answer to be created
reply := promise.(*gst.Promise).GetReply() if err != nil {
log.Error().Err(err).Msg("Failed to await create-answer promise")
return
}
if reply == nil { if reply == nil {
log.Error().Msg("Promise reply for create-answer was nil") log.Error().Msg("Promise reply for create-answer was nil")
return return
@ -132,16 +132,14 @@ func (api *API) previewHandler(c echo.Context) error {
log.Debug().Str("sdp", answer.SDP().String()).Msg("Answer created") log.Debug().Str("sdp", answer.SDP().String()).Msg("Answer created")
// Set Local Description // Set Local Description
promise, err = self.Emit("set-local-description", answer) descPromise := gst.NewPromise()
_, err = self.Emit("set-local-description", answer, descPromise)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to emit set-local-description") log.Error().Err(err).Msg("Failed to emit set-local-description")
return return
} }
if promise == nil {
log.Error().Msg("Emit set-local-description returned nil promise") descPromise.Interrupt() // Interrupt any previous waits
return
}
promise.(*gst.Promise).Interrupt() // Interrupt any previous waits
log.Info().Msg("Set local description (answer)") log.Info().Msg("Set local description (answer)")
@ -159,10 +157,12 @@ func (api *API) previewHandler(c echo.Context) error {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
log.Error().Err(err).Msg("Failed to send answer over WebSocket") log.Error().Err(err).Msg("Failed to send answer over WebSocket")
} }
}) }); err != nil {
log.Error().Err(err).Msg("Failed to connect on-negotiation-needed signal")
}
// on-ice-candidate: Triggered when webrtcbin generates a new ICE candidate. // on-ice-candidate: Triggered when webrtcbin generates a new ICE candidate.
previewPipeline.webrtcbin.Connect("on-ice-candidate", func(self *gst.Element, mlineindex uint, candidate string) { if _, err := previewPipeline.webrtcbin.Connect("on-ice-candidate", func(self *gst.Element, mlineindex uint, candidate string, _ any) {
log.Debug().Uint("mlineindex", mlineindex).Str("candidate", candidate).Msg("🧊 Generated ICE candidate") log.Debug().Uint("mlineindex", mlineindex).Str("candidate", candidate).Msg("🧊 Generated ICE candidate")
// Note: The 'sdpMid' is often derived from the mlineindex or context, // Note: The 'sdpMid' is often derived from the mlineindex or context,
// but the signal itself only provides mlineindex and candidate string directly. // but the signal itself only provides mlineindex and candidate string directly.
@ -183,7 +183,9 @@ func (api *API) previewHandler(c echo.Context) error {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
log.Error().Err(err).Msg("Failed to send ICE candidate over WebSocket") log.Error().Err(err).Msg("Failed to send ICE candidate over WebSocket")
} }
}) }); err != nil {
log.Error().Err(err).Msg("Failed to connect on-ice-candidate signal")
}
// --- WebSocket Message Loop --- // --- WebSocket Message Loop ---
for { for {
@ -454,7 +456,6 @@ func (api *API) createPreviewPipeline(d Device) (*PreviewPipeline, error) {
} }
log.Info().Str("pipeline", pipeline.GetName()).Msg("Preview pipeline state set to PLAYING") log.Info().Str("pipeline", pipeline.GetName()).Msg("Preview pipeline state set to PLAYING")
return previewPipeline, nil return previewPipeline, nil
} }

View file

@ -2,23 +2,8 @@ package gstreamer
import ( import (
"github.com/go-gst/go-gst/gst" "github.com/go-gst/go-gst/gst"
"github.com/goccy/go-graphviz/cgraph"
) )
func gstreamerBinToDot(bin *gst.Bin) string { func gstreamerBinToDot(bin *gst.Bin) string {
return bin.DebugBinToDotData(gst.DebugGraphShowAll) return bin.DebugBinToDotData(gst.DebugGraphShowAll)
} }
func setFontSize(graph *cgraph.Graph, fontSize float64) {
graph.SetFontSize(20)
graph.Set("arrowSize", "3")
for node, err := graph.FirstNode(); err == nil && node != nil; node, err = graph.NextNode(node) {
node.SetFontSize(fontSize)
for edge, err := graph.FirstEdge(node); err == nil && edge != nil; edge, err = graph.NextEdge(edge, node) {
edge.SetFontSize(fontSize)
}
}
//for subGraph, err := graph.FirstSubGraph(); err == nil && subGraph != nil; subGraph, err = graph.NextSubGraph() {
// setFontSize(subGraph, fontSize)
//}
}

View file

@ -5,6 +5,8 @@ import (
"github.com/go-gst/go-gst/gst" "github.com/go-gst/go-gst/gst"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"os"
"path/filepath"
"sync" "sync"
) )
@ -92,6 +94,8 @@ func (d *Device) createPipeline() error {
d.Lock() d.Lock()
defer d.Unlock() defer d.Unlock()
log.Debug().Str("name", d.name).Msg("Creating pipeline for device")
pipeline, err := gst.NewPipeline("ndi-" + d.name) pipeline, err := gst.NewPipeline("ndi-" + d.name)
if err != nil { if err != nil {
return fmt.Errorf("creating pipeline: %w", err) return fmt.Errorf("creating pipeline: %w", err)
@ -99,6 +103,10 @@ func (d *Device) createPipeline() error {
pipeline.ForceClock(gst.ObtainSystemClock().Clock) pipeline.ForceClock(gst.ObtainSystemClock().Clock)
input := d.gstDevice.CreateElement("") input := d.gstDevice.CreateElement("")
if err := pipeline.Add(input); err != nil {
return fmt.Errorf("adding input to pipeline: %w", err)
}
inputQueue, err := gst.NewElementWithProperties("queue2", inputQueue, err := gst.NewElementWithProperties("queue2",
map[string]interface{}{ map[string]interface{}{
"name": "input-queue", "name": "input-queue",
@ -106,6 +114,13 @@ func (d *Device) createPipeline() error {
"max-size-bytes": 0, "max-size-bytes": 0,
"max-size-time": 40000000, // 40ms "max-size-time": 40000000, // 40ms
}) })
if err != nil {
return fmt.Errorf("creating input-queue: %w", err)
}
if err := pipeline.Add(inputQueue); err != nil {
return fmt.Errorf("adding input-queue to pipeline: %w", err)
}
if err := input.Link(inputQueue); err != nil { if err := input.Link(inputQueue); err != nil {
return fmt.Errorf("linking input to input input-queue: %w", err) return fmt.Errorf("linking input to input input-queue: %w", err)
} }
@ -114,6 +129,9 @@ func (d *Device) createPipeline() error {
if err != nil { if err != nil {
return fmt.Errorf("creating ndisrcdemux: %w", err) return fmt.Errorf("creating ndisrcdemux: %w", err)
} }
if err := pipeline.Add(ndiDemuxer); err != nil {
return fmt.Errorf("adding ndisrcdemux to pipeline: %w", err)
}
if err := inputQueue.Link(ndiDemuxer); err != nil { if err := inputQueue.Link(ndiDemuxer); err != nil {
return fmt.Errorf("linking input-queue to ndisrcdemux: %w", err) return fmt.Errorf("linking input-queue to ndisrcdemux: %w", err)
@ -123,14 +141,16 @@ func (d *Device) createPipeline() error {
if err != nil { if err != nil {
return fmt.Errorf("creating video-tee: %w", err) return fmt.Errorf("creating video-tee: %w", err)
} }
if err := pipeline.Add(videoTee); err != nil {
return fmt.Errorf("adding video-tee to pipeline: %w", err)
}
audioTee, err := gst.NewElementWithName("tee", "audio-tee") audioTee, err := gst.NewElementWithName("tee", "audio-tee")
if err != nil { if err != nil {
return fmt.Errorf("creating audio-tee: %w", err) return fmt.Errorf("creating audio-tee: %w", err)
} }
if err := pipeline.Add(audioTee); err != nil {
if err := pipeline.AddMany(input, ndiDemuxer, videoTee, audioTee); err != nil { return fmt.Errorf("adding audio-tee to pipeline: %w", err)
return fmt.Errorf("adding elements to pipeline: %w", err)
} }
d.pipeline = pipeline d.pipeline = pipeline
@ -140,6 +160,18 @@ func (d *Device) createPipeline() error {
if _, err := ndiDemuxer.Connect("pad-added", d.onNdiSrcDemuxerPadAdded); err != nil { if _, err := ndiDemuxer.Connect("pad-added", d.onNdiSrcDemuxerPadAdded); err != nil {
return fmt.Errorf("connecting pad-added signal: %w", err) return fmt.Errorf("connecting pad-added signal: %w", err)
} }
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current working directory: %w", err)
}
log.Debug().Str("cwd", cwd).Msg("dumping pipeline to dot file")
filename := filepath.Join(cwd, "dumpDot", "ndi-pipeline.dot")
dot := pipeline.DebugBinToDotData(gst.DebugGraphShowAll)
if err := os.WriteFile(filename, []byte(dot), 0644); err != nil {
return fmt.Errorf("writing dot file: %w", err)
}
log.Debug().Str("name", d.name).Msg("Pipeline created")
return nil return nil
} }
@ -244,40 +276,10 @@ func (d *Device) setPipelineState(state gst.State) error {
} }
log.Info().Str("pipeline", d.pipeline.GetName()).Str("target_state", state.String()).Msg("Setting pipeline state") log.Info().Str("pipeline", d.pipeline.GetName()).Str("target_state", state.String()).Msg("Setting pipeline state")
stateChangeReturn := d.pipeline.SetState(state) if err := d.pipeline.SetState(state); err != nil {
if stateChangeReturn == gst.StateChangeFailure { log.Error().Str("pipeline", d.pipeline.GetName()).Str("target_state", state.String()).Err(err).Msg("SetState failed")
log.Error().Str("pipeline", d.pipeline.GetName()).Str("target_state", state.String()).Msg("SetState returned failure") return fmt.Errorf("setting pipeline state to %s: %w", state.String(), err)
return fmt.Errorf("setting pipeline state to %s failed", state.String())
} }
if stateChangeReturn == gst.StateChangeNoPreroll {
log.Warn().Str("pipeline", d.pipeline.GetName()).Str("target_state", state.String()).Msg("SetState returned no-preroll")
// No-preroll might be acceptable depending on the pipeline, especially for live sources.
}
// Wait briefly for the state change to complete, especially important for PLAYING.
// Use a reasonable timeout (e.g., 2 seconds)
_, currentState, pendingState := d.pipeline.GetState(2 * gst.Second)
log.Debug().
Str("pipeline", d.pipeline.GetName()).
Str("target_state", state.String()).
Str("current_state", currentState.String()).
Str("pending_state", pendingState.String()).
Msg("Pipeline state after SetState attempt")
if currentState != state && pendingState != state {
// If neither current nor pending matches the target state after timeout, log a warning.
// This might indicate a problem, but we won't return an error yet,
// as some state transitions might take longer or settle asynchronously.
log.Warn().
Str("pipeline", d.pipeline.GetName()).
Str("target_state", state.String()).
Str("actual_state", currentState.String()).
Msg("Pipeline did not reach target state within timeout")
} else {
log.Info().Str("pipeline", d.pipeline.GetName()).Str("reached_state", state.String()).Msg("Pipeline state change successful or pending")
}
return nil return nil
} }

15
webRTC-test/index.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC Video Preview</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>WebRTC Video Stream</h1>
<video id="video" autoplay playsinline></video>
<script src="script.js"></script>
</body>
</html>

15
webRTC-test/style.css Normal file
View file

@ -0,0 +1,15 @@
body {
font-family: Arial, sans-serif;
text-align: center;
}
h1 {
color: #4CAF50;
}
#video {
width: 100%;
max-width: 600px;
border: 1px solid #ddd;
margin-top: 20px;
}