Compare commits

...

11 Commits

Author SHA1 Message Date
Cédric Verstraeten
450d10acf7 Merge pull request #293 from kerberos-io/feature/live-preview-http-transfer
feature/live-preview-http-transfer
2026-06-24 11:53:55 +02:00
Cédric Verstraeten
8a0b5337f3 Fix default TURN URI port in README
Corrects the default AGENT_TURN_URI value in the configuration table from port 348 to 3478. This fixes a typo and aligns the TURN URI with the standard/STUN port used elsewhere in the README.
2026-06-24 11:53:09 +02:00
Cédric Verstraeten
3590a0b39e Merge branch 'master' into feature/live-preview-http-transfer 2026-06-24 11:52:21 +02:00
Cédric Verstraeten
976834cdfd Enhance live preview transport logging
Add livePreviewHttp flag to the device payload and log whether HTTP preview transport is enabled or disabled. Track the transport actually used (HTTP vs MQTT) with a lastTransport variable to avoid per-frame log spam and emit informative logs only when the transport changes, including fallback reasons (Hub not configured or HTTP upload failure). Capture HTTP publish errors to include in fallback messages, and lower the per-frame MQTT publish log level from Info to Debug. Small comment added explaining the logging behavior.
2026-06-24 08:05:32 +02:00
Cédric Verstraeten
d3ede93053 Merge pull request #291 from sharedjourney/fix/liveview-makeslice-panic
fix(machinery): prevent makeslice panic when liveview dims are poisoned
2026-06-23 12:59:52 +02:00
Cédric Verstraeten
58a79f8278 Merge pull request #294 from kerberos-io/feature/update-readme-turn-info
feature/update-readme-turn-info
2026-06-23 09:27:11 +02:00
Cédric Verstraeten
422279985f Update STUN and TURN server URIs in README 2026-06-23 09:21:27 +02:00
Cédric Verstraeten
99ff750c40 Publish live preview frames to Hub over HTTP
Add an HTTP-based live snapshot publisher to send resized SD preview frames directly to hub-api, reducing MQTT broker load. Introduce livesnapshot.Publisher with credential-stripping redirect handling and a publish timeout. Split live-preview signaling into two channels (HandleLiveSD and HandleLiveSDHTTP), add a Transport field to RequestSDStreamPayload, and update the MQTT request handler to signal the correct channel. Update cloud.HandleLiveStreamSD to prefer HTTP uploads for viewers that requested it, falling back to the legacy MQTT image push when needed.
2026-06-23 09:01:22 +02:00
Cédric Verstraeten
13c84a0f36 Merge pull request #292 from kerberos-io/feature/update-turn-uri
Change STUN and TURN URIs in config.json
2026-06-22 21:21:30 +02:00
Cédric Verstraeten
cb6bbe1609 Change STUN and TURN URIs in config.json
Updated STUN and TURN URIs for improved connectivity.
2026-06-22 21:12:52 +02:00
Sebastian Norling
b839cd985b fix(machinery): prevent makeslice panic when liveview dims are poisoned
Two stacked defenses against "runtime error: makeslice: len out of range"
observed in HandleLiveStreamSD on agents publishing snapshots over MQTT.

Extract the liveview base-dimension logic into utils.ResolveBaseDimensions,
gating the aspect-ratio compute on width>0 && height>0. A camera that hasn't
probed yet has Width=0, which made the ratio +Inf and int(float*+Inf) yield
MinInt - later cast to uint at ResizeImage call sites, wrapping to ~MaxUint
and crashing nfnt/resize's allocator. The helper also de-duplicates the two
identical inline blocks in RunAgent (main and sub stream).

utils/main.go: clamp ResizeImage's newWidth/newHeight inputs above a sane
camera ceiling (8192) to 0 ("auto"). Covers all three call sites (cloud,
capture, websocket) in one place so any future caller passing a wrapped or
negative uint silently falls back to source-aspect resize instead of panicking.

Driven by tests in utils/resize_test.go (RED/GREEN).
2026-06-22 15:12:27 +02:00
10 changed files with 437 additions and 41 deletions

View File

@@ -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" |

View File

@@ -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": "",

View File

@@ -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" {

View 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View 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())
}
}