From 38cc05c22d82a61f1f4e52328625acbc6d54f256 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Sun, 18 Jan 2026 14:23:50 +0300 Subject: [PATCH] feat(jpeg): Add keyframe caching with expiration mechanism to JPEG http handler (#1155) * feat(mjpeg): add keyframe caching with expiration and cleanup goroutine * mjpeg: make keyframe cache duration and default usage configurable * mjpeg: document and add config options for MJPEG snapshot caching * mjpeg: fix errors after rebase * Code refactoring for frame.jpeg cache #1155 --------- Co-authored-by: Alex X --- README.md | 32 ++++---------------------- internal/mjpeg/README.md | 42 ++++++++++++++++++++++++++++++++++ internal/mjpeg/init.go | 49 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 92 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index c70e65bd..a34b5a5a 100644 --- a/README.md +++ b/README.md @@ -1236,35 +1236,11 @@ Read more about [codecs filters](#codecs-filters). ## Module: MJPEG -**Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API. +- This module can provide and receive streams in MJPEG format. +- This module is also responsible for receiving snapshots in JPEG format. +- This module also supports streaming to the server console (terminal) in the **animated ASCII art** format. -You can receive an MJPEG stream in several ways: - -- some cameras support MJPEG codec inside [RTSP stream](#source-rtsp) (ex. second stream for Dahua cameras) -- some cameras have an HTTP link with [MJPEG stream](#source-http) -- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http) -- you can convert H264/H265 stream from your camera via [FFmpeg integraion](#source-ffmpeg) - -With this example, your stream will have both H264 and MJPEG codecs: - -```yaml -streams: - camera1: - - rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0 - - ffmpeg:camera1#video=mjpeg -``` - -API examples: - -- MJPEG stream: `http://192.168.1.123:1984/api/stream.mjpeg?src=camera1` -- JPEG snapshots: `http://192.168.1.123:1984/api/frame.jpeg?src=camera1` - - You can use `width`/`w` and/or `height`/`h` params - - You can use `rotate` param with `90`, `180`, `270` or `-90` values - - You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration) - -**PS.** This module also supports streaming to the server console (terminal) in the **animated ASCII art** format ([read more](internal/mjpeg/README.md)). - -[![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) +*[read more](internal/mjpeg/README.md)* ## Module: Log diff --git a/internal/mjpeg/README.md b/internal/mjpeg/README.md index a09e59c4..1d701de1 100644 --- a/internal/mjpeg/README.md +++ b/internal/mjpeg/README.md @@ -1,3 +1,45 @@ +# MJPEG + +**Important.** For stream in MJPEG format, your source MUST contain the MJPEG codec. If your stream has an MJPEG codec, you can receive **MJPEG stream** or **JPEG snapshots** via API. + +You can receive an MJPEG stream in several ways: + +- some cameras support MJPEG codec inside [RTSP stream](#source-rtsp) (ex. second stream for Dahua cameras) +- some cameras have an HTTP link with [MJPEG stream](#source-http) +- some cameras have an HTTP link with snapshots - go2rtc can convert them to [MJPEG stream](#source-http) +- you can convert H264/H265 stream from your camera via [FFmpeg integraion](#source-ffmpeg) + +With this example, your stream will have both H264 and MJPEG codecs: + +```yaml +streams: + camera1: + - rtsp://rtsp:12345678@192.168.1.123/av_stream/ch0 + - ffmpeg:camera1#video=mjpeg +``` + +## API examples + +**MJPEG stream** + +``` +http://192.168.1.123:1984/api/stream.mjpeg?src=camera1` +``` + +**JPEG snapshots** + +``` +http://192.168.1.123:1984/api/frame.jpeg?src=camera1 +``` + +- You can use `width`/`w` and/or `height`/`h` params. +- You can use `rotate` param with `90`, `180`, `270` or `-90` values. +- You can use `hardware`/`hw` param [read more](https://github.com/AlexxIT/go2rtc/wiki/Hardware-acceleration). +- You can use `cache` param (`1m`, `10s`, etc.) to get a cached snapshot. + - The snapshot is cached only when requested with the `cache` parameter. + - A cached snapshot will be used if its time is not older than the time specified in the `cache` parameter. + - The `cache` parameter does not check the image sizes from the cache and those specified in the query. + ## Stream as ASCII to Terminal [![](https://img.youtube.com/vi/sHj_3h_sX7M/mqdefault.jpg)](https://www.youtube.com/watch?v=sHj_3h_sX7M) diff --git a/internal/mjpeg/init.go b/internal/mjpeg/init.go index 2fa9fa32..b2f3e1f4 100644 --- a/internal/mjpeg/init.go +++ b/internal/mjpeg/init.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "github.com/AlexxIT/go2rtc/internal/api" @@ -36,12 +37,41 @@ func Init() { var log zerolog.Logger func handlerKeyframe(w http.ResponseWriter, r *http.Request) { - stream, _ := streams.GetOrPatch(r.URL.Query()) + query := r.URL.Query() + stream, _ := streams.GetOrPatch(query) if stream == nil { http.Error(w, api.StreamNotFound, http.StatusNotFound) return } + 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 { + writeJPEGResponse(w, entry.payload) + return + } + + defer func() { + 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) @@ -52,7 +82,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { once := &core.OnceBuffer{} // init and first frame _, _ = cons.WriteTo(once) - b := once.Buffer() + b = once.Buffer() stream.RemoveConsumer(cons) @@ -60,7 +90,7 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { case core.CodecH264, core.CodecH265: ts := time.Now() var err error - if b, err = ffmpeg.JPEGWithQuery(b, r.URL.Query()); err != nil { + if b, err = ffmpeg.JPEGWithQuery(b, query); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -69,6 +99,19 @@ func handlerKeyframe(w http.ResponseWriter, r *http.Request) { b = mjpeg.FixJPEG(b) } + writeJPEGResponse(w, b) +} + +var cache map[string]cacheEntry +var cacheMu sync.Mutex + +// cacheEntry represents a cached keyframe with its timestamp +type cacheEntry struct { + payload []byte + timestamp time.Time +} + +func writeJPEGResponse(w http.ResponseWriter, b []byte) { h := w.Header() h.Set("Content-Type", "image/jpeg") h.Set("Content-Length", strconv.Itoa(len(b)))