259 lines
6.1 KiB
Go
259 lines
6.1 KiB
Go
//go:build overlord_webrtc
|
|
|
|
package webrtcpub
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/pion/rtcp"
|
|
"github.com/pion/webrtc/v4"
|
|
"github.com/pion/webrtc/v4/pkg/media"
|
|
)
|
|
|
|
func drainRTCP(sender *webrtc.RTPSender) {
|
|
buf := make([]byte, 1500)
|
|
for {
|
|
n, _, err := sender.Read(buf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
packets, err := rtcp.Unmarshal(buf[:n])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, p := range packets {
|
|
switch p.(type) {
|
|
case *rtcp.PictureLossIndication, *rtcp.FullIntraRequest:
|
|
RequestKeyframe()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type trackWriter struct {
|
|
t *webrtc.TrackLocalStaticSample
|
|
}
|
|
|
|
func (w *trackWriter) WriteH264(nalu []byte, dur time.Duration) error {
|
|
return w.t.WriteSample(media.Sample{Data: nalu, Duration: dur})
|
|
}
|
|
|
|
const whipWriterID = "whip"
|
|
|
|
type Publisher struct {
|
|
pc *webrtc.PeerConnection
|
|
resourceURL string
|
|
token string
|
|
httpClient *http.Client
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
var (
|
|
whipMu sync.Mutex
|
|
whip *Publisher
|
|
)
|
|
|
|
func Start(ctx context.Context, opts Options) (*Publisher, error) {
|
|
if strings.TrimSpace(opts.WhipURL) == "" {
|
|
return nil, errors.New("webrtcpub: empty WhipURL")
|
|
}
|
|
if strings.TrimSpace(opts.PublishToken) == "" {
|
|
return nil, errors.New("webrtcpub: empty PublishToken")
|
|
}
|
|
Stop()
|
|
|
|
httpClient := buildHTTPClient(opts)
|
|
|
|
mediaEngine := &webrtc.MediaEngine{}
|
|
if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
|
|
RTPCodecCapability: webrtc.RTPCodecCapability{
|
|
MimeType: webrtc.MimeTypeH264,
|
|
ClockRate: 90000,
|
|
SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
|
},
|
|
PayloadType: 102,
|
|
}, webrtc.RTPCodecTypeVideo); err != nil {
|
|
return nil, fmt.Errorf("register codec: %w", err)
|
|
}
|
|
|
|
api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine))
|
|
pc, err := api.NewPeerConnection(webrtc.Configuration{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("new peer connection: %w", err)
|
|
}
|
|
|
|
track, err := webrtc.NewTrackLocalStaticSample(
|
|
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264},
|
|
"overlord-video", "overlord-desktop",
|
|
)
|
|
if err != nil {
|
|
_ = pc.Close()
|
|
return nil, fmt.Errorf("new track: %w", err)
|
|
}
|
|
transceiver, err := pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{
|
|
Direction: webrtc.RTPTransceiverDirectionSendonly,
|
|
})
|
|
if err != nil {
|
|
_ = pc.Close()
|
|
return nil, fmt.Errorf("add transceiver: %w", err)
|
|
}
|
|
if sender := transceiver.Sender(); sender != nil {
|
|
go drainRTCP(sender)
|
|
}
|
|
|
|
offer, err := pc.CreateOffer(nil)
|
|
if err != nil {
|
|
_ = pc.Close()
|
|
return nil, fmt.Errorf("create offer: %w", err)
|
|
}
|
|
gathered := webrtc.GatheringCompletePromise(pc)
|
|
if err := pc.SetLocalDescription(offer); err != nil {
|
|
_ = pc.Close()
|
|
return nil, fmt.Errorf("set local desc: %w", err)
|
|
}
|
|
|
|
select {
|
|
case <-gathered:
|
|
case <-time.After(5 * time.Second):
|
|
log.Printf("webrtcpub: ICE gathering timeout; continuing with partial candidates")
|
|
case <-ctx.Done():
|
|
_ = pc.Close()
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
answerSDP, resourceURL, err := postWhip(ctx, httpClient, opts.WhipURL, opts.PublishToken, pc.LocalDescription().SDP)
|
|
if err != nil {
|
|
_ = pc.Close()
|
|
return nil, err
|
|
}
|
|
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
|
Type: webrtc.SDPTypeAnswer,
|
|
SDP: answerSDP,
|
|
}); err != nil {
|
|
_ = pc.Close()
|
|
return nil, fmt.Errorf("set remote desc: %w", err)
|
|
}
|
|
|
|
pub := &Publisher{
|
|
pc: pc,
|
|
resourceURL: resourceURL,
|
|
token: opts.PublishToken,
|
|
httpClient: httpClient,
|
|
}
|
|
|
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
|
log.Printf("webrtcpub: WHIP peer state=%s", state)
|
|
switch state {
|
|
case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed:
|
|
whipMu.Lock()
|
|
if whip == pub {
|
|
whip = nil
|
|
}
|
|
whipMu.Unlock()
|
|
unregisterWriter(whipWriterID)
|
|
}
|
|
})
|
|
|
|
whipMu.Lock()
|
|
whip = pub
|
|
whipMu.Unlock()
|
|
registerWriter(whipWriterID, &trackWriter{t: track})
|
|
log.Printf("webrtcpub: WHIP session established (resource=%s)", resourceURL)
|
|
return pub, nil
|
|
}
|
|
|
|
func Stop() {
|
|
whipMu.Lock()
|
|
p := whip
|
|
whip = nil
|
|
whipMu.Unlock()
|
|
unregisterWriter(whipWriterID)
|
|
if p != nil {
|
|
p.Close()
|
|
}
|
|
}
|
|
|
|
func (p *Publisher) Close() {
|
|
p.closeOnce.Do(func() {
|
|
if p.resourceURL != "" {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, p.resourceURL, nil)
|
|
if err == nil {
|
|
if p.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+p.token)
|
|
}
|
|
if resp, err := p.httpClient.Do(req); err == nil {
|
|
_ = resp.Body.Close()
|
|
}
|
|
}
|
|
}
|
|
_ = p.pc.Close()
|
|
})
|
|
}
|
|
|
|
func postWhip(ctx context.Context, client *http.Client, whipURL, token, sdp string) (string, string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, whipURL, strings.NewReader(sdp))
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("build whip request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/sdp")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("whip post: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode/100 != 2 {
|
|
return "", "", fmt.Errorf("whip post: %s: %s", resp.Status, bytes.TrimSpace(body))
|
|
}
|
|
|
|
resource := resp.Header.Get("Location")
|
|
if resource != "" {
|
|
if parsed, err := url.Parse(resource); err == nil && !parsed.IsAbs() {
|
|
base, _ := url.Parse(whipURL)
|
|
if base != nil {
|
|
resource = base.ResolveReference(parsed).String()
|
|
}
|
|
}
|
|
}
|
|
return string(body), resource, nil
|
|
}
|
|
|
|
func buildHTTPClient(opts Options) *http.Client {
|
|
tlsCfg := &tls.Config{
|
|
InsecureSkipVerify: opts.TLSInsecureSkipVerify,
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
if path := strings.TrimSpace(opts.TLSCAPath); path != "" {
|
|
if pem, err := os.ReadFile(path); err == nil {
|
|
pool := x509.NewCertPool()
|
|
if pool.AppendCertsFromPEM(pem) {
|
|
tlsCfg.RootCAs = pool
|
|
}
|
|
}
|
|
}
|
|
return &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: tlsCfg,
|
|
},
|
|
}
|
|
}
|