From ea03aa832da7ff4a071c9d9a58571aa171aafbf3 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Tue, 10 Mar 2026 21:37:29 +0300 Subject: [PATCH] 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 --- go.mod | 3 +- go.sum | 2 + internal/webp/webp.go | 160 ++++++++++++++++++++++++++++++++++++ internal/webp/webp_test.go | 16 ++++ main.go | 2 + pkg/image/producer.go | 35 ++++++++ pkg/webp/consumer.go | 61 ++++++++++++++ pkg/webp/helpers.go | 84 +++++++++++++++++++ pkg/webp/rtp.go | 11 +++ pkg/webp/webp_test.go | 163 +++++++++++++++++++++++++++++++++++++ pkg/webp/writer.go | 38 +++++++++ website/api/openapi.yaml | 61 ++++++++++++++ www/links.html | 2 + www/schema.json | 4 + 14 files changed, 641 insertions(+), 1 deletion(-) create mode 100644 internal/webp/webp.go create mode 100644 internal/webp/webp_test.go create mode 100644 pkg/webp/consumer.go create mode 100644 pkg/webp/helpers.go create mode 100644 pkg/webp/rtp.go create mode 100644 pkg/webp/webp_test.go create mode 100644 pkg/webp/writer.go diff --git a/go.mod b/go.mod index 485509e6..0f2354f2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/AlexxIT/go2rtc -go 1.24.0 +go 1.26.1 require ( github.com/asticode/go-astits v1.14.0 @@ -43,6 +43,7 @@ require ( github.com/pion/transport/v4 v4.0.1 // indirect github.com/pion/turn/v4 v4.1.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/skrashevich/go-webp v0.0.0-20260309074808-df43a8e8d32a // indirect github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index 897bb8a2..261390fc 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfU github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxIpoID9prlYH8nuNYKt0XvweHA= github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8= +github.com/skrashevich/go-webp v0.0.0-20260309074808-df43a8e8d32a h1:XlEMOOLLCc6IRVisVzFa/ajiYLL/O10Y8vYEnc+l4Y8= +github.com/skrashevich/go-webp v0.0.0-20260309074808-df43a8e8d32a/go.mod h1:9QtuNP/H9q/qzqgaZeYalNIk7n5lfyqVs1WTaPtC/Ao= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/internal/webp/webp.go b/internal/webp/webp.go new file mode 100644 index 00000000..36895076 --- /dev/null +++ b/internal/webp/webp.go @@ -0,0 +1,160 @@ +package webp + +import ( + "net/http" + "strconv" + "sync" + "time" + + "github.com/AlexxIT/go2rtc/internal/api" + "github.com/AlexxIT/go2rtc/internal/app" + "github.com/AlexxIT/go2rtc/internal/ffmpeg" + "github.com/AlexxIT/go2rtc/internal/streams" + "github.com/AlexxIT/go2rtc/pkg/core" + "github.com/AlexxIT/go2rtc/pkg/magic" + "github.com/AlexxIT/go2rtc/pkg/mjpeg" + "github.com/AlexxIT/go2rtc/pkg/webp" + "github.com/rs/zerolog" +) + +func Init() { + api.HandleFunc("api/frame.webp", handlerKeyframe) + api.HandleFunc("api/stream.webp", handlerStream) + + log = app.GetLogger("webp") +} + +var log zerolog.Logger + +var cache map[string]cacheEntry +var cacheMu sync.Mutex + +type cacheEntry struct { + payload []byte + timestamp time.Time +} + +func handlerKeyframe(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + stream, _ := streams.GetOrPatch(query) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + quality := 75 + if s := query.Get("quality"); s != "" { + if q, err := strconv.Atoi(s); err == nil && q > 0 && q <= 100 { + quality = q + } + } + + var b []byte + + if s := query.Get("cache"); s != "" { + if timeout, err := time.ParseDuration(s); err == nil { + src := query.Get("src") + + cacheMu.Lock() + entry, found := cache[src] + cacheMu.Unlock() + + if found && time.Since(entry.timestamp) < timeout { + writeWebPResponse(w, entry.payload) + return + } + + defer func() { + if b == nil { + return + } + entry = cacheEntry{payload: b, timestamp: time.Now()} + cacheMu.Lock() + if cache == nil { + cache = map[string]cacheEntry{src: entry} + } else { + cache[src] = entry + } + cacheMu.Unlock() + }() + } + } + + cons := magic.NewKeyframe() + cons.WithRequest(r) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Caller().Send() + return + } + + once := &core.OnceBuffer{} + _, _ = cons.WriteTo(once) + b = once.Buffer() + + stream.RemoveConsumer(cons) + + var err error + switch cons.CodecName() { + case core.CodecH264, core.CodecH265: + ts := time.Now() + var jpegBytes []byte + if jpegBytes, err = ffmpeg.JPEGWithQuery(b, query); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + log.Debug().Msgf("[webp] transcoding time=%s", time.Since(ts)) + if b, err = webp.EncodeJPEG(jpegBytes, quality); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + case core.CodecJPEG: + fixed := mjpeg.FixJPEG(b) + if b, err = webp.EncodeJPEG(fixed, quality); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + writeWebPResponse(w, b) +} + +func writeWebPResponse(w http.ResponseWriter, b []byte) { + h := w.Header() + h.Set("Content-Type", "image/webp") + h.Set("Content-Length", strconv.Itoa(len(b))) + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "close") + h.Set("Pragma", "no-cache") + + if _, err := w.Write(b); err != nil { + log.Error().Err(err).Caller().Send() + } +} + +func handlerStream(w http.ResponseWriter, r *http.Request) { + src := r.URL.Query().Get("src") + stream := streams.Get(src) + if stream == nil { + http.Error(w, api.StreamNotFound, http.StatusNotFound) + return + } + + cons := webp.NewConsumer() + cons.WithRequest(r) + + if err := stream.AddConsumer(cons); err != nil { + log.Error().Err(err).Msg("[api.webp] add consumer") + return + } + + h := w.Header() + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "close") + h.Set("Pragma", "no-cache") + + wr := webp.NewWriter(w) + _, _ = cons.WriteTo(wr) + + stream.RemoveConsumer(cons) +} diff --git a/internal/webp/webp_test.go b/internal/webp/webp_test.go new file mode 100644 index 00000000..83bc8f1c --- /dev/null +++ b/internal/webp/webp_test.go @@ -0,0 +1,16 @@ +package webp + +import ( + "testing" +) + +func TestInit(t *testing.T) { + // Verify Init() runs without panicking and registers API endpoints. + // api.HandleFunc registrations are idempotent so calling Init multiple times is safe. + defer func() { + if r := recover(); r != nil { + t.Fatalf("Init() panicked: %v", r) + } + }() + Init() +} diff --git a/main.go b/main.go index 00c059e3..03099862 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ import ( "github.com/AlexxIT/go2rtc/internal/kasa" "github.com/AlexxIT/go2rtc/internal/mjpeg" "github.com/AlexxIT/go2rtc/internal/mp4" + "github.com/AlexxIT/go2rtc/internal/webp" "github.com/AlexxIT/go2rtc/internal/mpeg" "github.com/AlexxIT/go2rtc/internal/multitrans" "github.com/AlexxIT/go2rtc/internal/nest" @@ -73,6 +74,7 @@ func main() { {"mp4", mp4.Init}, // MP4 API {"hls", hls.Init}, // HLS API {"mjpeg", mjpeg.Init}, // MJPEG API + {"webp", webp.Init}, // WebP API // Other sources and servers {"hass", hass.Init}, // hass source, Hass API server {"homekit", homekit.Init}, // homekit source, HomeKit server diff --git a/pkg/image/producer.go b/pkg/image/producer.go index 2081c048..c99db7e5 100644 --- a/pkg/image/producer.go +++ b/pkg/image/producer.go @@ -1,13 +1,16 @@ package image import ( + "bytes" "errors" + "image/jpeg" "io" "net/http" "github.com/AlexxIT/go2rtc/pkg/core" "github.com/AlexxIT/go2rtc/pkg/tcp" "github.com/pion/rtp" + webp "github.com/skrashevich/go-webp" ) type Producer struct { @@ -49,6 +52,12 @@ func (c *Producer) Start() error { return err } + if isWebP(body) { + if converted, err2 := webpToJPEG(body); err2 == nil { + body = converted + } + } + pkt := &rtp.Packet{ Header: rtp.Header{Timestamp: core.Now90000()}, Payload: body, @@ -74,6 +83,12 @@ func (c *Producer) Start() error { return err } + if isWebP(body) { + if converted, err2 := webpToJPEG(body); err2 == nil { + body = converted + } + } + c.Recv += len(body) pkt = &rtp.Packet{ @@ -90,3 +105,23 @@ func (c *Producer) Stop() error { c.closed = true return c.Connection.Stop() } + +// isWebP returns true if data starts with RIFF....WEBP magic bytes. +func isWebP(data []byte) bool { + return len(data) >= 12 && + data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && + data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' +} + +// webpToJPEG decodes WebP bytes and re-encodes as JPEG. +func webpToJPEG(data []byte) ([]byte, error) { + img, err := webp.Decode(bytes.NewReader(data)) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err = jpeg.Encode(&buf, img, nil); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/pkg/webp/consumer.go b/pkg/webp/consumer.go new file mode 100644 index 00000000..d90b5d44 --- /dev/null +++ b/pkg/webp/consumer.go @@ -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) +} diff --git a/pkg/webp/helpers.go b/pkg/webp/helpers.go new file mode 100644 index 00000000..85b6b350 --- /dev/null +++ b/pkg/webp/helpers.go @@ -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) + } +} diff --git a/pkg/webp/rtp.go b/pkg/webp/rtp.go new file mode 100644 index 00000000..d4a5f784 --- /dev/null +++ b/pkg/webp/rtp.go @@ -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)) +} diff --git a/pkg/webp/webp_test.go b/pkg/webp/webp_test.go new file mode 100644 index 00000000..de4f1005 --- /dev/null +++ b/pkg/webp/webp_test.go @@ -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 +} diff --git a/pkg/webp/writer.go b/pkg/webp/writer.go new file mode 100644 index 00000000..a2705f62 --- /dev/null +++ b/pkg/webp/writer.go @@ -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 +} diff --git a/website/api/openapi.yaml b/website/api/openapi.yaml index b6110572..777f329f 100644 --- a/website/api/openapi.yaml +++ b/website/api/openapi.yaml @@ -567,6 +567,18 @@ paths: description: "" content: { multipart/x-mixed-replace: { example: "" } } + /api/stream.webp?src={src}: + get: + summary: Get stream in Motion-WebP format (multipart) + description: "Multipart stream of WebP frames. Pure Go encoding via [go-webp](https://github.com/skrashevich/go-webp)." + tags: [ Consume stream ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + responses: + 200: + description: "" + content: { multipart/x-mixed-replace: { example: "" } } + /api/stream.ascii?src={src}: get: summary: Get stream in ASCII-art format (ANSI escape codes) @@ -691,6 +703,55 @@ paths: content: image/jpeg: { example: "" } + /api/frame.webp?src={src}: + get: + summary: Get snapshot in WebP format + description: "Pure Go WebP encoding via [go-webp](https://github.com/skrashevich/go-webp). No FFmpeg or CGO required for the WebP conversion itself." + tags: [ Snapshot ] + parameters: + - $ref: "#/components/parameters/stream_src_path" + - name: name + in: query + description: Optional stream name to create/update if `src` is a URL + required: false + schema: { type: string } + - name: quality + in: query + description: "WebP quality (1-100, default: 75)" + required: false + schema: { type: integer, minimum: 1, maximum: 100, default: 75 } + - name: width + in: query + description: "Scale output width (alias: `w`). Requires FFmpeg for H264/H265 sources." + required: false + schema: { type: integer, minimum: 1 } + - name: height + in: query + description: "Scale output height (alias: `h`). Requires FFmpeg for H264/H265 sources." + required: false + schema: { type: integer, minimum: 1 } + - name: rotate + in: query + description: "Rotate output (degrees). Requires FFmpeg for H264/H265 sources." + required: false + schema: { type: integer, enum: [ 90, 180, 270 ] } + - name: hardware + in: query + description: "Hardware acceleration engine for FFmpeg snapshot transcoding (alias: `hw`)" + required: false + schema: { type: string } + - name: cache + in: query + description: "Cache duration (e.g. `5s`, `1m`). Serves cached frame if within timeout." + required: false + schema: { type: string } + example: "5s" + responses: + "200": + description: "" + content: + image/webp: { example: "" } + /api/frame.mp4?src={src}: get: summary: Get snapshot in MP4 format diff --git a/www/links.html b/www/links.html index 13e08edf..8be6cfe7 100644 --- a/www/links.html +++ b/www/links.html @@ -66,7 +66,9 @@

MJPEG source

  • stream.html with MJPEG mode / browsers: all / codecs: MJPEG, JPEG
  • stream.mjpeg MJPEG stream / browsers: all / codecs: MJPEG, JPEG
  • +
  • stream.webp Motion-WebP stream / browsers: all modern / codecs: MJPEG, JPEG
  • frame.jpeg snapshot in JPEG-format / browsers: all / codecs: MJPEG, JPEG
  • +
  • frame.webp snapshot in WebP-format / browsers: all modern / codecs: H264, H265, MJPEG, JPEG
  • `; }); diff --git a/www/schema.json b/www/schema.json index 27fee57d..0f84a47b 100644 --- a/www/schema.json +++ b/www/schema.json @@ -151,6 +151,7 @@ "mp4", "hls", "mjpeg", + "webp", "hass", "homekit", "onvif", @@ -427,6 +428,9 @@ "mjpeg": { "$ref": "#/definitions/log_level" }, + "webp": { + "$ref": "#/definitions/log_level" + }, "mp4": { "$ref": "#/definitions/log_level" },