Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -204,11 +206,11 @@ lk room join \
<room_name>
```

### 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://<host>:<port>/<width>x<height>` are passed in as arguments.
The track will be published in simulcast mode if multiple `--publish` flags with the syntax `<codec>://<host>:<port>/<width>x<height>` are passed in as arguments, where `<codec>` is `h264` or `h265`. All layers must use the same codec.

Example:

Expand Down Expand Up @@ -239,7 +241,7 @@ lk room join --identity <name> --url "<url>" --api-key "<key>" --api-secret "<se
```

Notes:
- LiveKit CLI can only publish simulcast tracks using H.264 codec.
- LiveKit CLI can publish simulcast tracks using H.264 or H.265. Ensure your LiveKit deployment and clients support the chosen codec (HEVC/H.265 support varies by platform/browser).
- You can only use multiple `--publish` flags to create a simulcast track.
- Using more than 1 `--publish` flag for other types of streams will not work.
- Tracks will automatically be set to HIGH/MED/LOW resolution based on the order of their width.
Expand All @@ -248,7 +250,7 @@ Notes:
### Publish streams from your application

Using unix sockets, it's also possible to publish streams from your application. The tracks need to be encoded into
a format that WebRTC clients could playback (VP8, H.264, and Opus).
a format that WebRTC clients could playback (VP8, H.264, H.265, and Opus).

Once you are writing to the socket, you could use `ffplay` to test the stream.

Expand Down
35 changes: 26 additions & 9 deletions cmd/lk/join.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ 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 '<codec>://<socket_name>' or '<codec>://<host:address>' respectively. Valid codecs are \"h264\", \"vp8\", \"opus\"",
"can publish from Unix or TCP socket using the format '<codec>://<socket_name>' or '<codec>://<host:address>' respectively. Valid codecs are \"h264\", \"h265\", \"vp8\", \"opus\"",
},
&cli.FloatFlag{
Name: "fps",
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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://<host:port>/<width>x<height> or h264://<socket_path>/<width>x<height>
// parseSimulcastURL validates and parses a simulcast URL in the format <codec>://<host:port>/<width>x<height> or <codec>://<socket_path>/<width>x<height>
func parseSimulcastURL(url string) (*simulcastURLParts, error) {
matches := simulcastURLRegex.FindStringSubmatch(url)
if matches == nil {
return nil, fmt.Errorf("simulcast URL must be in format h264://<host:port>/<width>x<height> or h264://<socket_path>/<width>x<height>, got: %s", url)
return nil, fmt.Errorf("simulcast URL must be in format <codec>://<host:port>/<width>x<height> or <codec>://<socket_path>/<width>x<height> 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)
Expand All @@ -429,14 +433,15 @@ func parseSimulcastURL(url string) (*simulcastURLParts, error) {
}

return &simulcastURLParts{
codec: codec,
network: network,
address: address,
width: uint32(width),
height: uint32(height),
}, 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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
23 changes: 22 additions & 1 deletion cmd/lk/join_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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")
Expand Down
23 changes: 13 additions & 10 deletions cmd/lk/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ import (
)

var (
// simulcastURLRegex matches h264 simulcast URLs in format h264://<host:port>/<width>x<height> or h264://<socket_path>/<width>x<height>
simulcastURLRegex = regexp.MustCompile(`^h264://(.+)/(\d+)x(\d+)$`)
// simulcastURLRegex matches h264 or h265 simulcast URLs in format <codec>://<host:port>/<width>x<height> or <codec>://<socket_path>/<width>x<height>
simulcastURLRegex = regexp.MustCompile(`^(h264|h265)://(.+)/(\d+)x(\d+)$`)

RoomCommands = []*cli.Command{
{
Expand Down Expand Up @@ -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 '<codec>:///<socket_path>' or '<codec>://<host:port>' respectively. Valid codecs are \"h264\", \"vp8\", \"opus\". " +
"For simulcast: use 2-3 h264:// URLs with format 'h264://<host:port>/<width>x<height>' or 'h264:///path/to/<socket_path>/<width>x<height>' (quality determined by width order)",
"Can publish from Unix or TCP socket using the format '<codec>:///<socket_path>' or '<codec>://<host:port>' respectively. Valid codecs are \"h264\", \"h265\", \"vp8\", \"opus\". " +
"For simulcast: use 2-3 h264:// or h265:// URLs with format '<codec>://<host:port>/<width>x<height>' or '<codec>:///path/to/<socket_path>/<width>x<height>' (all layers must use the same codec; quality determined by width order)",
},
&cli.StringFlag{
Name: "publish-data",
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
Loading