feat(webp): add WebP streaming and snapshot APIs

- implement WebP streaming with multipart support
- add snapshot endpoint for WebP format with quality options
- introduce WebP encoding using pure Go library without FFmpeg
- update documentation and links for new WebP features
This commit is contained in:
Sergey Krashevich
2026-03-10 21:37:29 +03:00
parent beb96dd076
commit ea03aa832d
14 changed files with 641 additions and 1 deletions
+61
View File
@@ -0,0 +1,61 @@
package webp
import (
"io"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/pion/rtp"
)
type Consumer struct {
core.Connection
wr *core.WriteBuffer
}
func NewConsumer() *Consumer {
medias := []*core.Media{
{
Kind: core.KindVideo,
Direction: core.DirectionSendonly,
Codecs: []*core.Codec{
{Name: core.CodecJPEG},
{Name: core.CodecRAW},
},
},
}
wr := core.NewWriteBuffer(nil)
return &Consumer{
Connection: core.Connection{
ID: core.NewID(),
FormatName: "webp",
Medias: medias,
Transport: wr,
},
wr: wr,
}
}
func (c *Consumer) AddTrack(media *core.Media, _ *core.Codec, track *core.Receiver) error {
sender := core.NewSender(media, track.Codec)
sender.Handler = func(packet *rtp.Packet) {
if n, err := c.wr.Write(packet.Payload); err == nil {
c.Send += n
}
}
if track.Codec.IsRTP() {
sender.Handler = RTPDepay(sender.Handler)
} else if track.Codec.Name == core.CodecRAW {
sender.Handler = Encoder(track.Codec, sender.Handler)
} else if track.Codec.Name == core.CodecJPEG {
sender.Handler = JPEGToWebP(sender.Handler)
}
sender.HandleRTP(track)
c.Senders = append(c.Senders, sender)
return nil
}
func (c *Consumer) WriteTo(wr io.Writer) (int64, error) {
return c.wr.WriteTo(wr)
}
+84
View File
@@ -0,0 +1,84 @@
package webp
import (
"bytes"
"image"
"image/jpeg"
webplib "github.com/skrashevich/go-webp"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
"github.com/AlexxIT/go2rtc/pkg/y4m"
"github.com/pion/rtp"
)
// EncodeImage encodes any image.Image to WebP lossy bytes.
func EncodeImage(img image.Image, quality int) ([]byte, error) {
buf := bytes.NewBuffer(nil)
if err := webplib.Encode(buf, img, &webplib.Options{Lossy: true, Quality: float32(quality)}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// EncodeLossless encodes image.Image to WebP lossless bytes.
func EncodeLossless(img image.Image) ([]byte, error) {
buf := bytes.NewBuffer(nil)
if err := webplib.Encode(buf, img, &webplib.Options{Lossy: false}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// EncodeJPEG converts JPEG bytes to WebP lossy bytes.
func EncodeJPEG(jpegData []byte, quality int) ([]byte, error) {
img, err := jpeg.Decode(bytes.NewReader(jpegData))
if err != nil {
return nil, err
}
return EncodeImage(img, quality)
}
// Decode decodes WebP bytes to image.Image.
func Decode(data []byte) (image.Image, error) {
return webplib.Decode(bytes.NewReader(data))
}
// FixJPEGToWebP is like mjpeg.FixJPEG but outputs WebP. Handles AVI1 MJPEG frames.
func FixJPEGToWebP(jpegData []byte, quality int) ([]byte, error) {
fixed := mjpeg.FixJPEG(jpegData)
return EncodeJPEG(fixed, quality)
}
// Encoder converts a RAW YUV frame to WebP.
func Encoder(codec *core.Codec, handler core.HandlerFunc) core.HandlerFunc {
newImage := y4m.NewImage(codec.FmtpLine)
return func(packet *rtp.Packet) {
img := newImage(packet.Payload)
b, err := EncodeImage(img, 75)
if err != nil {
return
}
clone := *packet
clone.Payload = b
handler(&clone)
}
}
// JPEGToWebP converts a JPEG frame packet to WebP.
func JPEGToWebP(handler core.HandlerFunc) core.HandlerFunc {
return func(packet *rtp.Packet) {
b, err := EncodeJPEG(packet.Payload, 75)
if err != nil {
return
}
clone := *packet
clone.Payload = b
handler(&clone)
}
}
+11
View File
@@ -0,0 +1,11 @@
package webp
import (
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/mjpeg"
)
// RTPDepay depayloads RTP/JPEG packets and converts the resulting JPEG frame to WebP.
func RTPDepay(handler core.HandlerFunc) core.HandlerFunc {
return mjpeg.RTPDepay(JPEGToWebP(handler))
}
+163
View File
@@ -0,0 +1,163 @@
package webp
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"testing"
"github.com/AlexxIT/go2rtc/pkg/core"
)
func newTestImage(w, h int) *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255})
}
}
return img
}
func isWebP(data []byte) bool {
return len(data) >= 12 &&
bytes.Equal(data[0:4], []byte("RIFF")) &&
bytes.Equal(data[8:12], []byte("WEBP"))
}
func TestEncodeImage(t *testing.T) {
img := newTestImage(100, 100)
data, err := EncodeImage(img, 75)
if err != nil {
t.Fatalf("EncodeImage error: %v", err)
}
if !isWebP(data) {
t.Fatalf("output is not valid WebP: got prefix %q", data[:min(12, len(data))])
}
}
func TestEncodeJPEG(t *testing.T) {
img := newTestImage(100, 100)
var jpegBuf bytes.Buffer
if err := jpeg.Encode(&jpegBuf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("jpeg.Encode error: %v", err)
}
data, err := EncodeJPEG(jpegBuf.Bytes(), 75)
if err != nil {
t.Fatalf("EncodeJPEG error: %v", err)
}
if !isWebP(data) {
t.Fatalf("output is not valid WebP: got prefix %q", data[:min(12, len(data))])
}
}
func TestDecode(t *testing.T) {
img := newTestImage(100, 80)
data, err := EncodeImage(img, 80)
if err != nil {
t.Fatalf("EncodeImage error: %v", err)
}
decoded, err := Decode(data)
if err != nil {
t.Fatalf("Decode error: %v", err)
}
bounds := decoded.Bounds()
if bounds.Dx() != 100 || bounds.Dy() != 80 {
t.Fatalf("expected 100x80, got %dx%d", bounds.Dx(), bounds.Dy())
}
}
func TestRoundTrip(t *testing.T) {
img := newTestImage(64, 64)
data, err := EncodeLossless(img)
if err != nil {
t.Fatalf("EncodeLossless error: %v", err)
}
decoded, err := Decode(data)
if err != nil {
t.Fatalf("Decode error: %v", err)
}
bounds := decoded.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
orig := img.At(x, y)
got := decoded.At(x, y)
or, og, ob, oa := orig.RGBA()
gr, gg, gb, ga := got.RGBA()
if or != gr || og != gg || ob != gb || oa != ga {
t.Fatalf("pixel mismatch at (%d,%d): want %v got %v", x, y, orig, got)
}
}
}
}
func TestEncodeLossless(t *testing.T) {
img := newTestImage(50, 50)
data, err := EncodeLossless(img)
if err != nil {
t.Fatalf("EncodeLossless error: %v", err)
}
if !isWebP(data) {
t.Fatalf("output is not valid WebP")
}
decoded, err := Decode(data)
if err != nil {
t.Fatalf("Decode error: %v", err)
}
bounds := decoded.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
orig := img.At(x, y)
got := decoded.At(x, y)
or, og, ob, oa := orig.RGBA()
gr, gg, gb, ga := got.RGBA()
if or != gr || og != gg || ob != gb || oa != ga {
t.Fatalf("pixel mismatch at (%d,%d): want %v got %v", x, y, orig, got)
}
}
}
}
func TestNewConsumer(t *testing.T) {
c := NewConsumer()
if c == nil {
t.Fatal("NewConsumer returned nil")
}
if c.FormatName != "webp" {
t.Fatalf("expected FormatName=webp, got %q", c.FormatName)
}
if len(c.Medias) == 0 {
t.Fatal("expected at least one media")
}
media := c.Medias[0]
if media.Kind != core.KindVideo {
t.Fatalf("expected KindVideo, got %v", media.Kind)
}
if media.Direction != core.DirectionSendonly {
t.Fatalf("expected DirectionSendonly, got %v", media.Direction)
}
hasJPEG := false
hasRAW := false
for _, codec := range media.Codecs {
if codec.Name == core.CodecJPEG {
hasJPEG = true
}
if codec.Name == core.CodecRAW {
hasRAW = true
}
}
if !hasJPEG {
t.Fatal("expected JPEG codec in consumer medias")
}
if !hasRAW {
t.Fatal("expected RAW codec in consumer medias")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+38
View File
@@ -0,0 +1,38 @@
package webp
import (
"io"
"net/http"
"strconv"
)
const header = "--frame\r\nContent-Type: image/webp\r\nContent-Length: "
// Writer writes multipart WebP frames to an HTTP response.
type Writer struct {
wr io.Writer
buf []byte
}
// NewWriter creates a Writer that sets the multipart Content-Type header.
func NewWriter(w io.Writer) *Writer {
h := w.(http.ResponseWriter).Header()
h.Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
return &Writer{wr: w, buf: []byte(header)}
}
func (w *Writer) Write(p []byte) (n int, err error) {
w.buf = w.buf[:len(header)]
w.buf = append(w.buf, strconv.Itoa(len(p))...)
w.buf = append(w.buf, "\r\n\r\n"...)
w.buf = append(w.buf, p...)
w.buf = append(w.buf, "\r\n"...)
if _, err = w.wr.Write(w.buf); err != nil {
return 0, err
}
w.wr.(http.Flusher).Flush()
return len(p), nil
}