diff --git a/README.md b/README.md index 6c6c766c..7a2500b3 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,8 @@ lk room join --identity bot \ You should now see both video and audio tracks published to the room. +Note: To publish H.265/HEVC over sockets, use the `h265://` scheme (for example, `h265:///tmp/myvideo.sock` or `h265://127.0.0.1:16400`). Ensure your LiveKit deployment and clients support H.265 playback. + ### Publish from TCP (i.e. gstreamer) It's possible to publish from video streams coming over a TCP socket. `lk` can act as a TCP client. For example, with a gstreamer pipeline ending in `! tcpserversink port=16400` and streaming H.264. @@ -204,11 +206,11 @@ lk room join \ ``` -### Publish H.264 simulcast track from TCP +### Publish H.264/H.265 simulcast track from TCP -You can publish multiple H.264 video tracks from different TCP ports as a single [Simulcast](https://docs.livekit.io/home/client/tracks/advanced/#video-simulcast) track. This is done by using multiple `--publish` flags. +You can publish multiple H.264 or H.265 video tracks from different TCP ports as a single [Simulcast](https://docs.livekit.io/home/client/tracks/advanced/#video-simulcast) track. This is done by using multiple `--publish` flags. -The track will be published in simulcast mode if multiple `--publish` flags with the syntax `h264://:/x` are passed in as arguments. +The track will be published in simulcast mode if multiple `--publish` flags with the syntax `://:/x` are passed in as arguments, where `` is `h264` or `h265`. All layers must use the same codec. Example: @@ -239,7 +241,7 @@ lk room join --identity --url "" --api-key "" --api-secret "://' or '://' respectively. Valid codecs are \"h264\", \"vp8\", \"opus\"", + "can publish from Unix or TCP socket using the format '://' or '://' respectively. Valid codecs are \"h264\", \"h265\", \"vp8\", \"opus\"", }, &cli.FloatFlag{ Name: "fps", @@ -297,7 +297,7 @@ func parseSocketFromName(name string) (string, string, string, error) { mimeType := name[:offset] - if mimeType != "h264" && mimeType != "vp8" && mimeType != "opus" { + if mimeType != "h264" && mimeType != "h265" && mimeType != "vp8" && mimeType != "opus" { return "", "", "", fmt.Errorf("unsupported mime type: %s", mimeType) } @@ -331,6 +331,8 @@ func publishSocket(room *lksdk.Room, switch { case strings.Contains(mimeType, "h264"): mime = webrtc.MimeTypeH264 + case strings.Contains(mimeType, "h265"): + mime = webrtc.MimeTypeH265 case strings.Contains(mimeType, "vp8"): mime = webrtc.MimeTypeVP8 case strings.Contains(mimeType, "opus"): @@ -397,20 +399,22 @@ func publishReader(room *lksdk.Room, // simulcastURLParts represents the parsed components of a simulcast URL type simulcastURLParts struct { + codec string // "h264" or "h265" network string // "tcp" or "unix" address string width uint32 height uint32 } -// parseSimulcastURL validates and parses a simulcast URL in the format h264:///x or h264:///x +// parseSimulcastURL validates and parses a simulcast URL in the format :///x or :///x func parseSimulcastURL(url string) (*simulcastURLParts, error) { matches := simulcastURLRegex.FindStringSubmatch(url) if matches == nil { - return nil, fmt.Errorf("simulcast URL must be in format h264:///x or h264:///x, got: %s", url) + return nil, fmt.Errorf("simulcast URL must be in format :///x or :///x where codec is h264 or h265, got: %s", url) } - address, widthStr, heightStr := matches[1], matches[2], matches[3] + codec := matches[1] + address, widthStr, heightStr := matches[2], matches[3], matches[4] // Parse dimensions width, err := strconv.ParseUint(widthStr, 10, 32) @@ -429,6 +433,7 @@ func parseSimulcastURL(url string) (*simulcastURLParts, error) { } return &simulcastURLParts{ + codec: codec, network: network, address: address, width: uint32(width), @@ -436,7 +441,7 @@ func parseSimulcastURL(url string) (*simulcastURLParts, error) { }, nil } -// createSimulcastVideoTrack creates a simulcast video track from a TCP or Unix socket H.264 streams +// createSimulcastVideoTrack creates a simulcast video track from a TCP or Unix socket H.264/H.265 streams func createSimulcastVideoTrack(urlParts *simulcastURLParts, quality livekit.VideoQuality, fps float64, h26xStreamingFormat string, onComplete func()) (*lksdk.LocalTrack, error) { conn, err := net.Dial(urlParts.network, urlParts.address) if err != nil { @@ -472,10 +477,14 @@ func createSimulcastVideoTrack(urlParts *simulcastURLParts, quality livekit.Vide Height: urlParts.height, }))) - return lksdk.NewLocalReaderTrack(conn, webrtc.MimeTypeH264, opts...) + mime := webrtc.MimeTypeH264 + if urlParts.codec == "h265" { + mime = webrtc.MimeTypeH265 + } + return lksdk.NewLocalReaderTrack(conn, mime, opts...) } -// simulcastLayer represents a parsed H.264 stream with quality info +// simulcastLayer represents a parsed H.264/H.265 stream with quality info type simulcastLayer struct { url string parts *simulcastURLParts @@ -504,6 +513,14 @@ func handleSimulcastPublish(room *lksdk.Room, urls []string, fps float64, h26xSt return fmt.Errorf("no valid simulcast URLs provided") } + // Ensure all layers use the same codec + codec := layers[0].parts.codec + for _, l := range layers[1:] { + if l.parts.codec != codec { + return fmt.Errorf("all simulcast layers must use the same codec; expected %s, found %s", codec, l.parts.codec) + } + } + // Sort streams by width to determine quality levels sort.Slice(layers, func(i, j int) bool { return layers[i].parts.width < layers[j].parts.width @@ -573,6 +590,6 @@ func handleSimulcastPublish(room *lksdk.Room, urls []string, fps float64, h26xSt return fmt.Errorf("failed to publish simulcast track: %w", err) } - fmt.Printf("Successfully published H.264 simulcast track with qualities: %v\n", trackNames) + fmt.Printf("Successfully published %s simulcast track with qualities: %v\n", strings.ToUpper(codec), trackNames) return nil } diff --git a/cmd/lk/join_test.go b/cmd/lk/join_test.go index 2a5f67fa..7d18ed06 100644 --- a/cmd/lk/join_test.go +++ b/cmd/lk/join_test.go @@ -88,6 +88,7 @@ func TestParseSimulcastURL(t *testing.T) { // Test TCP format parts, err := parseSimulcastURL("h264://localhost:8080/640x480") assert.NoError(t, err, "Expected no error for valid TCP simulcast URL") + assert.Equal(t, "h264", parts.codec) assert.Equal(t, "tcp", parts.network) assert.Equal(t, "localhost:8080", parts.address) assert.Equal(t, uint32(640), parts.width) @@ -96,6 +97,7 @@ func TestParseSimulcastURL(t *testing.T) { // Test Unix socket format with multiple slashes parts, err = parseSimulcastURL("h264:///tmp/my.socket/1280x720") assert.NoError(t, err, "Expected no error for valid Unix socket simulcast URL") + assert.Equal(t, "h264", parts.codec) assert.Equal(t, "unix", parts.network) assert.Equal(t, "/tmp/my.socket", parts.address) assert.Equal(t, uint32(1280), parts.width) @@ -104,6 +106,7 @@ func TestParseSimulcastURL(t *testing.T) { // Test Unix socket format with nested paths parts, err = parseSimulcastURL("h264:///tmp/deep/nested/path/my.socket/1920x1080") assert.NoError(t, err, "Expected no error for valid nested path Unix socket simulcast URL") + assert.Equal(t, "h264", parts.codec) assert.Equal(t, "unix", parts.network) assert.Equal(t, "/tmp/deep/nested/path/my.socket", parts.address) assert.Equal(t, uint32(1920), parts.width) @@ -112,17 +115,35 @@ func TestParseSimulcastURL(t *testing.T) { // Test simple socket name without path parts, err = parseSimulcastURL("h264://mysocket/640x480") assert.NoError(t, err, "Expected no error for simple socket name") + assert.Equal(t, "h264", parts.codec) assert.Equal(t, "unix", parts.network) assert.Equal(t, "mysocket", parts.address) assert.Equal(t, uint32(640), parts.width) assert.Equal(t, uint32(480), parts.height) + // H265 variants + parts, err = parseSimulcastURL("h265://localhost:8080/640x480") + assert.NoError(t, err, "Expected no error for valid TCP simulcast URL (h265)") + assert.Equal(t, "h265", parts.codec) + assert.Equal(t, "tcp", parts.network) + assert.Equal(t, "localhost:8080", parts.address) + assert.Equal(t, uint32(640), parts.width) + assert.Equal(t, uint32(480), parts.height) + + parts, err = parseSimulcastURL("h265:///tmp/my.socket/1280x720") + assert.NoError(t, err, "Expected no error for valid Unix socket simulcast URL (h265)") + assert.Equal(t, "h265", parts.codec) + assert.Equal(t, "unix", parts.network) + assert.Equal(t, "/tmp/my.socket", parts.address) + assert.Equal(t, uint32(1280), parts.width) + assert.Equal(t, uint32(720), parts.height) + // Test invalid format _, err = parseSimulcastURL("h264://localhost:8080") assert.Error(t, err, "Expected error for URL without dimensions") _, err = parseSimulcastURL("opus:///tmp/socket/640x480") - assert.Error(t, err, "Expected error for non-h264 protocol") + assert.Error(t, err, "Expected error for non-h264/h265 protocol") _, err = parseSimulcastURL("h264:///tmp/socket/invalidxinvalid") assert.Error(t, err, "Expected error for invalid dimensions") diff --git a/cmd/lk/room.go b/cmd/lk/room.go index 6db7a9ac..2f206ccd 100644 --- a/cmd/lk/room.go +++ b/cmd/lk/room.go @@ -38,8 +38,8 @@ import ( ) var ( - // simulcastURLRegex matches h264 simulcast URLs in format h264:///x or h264:///x - simulcastURLRegex = regexp.MustCompile(`^h264://(.+)/(\d+)x(\d+)$`) + // simulcastURLRegex matches h264 or h265 simulcast URLs in format :///x or :///x + simulcastURLRegex = regexp.MustCompile(`^(h264|h265)://(.+)/(\d+)x(\d+)$`) RoomCommands = []*cli.Command{ { @@ -157,8 +157,8 @@ var ( TakesFile: true, Usage: "`FILES` to publish as tracks to room (supports .h264, .ivf, .ogg). " + "Can be used multiple times to publish multiple files. " + - "Can publish from Unix or TCP socket using the format ':///' or '://' respectively. Valid codecs are \"h264\", \"vp8\", \"opus\". " + - "For simulcast: use 2-3 h264:// URLs with format 'h264:///x' or 'h264:///path/to//x' (quality determined by width order)", + "Can publish from Unix or TCP socket using the format ':///' or '://' respectively. Valid codecs are \"h264\", \"h265\", \"vp8\", \"opus\". " + + "For simulcast: use 2-3 h264:// or h265:// URLs with format ':///x' or ':///path/to//x' (all layers must use the same codec; quality determined by width order)", }, &cli.StringFlag{ Name: "publish-data", @@ -828,19 +828,22 @@ func joinRoom(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("no more than 3 --publish flags can be specified, got %d", len(publishUrls)) } - // If simulcast mode, validate all URLs are h264 format with dimensions + // If simulcast mode, validate all URLs are h264 or h265 format with dimensions if simulcastMode { if len(publishUrls) == 1 { return fmt.Errorf("simulcast mode requires 2-3 streams, but only 1 was provided") } + var firstCodec string for i, url := range publishUrls { - if !strings.HasPrefix(url, "h264://") { - return fmt.Errorf("publish flag %d: simulcast mode requires h264:// URLs with dimensions (format: h264://host:port/widthxheight), got: %s", i+1, url) - } - // Validate the format has dimensions - if _, err := parseSimulcastURL(url); err != nil { + parts, err := parseSimulcastURL(url) + if err != nil { return fmt.Errorf("publish flag %d: %w", i+1, err) } + if i == 0 { + firstCodec = parts.codec + } else if parts.codec != firstCodec { + return fmt.Errorf("publish flag %d: simulcast layers must use the same codec; expected %s://, got %s://", i+1, firstCodec, parts.codec) + } } }