mirror of
https://github.com/kerberos-io/agent.git
synced 2026-09-03 16:58:34 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
450d10acf7 | ||
|
|
8a0b5337f3 | ||
|
|
3590a0b39e | ||
|
|
976834cdfd | ||
|
|
d3ede93053 | ||
|
|
58a79f8278 | ||
|
|
422279985f | ||
|
|
99ff750c40 | ||
|
|
13c84a0f36 | ||
|
|
cb6bbe1609 | ||
|
|
b839cd985b |
@@ -231,9 +231,9 @@ Next to attaching the configuration file, it is also possible to override the co
|
||||
| `AGENT_MQTT_PASSWORD` | Password of the MQTT broker. | "" |
|
||||
| `AGENT_REALTIME_PROCESSING` | If `AGENT_REALTIME_PROCESSING` set to `true`, the agent will send key frames to the topic | "" |
|
||||
| `AGENT_REALTIME_PROCESSING_TOPIC` | The topic to which keyframes will be sent in base64 encoded format. | "" |
|
||||
| `AGENT_STUN_URI` | When using WebRTC, you'll need to provide a STUN server. | "stun:turn.kerberos.io:8443" |
|
||||
| `AGENT_STUN_URI` | When using WebRTC, you'll need to provide a STUN server. | "stun:turn-fra1.kerberos.io:3478"|
|
||||
| `AGENT_FORCE_TURN` | Force using a TURN server, by generating relay candidates only. | "false" |
|
||||
| `AGENT_TURN_URI` | When using WebRTC, you'll need to provide a TURN server. | "turn:turn.kerberos.io:8443" |
|
||||
| `AGENT_TURN_URI` | When using WebRTC, you'll need to provide a TURN server. | "turn:turn-fra1.kerberos.io:3478"|
|
||||
| `AGENT_TURN_USERNAME` | TURN username used for WebRTC. | "username1" |
|
||||
| `AGENT_TURN_PASSWORD` | TURN password used for WebRTC. | "password1" |
|
||||
| `AGENT_CLOUD` | Store recordings in Kerberos Hub (s3), Kerberos Vault (kstorage), or Dropbox (dropbox). | "s3" |
|
||||
|
||||
@@ -106,9 +106,9 @@
|
||||
"mqtturi": "tcp://mqtt.kerberos.io:1883",
|
||||
"mqtt_username": "",
|
||||
"mqtt_password": "",
|
||||
"stunuri": "stun:turn.kerberos.io:8443",
|
||||
"turn_force": "false",
|
||||
"turnuri": "turn:turn.kerberos.io:8443",
|
||||
"stunuri": "stun:turn-fra1.kerberos.io:3478",
|
||||
"turnuri": "turn:turn-fra1.kerberos.io:3478",
|
||||
"turn_username": "username1",
|
||||
"turn_password": "password1",
|
||||
"heartbeaturi": "",
|
||||
|
||||
@@ -2,6 +2,7 @@ package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
"github.com/kerberos-io/agent/machinery/src/cloud/livesnapshot"
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
@@ -528,6 +530,7 @@ loop:
|
||||
"onvif_events_list": %s,
|
||||
"cameraConnected": "%s",
|
||||
"hasBackChannel": "%s",
|
||||
"livePreviewHttp": true,
|
||||
"numberoffiles" : "33",
|
||||
"timestamp" : 1564747908,
|
||||
"cameratype" : "IPCamera",
|
||||
@@ -684,7 +687,35 @@ func HandleLiveStreamSD(livestreamCursor *packets.QueueCursor, configuration *mo
|
||||
hubKey = config.HubKey
|
||||
}
|
||||
|
||||
lastLivestreamRequest := int64(0)
|
||||
lastLivestreamRequestMQTT := int64(0)
|
||||
lastLivestreamRequestHTTP := int64(0)
|
||||
|
||||
// HTTP transport (preferred when this agent is paired with a Kerberos
|
||||
// Hub): ship preview frames to hub-api over HTTPS instead of pushing
|
||||
// (large, base64) images through the MQTT broker. Viewers opt in per
|
||||
// session via the "http" transport on their keepalive; the legacy MQTT
|
||||
// push is kept for viewers (older frontends) that don't, and as a fallback.
|
||||
region := ""
|
||||
if config.S3 != nil {
|
||||
region = config.S3.Region
|
||||
}
|
||||
var snapshotPublisher *livesnapshot.Publisher
|
||||
if config.HubURI != "" && config.HubKey != "" {
|
||||
snapshotPublisher = livesnapshot.NewPublisher(livesnapshot.PublisherConfig{
|
||||
HubURI: config.HubURI,
|
||||
HubKey: config.HubKey,
|
||||
HubPrivateKey: config.HubPrivateKey,
|
||||
Region: region,
|
||||
DeviceKey: deviceId,
|
||||
})
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): HTTP preview transport ENABLED; frames go to " + strings.TrimRight(config.HubURI, "/") + "/storage/snapshot when a viewer requests it (kept off MQTT).")
|
||||
} else {
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): HTTP preview transport DISABLED (Hub not configured: HubURI/HubKey empty); preview frames are pushed over MQTT.")
|
||||
}
|
||||
|
||||
// Track the transport actually used so we log only when it changes; the
|
||||
// loop runs once per keyframe and logging every frame would be noise.
|
||||
lastTransport := ""
|
||||
|
||||
var cursorError error
|
||||
var pkt packets.Packet
|
||||
@@ -695,20 +726,74 @@ func HandleLiveStreamSD(livestreamCursor *packets.QueueCursor, configuration *mo
|
||||
continue
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
// Drain both viewer keepalive channels (non-blocking): one for the
|
||||
// HTTP transport, one for the legacy MQTT push.
|
||||
select {
|
||||
case <-communication.HandleLiveSD:
|
||||
lastLivestreamRequest = now
|
||||
lastLivestreamRequestMQTT = now
|
||||
default:
|
||||
}
|
||||
if now-lastLivestreamRequest > 3 {
|
||||
select {
|
||||
case <-communication.HandleLiveSDHTTP:
|
||||
lastLivestreamRequestHTTP = now
|
||||
default:
|
||||
}
|
||||
|
||||
mqttViewerActive := now-lastLivestreamRequestMQTT <= 3
|
||||
httpViewerActive := now-lastLivestreamRequestHTTP <= 3
|
||||
if !mqttViewerActive && !httpViewerActive {
|
||||
continue
|
||||
}
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): Sending base64 encoded images to MQTT.")
|
||||
img, err := rtspClient.DecodePacket(pkt)
|
||||
if err == nil {
|
||||
imageResized, _ := utils.ResizeImage(&img, uint(config.Capture.IPCamera.BaseWidth), uint(config.Capture.IPCamera.BaseHeight))
|
||||
bytes, _ := utils.ImageToBytes(imageResized)
|
||||
|
||||
img, err := rtspClient.DecodePacket(pkt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
imageResized, _ := utils.ResizeImage(&img, uint(config.Capture.IPCamera.BaseWidth), uint(config.Capture.IPCamera.BaseHeight))
|
||||
bytes, _ := utils.ImageToBytes(imageResized)
|
||||
|
||||
// Prefer HTTP for viewers that asked for it. Only if that did not
|
||||
// deliver (Hub not configured, or the upload failed) do we also push
|
||||
// over MQTT, so a new frontend can still fall back to its MQTT path.
|
||||
httpPushed := false
|
||||
var httpErr error
|
||||
if httpViewerActive && snapshotPublisher != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
httpErr = snapshotPublisher.PublishSnapshot(ctx, bytes)
|
||||
if httpErr == nil {
|
||||
httpPushed = true
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
pushMQTT := mqttViewerActive || (httpViewerActive && !httpPushed)
|
||||
|
||||
// Log only when the effective transport changes, so an operator can
|
||||
// tell at a glance whether a device's preview travels over HTTP or
|
||||
// MQTT (and why it fell back) without per-frame log spam.
|
||||
transport := ""
|
||||
if httpPushed {
|
||||
transport = "http"
|
||||
} else if pushMQTT {
|
||||
transport = "mqtt"
|
||||
}
|
||||
if transport != "" && transport != lastTransport {
|
||||
if transport == "http" {
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): delivering preview frames over HTTP for device " + deviceId + ".")
|
||||
} else {
|
||||
reason := "viewer requested MQTT (older frontend)"
|
||||
if httpViewerActive && snapshotPublisher == nil {
|
||||
reason = "viewer asked for HTTP but Hub is not configured"
|
||||
} else if httpViewerActive && httpErr != nil {
|
||||
reason = "HTTP upload failed, falling back: " + httpErr.Error()
|
||||
}
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): delivering preview frames over MQTT for device " + deviceId + " (" + reason + ").")
|
||||
}
|
||||
lastTransport = transport
|
||||
}
|
||||
|
||||
if pushMQTT {
|
||||
log.Log.Debug("cloud.HandleLiveStreamSD(): Sending base64 encoded images to MQTT.")
|
||||
chunking := config.Capture.LiveviewChunking
|
||||
|
||||
if chunking == "true" {
|
||||
|
||||
151
machinery/src/cloud/livesnapshot/publisher.go
Normal file
151
machinery/src/cloud/livesnapshot/publisher.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Package livesnapshot implements the agent-side producer for the live-view
|
||||
// "preview" (SD) mode over HTTP.
|
||||
//
|
||||
// Historically the preview pipeline shipped each resized keyframe (a base64
|
||||
// JPEG, often chunked) to viewers over the MQTT broker. MQTT is a control plane
|
||||
// for small messages, so pushing ~1 image/second of base64 image data per
|
||||
// watched camera congests the broker and delays genuine control traffic. This
|
||||
// package moves those frames off MQTT: the agent POSTs the latest resized JPEG
|
||||
// straight to hub-api over plain HTTPS (outbound only), and viewers fetch it
|
||||
// back with their session token. Only the tiny "a viewer is watching" keepalive
|
||||
// stays on MQTT.
|
||||
//
|
||||
// The wire contract (agent -> hub-api) deliberately mirrors the live HLS ingest
|
||||
// and the existing storage-upload convention (X-Kerberos-Storage-Device plus the
|
||||
// Hub public/private key auth headers). hub-api authenticates the agent and
|
||||
// stores the frame in an ephemeral, short-TTL per-device slot which it serves
|
||||
// straight back to authorized viewers; the frame never enters the vault or the
|
||||
// recordings collection.
|
||||
//
|
||||
// Like live HLS segments, a preview frame is worthless once stale: a frame that
|
||||
// fails to upload is superseded by the next one a second later, so the publisher
|
||||
// is fire-and-forget and drops on failure (logged) rather than retrying.
|
||||
package livesnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// snapshotIngestPath is the hub-api endpoint that accepts the latest preview
|
||||
// frame and stores it in the device's ephemeral snapshot slot (mirrors the
|
||||
// /storage/live live-HLS ingest convention).
|
||||
snapshotIngestPath = "/storage/snapshot"
|
||||
|
||||
contentTypeJPEG = "image/jpeg"
|
||||
|
||||
// Header names for the snapshot ingest contract (shared with live HLS / storage).
|
||||
headerHubPublicKey = "X-Kerberos-Hub-PublicKey"
|
||||
headerHubPrivateKey = "X-Kerberos-Hub-PrivateKey"
|
||||
headerHubRegion = "X-Kerberos-Hub-Region"
|
||||
headerStorageDevice = "X-Kerberos-Storage-Device"
|
||||
|
||||
// defaultPublishTimeout bounds a single snapshot upload. Preview frames are
|
||||
// produced roughly once a second from a single goroutine, so an upload that
|
||||
// cannot land in a few seconds is abandoned rather than allowed to back up the
|
||||
// preview loop behind a slow request.
|
||||
defaultPublishTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
// PublisherConfig carries the hub endpoint and credentials needed to ship
|
||||
// preview frames. It is populated from the agent's models.Config (the same
|
||||
// HubURI/HubKey/HubPrivateKey used by recordings and live HLS).
|
||||
type PublisherConfig struct {
|
||||
HubURI string // base hub-api URL, e.g. https://api.hub.example.com
|
||||
HubKey string // Hub public key (X-Kerberos-Hub-PublicKey)
|
||||
HubPrivateKey string // Hub private key (X-Kerberos-Hub-PrivateKey)
|
||||
Region string // storage region (X-Kerberos-Hub-Region), may be empty
|
||||
DeviceKey string // device/camera key (X-Kerberos-Storage-Device)
|
||||
|
||||
// Timeout optionally overrides defaultPublishTimeout (used by tests).
|
||||
Timeout time.Duration
|
||||
// HTTPClient optionally injects a client (used by tests). When nil a
|
||||
// redirect-credential-stripping client is created.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Publisher ships the latest preview frame to hub-api over plain HTTP POST.
|
||||
//
|
||||
// It is safe for sequential use from a single live-stream goroutine. PublishSnapshot
|
||||
// is fire-and-forget: it returns an error for the caller to log, but the caller is
|
||||
// expected to continue (drop-on-fail) rather than retry.
|
||||
type Publisher struct {
|
||||
cfg PublisherConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewPublisher builds a Publisher. The HTTP client strips the Hub credential
|
||||
// headers on a cross-host redirect (net/http does this for standard auth headers
|
||||
// but not custom-named ones), matching the recording/live-HLS upload clients.
|
||||
func NewPublisher(cfg PublisherConfig) *Publisher {
|
||||
client := cfg.HTTPClient
|
||||
if client == nil {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultPublishTimeout
|
||||
}
|
||||
client = &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: stripHubCredentialsOnCrossHostRedirect,
|
||||
}
|
||||
}
|
||||
return &Publisher{cfg: cfg, client: client}
|
||||
}
|
||||
|
||||
// PublishSnapshot uploads a single resized preview frame (JPEG) as the device's
|
||||
// latest snapshot. It overwrites whatever frame was there before, so viewers
|
||||
// always fetch the most recent frame.
|
||||
func (p *Publisher) PublishSnapshot(ctx context.Context, jpeg []byte) error {
|
||||
if p.cfg.HubURI == "" {
|
||||
return fmt.Errorf("livesnapshot: HubURI not configured")
|
||||
}
|
||||
if len(jpeg) == 0 {
|
||||
return fmt.Errorf("livesnapshot: empty snapshot body")
|
||||
}
|
||||
|
||||
url := strings.TrimRight(p.cfg.HubURI, "/") + snapshotIngestPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jpeg))
|
||||
if err != nil {
|
||||
return fmt.Errorf("livesnapshot: build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", contentTypeJPEG)
|
||||
req.Header.Set(headerStorageDevice, p.cfg.DeviceKey)
|
||||
req.Header.Set(headerHubPublicKey, p.cfg.HubKey)
|
||||
req.Header.Set(headerHubPrivateKey, p.cfg.HubPrivateKey)
|
||||
req.Header.Set(headerHubRegion, p.cfg.Region)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("livesnapshot: upload snapshot: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("livesnapshot: upload snapshot rejected: %s", resp.Status)
|
||||
}
|
||||
log.Log.Debug("livesnapshot.Publisher.PublishSnapshot(): shipped preview frame for device " + p.cfg.DeviceKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripHubCredentialsOnCrossHostRedirect removes the Hub credential headers when
|
||||
// a redirect crosses to a different host. net/http strips standard sensitive
|
||||
// headers on a cross-host redirect but not custom-named ones, so without this the
|
||||
// Hub keys could leak to a redirect target.
|
||||
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del(headerHubPrivateKey)
|
||||
req.Header.Del(headerHubPublicKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -70,6 +70,7 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
|
||||
communication.HandleUpload = make(chan string, 1)
|
||||
communication.HandleHeartBeat = make(chan string, 1)
|
||||
communication.HandleLiveSD = make(chan int64, 1)
|
||||
communication.HandleLiveSDHTTP = make(chan int64, 1)
|
||||
communication.HandleLiveHDKeepalive = make(chan string, 1)
|
||||
communication.HandleLiveHDPeers = make(chan string, 1)
|
||||
communication.HandleLiveHLS = make(chan int64, 1)
|
||||
@@ -177,19 +178,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
configuration.Config.Capture.IPCamera.Height = height
|
||||
|
||||
// Set the liveview width and height, this is used for the liveview and motion regions (drawing on the hub).
|
||||
baseWidth := config.Capture.IPCamera.BaseWidth
|
||||
baseHeight := config.Capture.IPCamera.BaseHeight
|
||||
// If the liveview height is not set, we will calculate it based on the width and aspect ratio of the camera.
|
||||
if baseWidth > 0 && baseHeight == 0 {
|
||||
widthAspectRatio := float64(baseWidth) / float64(width)
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = int(float64(height) * widthAspectRatio)
|
||||
} else if baseHeight > 0 && baseWidth > 0 {
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = baseHeight
|
||||
configuration.Config.Capture.IPCamera.BaseWidth = baseWidth
|
||||
} else {
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = height
|
||||
configuration.Config.Capture.IPCamera.BaseWidth = width
|
||||
}
|
||||
// ResolveBaseDimensions gates the aspect-ratio compute on width/height > 0
|
||||
// so a not-yet-probed stream can't poison the dimensions and crash resize.
|
||||
configuration.Config.Capture.IPCamera.BaseWidth, configuration.Config.Capture.IPCamera.BaseHeight =
|
||||
utils.ResolveBaseDimensions(config.Capture.IPCamera.BaseWidth, config.Capture.IPCamera.BaseHeight, width, height)
|
||||
|
||||
// Set the SPS and PPS values in the configuration.
|
||||
configuration.Config.Capture.IPCamera.SPSNALUs = [][]byte{videoStream.SPS}
|
||||
@@ -247,19 +239,8 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
|
||||
// If we have a substream, we need to set the width and height of the substream. (so we will override above information)
|
||||
// Set the liveview width and height, this is used for the liveview and motion regions (drawing on the hub).
|
||||
baseWidth := config.Capture.IPCamera.BaseWidth
|
||||
baseHeight := config.Capture.IPCamera.BaseHeight
|
||||
// If the liveview height is not set, we will calculate it based on the width and aspect ratio of the camera.
|
||||
if baseWidth > 0 && baseHeight == 0 {
|
||||
widthAspectRatio := float64(baseWidth) / float64(width)
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = int(float64(height) * widthAspectRatio)
|
||||
} else if baseHeight > 0 && baseWidth > 0 {
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = baseHeight
|
||||
configuration.Config.Capture.IPCamera.BaseWidth = baseWidth
|
||||
} else {
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = height
|
||||
configuration.Config.Capture.IPCamera.BaseWidth = width
|
||||
}
|
||||
configuration.Config.Capture.IPCamera.BaseWidth, configuration.Config.Capture.IPCamera.BaseHeight =
|
||||
utils.ResolveBaseDimensions(config.Capture.IPCamera.BaseWidth, config.Capture.IPCamera.BaseHeight, width, height)
|
||||
}
|
||||
|
||||
// We are creating a queue to store the RTSP frames in, these frames will be
|
||||
|
||||
@@ -37,6 +37,7 @@ type Communication struct {
|
||||
HandleUpload chan string
|
||||
HandleHeartBeat chan string
|
||||
HandleLiveSD chan int64
|
||||
HandleLiveSDHTTP chan int64
|
||||
HandleLiveHDKeepalive chan string
|
||||
HandleLiveHDHandshake chan LiveHDHandshake
|
||||
HandleLiveHDPeers chan string
|
||||
|
||||
@@ -171,6 +171,12 @@ type UpdateConfigPayload struct {
|
||||
// We received a request SD stream request
|
||||
type RequestSDStreamPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp
|
||||
// Transport selects how the agent should deliver the preview frames for this
|
||||
// viewer. "http" asks the agent to POST frames to hub-api (keeping them off
|
||||
// MQTT); empty/absent means the legacy MQTT image push. Older agents simply
|
||||
// ignore this unknown field and keep doing MQTT, and older frontends never set
|
||||
// it — so new/old agents and frontends interoperate in every combination.
|
||||
Transport string `json:"transport,omitempty"`
|
||||
}
|
||||
|
||||
// We received a live HLS stream request. Like SD it is a simple viewer
|
||||
|
||||
@@ -550,9 +550,20 @@ func HandleRequestSDStream(mqttClient mqtt.Client, hubKey string, payload models
|
||||
|
||||
if requestSDStreamPayload.Timestamp != 0 {
|
||||
if communication.CameraConnected {
|
||||
select {
|
||||
case communication.HandleLiveSD <- time.Now().Unix():
|
||||
default:
|
||||
// A viewer that opted into the HTTP transport is signalled on a separate
|
||||
// channel so the producer ships its frames to hub-api over HTTP instead of
|
||||
// publishing them over MQTT. Any other (or absent) transport keeps the
|
||||
// legacy MQTT image push, so older frontends behave exactly as before.
|
||||
if requestSDStreamPayload.Transport == "http" {
|
||||
select {
|
||||
case communication.HandleLiveSDHTTP <- time.Now().Unix():
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case communication.HandleLiveSD <- time.Now().Unix():
|
||||
default:
|
||||
}
|
||||
}
|
||||
log.Log.Info("routers.mqtt.main.HandleRequestSDStream(): received request to livestream.")
|
||||
} else {
|
||||
|
||||
@@ -427,12 +427,49 @@ func ResizeImage(img image.Image, newWidth uint, newHeight uint) (*image.Image,
|
||||
return nil, errors.New("image is nil")
|
||||
}
|
||||
|
||||
// Callers cast int->uint, so a negative or poisoned int (e.g. MinInt from
|
||||
// `int(float * +Inf)` when the source width is 0) wraps to a near-MaxUint
|
||||
// value here and crashes nfnt/resize's allocator with "makeslice: len out
|
||||
// of range". Clamp anything past a sane camera ceiling to 0 ("auto" in
|
||||
// nfnt — preserves aspect from the source).
|
||||
const maxDim uint = 8192
|
||||
if newWidth > maxDim {
|
||||
newWidth = 0
|
||||
}
|
||||
if newHeight > maxDim {
|
||||
newHeight = 0
|
||||
}
|
||||
|
||||
// resize to width 640 using Lanczos resampling
|
||||
// and preserve aspect ratio
|
||||
m := resize.Resize(newWidth, newHeight, img, resize.Lanczos3)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// ResolveBaseDimensions resolves the liveview/motion base dimensions for a
|
||||
// stream given the (optionally configured) base width/height and the camera's
|
||||
// probed source width/height. It returns the width and height that should be
|
||||
// stored on the configuration.
|
||||
//
|
||||
// The aspect-ratio branch is gated on width>0 && height>0: a not-yet-probed
|
||||
// stream has width=height=0, which previously made the ratio +Inf and
|
||||
// int(float * +Inf) yield MinInt. That poisoned value, later cast to uint at
|
||||
// the ResizeImage call sites, wrapped to ~MaxUint and crashed resize with
|
||||
// "makeslice: len out of range". When the source isn't probed yet we fall back
|
||||
// to the source dimensions (0,0 -> "auto") instead.
|
||||
func ResolveBaseDimensions(baseWidth, baseHeight, width, height int) (int, int) {
|
||||
if baseWidth > 0 && baseHeight == 0 && width > 0 && height > 0 {
|
||||
// Derive the height from the configured width and the source aspect ratio.
|
||||
widthAspectRatio := float64(baseWidth) / float64(width)
|
||||
return baseWidth, int(float64(height) * widthAspectRatio)
|
||||
} else if baseHeight > 0 && baseWidth > 0 {
|
||||
// Both base dimensions are configured; honor them as-is.
|
||||
return baseWidth, baseHeight
|
||||
}
|
||||
// Nothing usable configured (or source not probed yet): use source dimensions.
|
||||
return width, height
|
||||
}
|
||||
|
||||
func ResizeHeightWithAspectRatio(newWidth int, width int, height int) (int, int) {
|
||||
if newWidth <= 0 || width <= 0 || height <= 0 {
|
||||
return width, height
|
||||
|
||||
124
machinery/src/utils/resize_test.go
Normal file
124
machinery/src/utils/resize_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveBaseDimensions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseWidth, baseHeight int
|
||||
width, height int
|
||||
wantWidth, wantHeight int
|
||||
}{
|
||||
{
|
||||
name: "base width set, height derived from aspect ratio",
|
||||
baseWidth: 640, baseHeight: 0,
|
||||
width: 1920, height: 1080,
|
||||
wantWidth: 640, wantHeight: 360,
|
||||
},
|
||||
{
|
||||
name: "both base dimensions configured are honored",
|
||||
baseWidth: 640, baseHeight: 480,
|
||||
width: 1920, height: 1080,
|
||||
wantWidth: 640, wantHeight: 480,
|
||||
},
|
||||
{
|
||||
name: "no base configured falls back to source dimensions",
|
||||
baseWidth: 0, baseHeight: 0,
|
||||
width: 1920, height: 1080,
|
||||
wantWidth: 1920, wantHeight: 1080,
|
||||
},
|
||||
{
|
||||
// Regression: a not-yet-probed stream has width=height=0. The old
|
||||
// aspect-ratio branch divided by zero (float * +Inf -> MinInt) and
|
||||
// poisoned BaseHeight, later crashing resize with makeslice panic.
|
||||
name: "unprobed stream (width=0) does not poison dimensions",
|
||||
baseWidth: 640, baseHeight: 0,
|
||||
width: 0, height: 0,
|
||||
wantWidth: 0, wantHeight: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotWidth, gotHeight := ResolveBaseDimensions(tt.baseWidth, tt.baseHeight, tt.width, tt.height)
|
||||
if gotWidth != tt.wantWidth || gotHeight != tt.wantHeight {
|
||||
t.Fatalf("ResolveBaseDimensions(%d,%d,%d,%d) = (%d,%d), want (%d,%d)",
|
||||
tt.baseWidth, tt.baseHeight, tt.width, tt.height,
|
||||
gotWidth, gotHeight, tt.wantWidth, tt.wantHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBaseDimensionsNeverNegative(t *testing.T) {
|
||||
// Whatever the inputs, the resolved dimensions must never be negative,
|
||||
// otherwise the uint cast at the resize call sites wraps to ~MaxUint.
|
||||
for _, c := range [][4]int{
|
||||
{640, 0, 0, 0},
|
||||
{640, 0, 0, 1080},
|
||||
{640, 0, 1920, 0},
|
||||
{0, 0, 0, 0},
|
||||
} {
|
||||
w, h := ResolveBaseDimensions(c[0], c[1], c[2], c[3])
|
||||
if w < 0 || h < 0 {
|
||||
t.Fatalf("ResolveBaseDimensions(%v) produced negative dims (%d,%d)", c, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageClampsPoisonedDimensions(t *testing.T) {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 320, 240))
|
||||
|
||||
// uint(math.MinInt) is the value produced when a poisoned int (from
|
||||
// int(float * +Inf)) is cast to uint at a call site. It must not panic
|
||||
// nfnt/resize's allocator; it should fall back to source-aspect resize.
|
||||
// Compute via a runtime int so the conversion doesn't overflow at compile time.
|
||||
minInt := math.MinInt
|
||||
poison := uint(minInt)
|
||||
|
||||
resized, err := ResizeImage(src, poison, poison)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resized == nil {
|
||||
t.Fatalf("expected an image, got nil")
|
||||
}
|
||||
b := (*resized).Bounds()
|
||||
if b.Dx() != 320 || b.Dy() != 240 {
|
||||
t.Fatalf("poisoned dims should fall back to source size, got %dx%d", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageClampsAboveCameraCeiling(t *testing.T) {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 320, 240))
|
||||
|
||||
// A width beyond any sane camera resolution is treated as "auto" (0).
|
||||
resized, err := ResizeImage(src, 100000, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
b := (*resized).Bounds()
|
||||
if b.Dx() != 320 || b.Dy() != 240 {
|
||||
t.Fatalf("oversized width should fall back to source size, got %dx%d", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageNormalResizeStillWorks(t *testing.T) {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 1920, 1080))
|
||||
|
||||
resized, err := ResizeImage(src, 640, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
b := (*resized).Bounds()
|
||||
if b.Dx() != 640 {
|
||||
t.Fatalf("expected width 640, got %d", b.Dx())
|
||||
}
|
||||
if b.Dy() != 360 {
|
||||
t.Fatalf("expected aspect-preserved height 360, got %d", b.Dy())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user