Merge branch 'go-webp' into beta

# Conflicts:
#	.gitignore
#	go.sum
This commit is contained in:
Sergey Krashevich
2026-03-10 23:54:17 +03:00
15 changed files with 643 additions and 0 deletions
+1
View File
@@ -22,6 +22,7 @@ website/.vitepress/dist
node_modules
package-lock.json
CLAUDE.md
*/**/CLAUDE.md
.claude*
.ruff*
+1
View File
@@ -21,6 +21,7 @@ require (
github.com/rs/zerolog v1.34.0
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f
github.com/skrashevich/go-webp v0.1.0
github.com/stretchr/testify v1.11.1
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9
golang.org/x/crypto v0.47.0
+4
View File
@@ -73,6 +73,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.1.0 h1:C+dtldBorS5ISATYR5mvG9HFj8GRLDGRywT0xs/ZLUQ=
github.com/skrashevich/go-webp v0.1.0/go.mod h1:9QtuNP/H9q/qzqgaZeYalNIk7n5lfyqVs1WTaPtC/Ao=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
@@ -81,6 +83,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
+160
View File
@@ -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)
}
+16
View File
@@ -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()
}
+2
View File
@@ -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
+35
View File
@@ -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
}
+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
}
+61
View File
@@ -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
+2
View File
@@ -66,7 +66,9 @@
<h2>MJPEG source</h2>
<li><a href="stream.html?src=${src}&mode=mjpeg">stream.html</a> with MJPEG mode / browsers: all / codecs: MJPEG, JPEG</li>
<li><a href="api/stream.mjpeg?src=${src}">stream.mjpeg</a> MJPEG stream / browsers: all / codecs: MJPEG, JPEG</li>
<li><a href="api/stream.webp?src=${src}">stream.webp</a> Motion-WebP stream / browsers: all modern / codecs: MJPEG, JPEG</li>
<li><a href="api/frame.jpeg?src=${src}">frame.jpeg</a> snapshot in JPEG-format / browsers: all / codecs: MJPEG, JPEG</li>
<li><a href="api/frame.webp?src=${src}">frame.webp</a> snapshot in WebP-format / browsers: all modern / codecs: H264, H265, MJPEG, JPEG</li>
`;
});
</script>
+4
View File
@@ -151,6 +151,7 @@
"mp4",
"hls",
"mjpeg",
"webp",
"hass",
"homekit",
"onvif",
@@ -462,6 +463,9 @@
"mjpeg": {
"$ref": "#/definitions/log_level"
},
"webp": {
"$ref": "#/definitions/log_level"
},
"mp4": {
"$ref": "#/definitions/log_level"
},