mirror of
https://github.com/AlexxIT/go2rtc.git
synced 2026-04-22 15:47:06 +08:00
Merge branch 'go-webp' into beta
# Conflicts: # .gitignore # go.sum
This commit is contained in:
@@ -22,6 +22,7 @@ website/.vitepress/dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
CLAUDE.md
|
||||
*/**/CLAUDE.md
|
||||
.claude*
|
||||
.ruff*
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user