Merge pull request #304 from kerberos-io/feature/tweak-remote-recording

feature/tweak-remote-recording
This commit is contained in:
Cédric Verstraeten
2026-07-13 21:17:50 +02:00
committed by GitHub
7 changed files with 523 additions and 120 deletions

View File

@@ -50,6 +50,55 @@ func publishRecordingState(mqttClient mqtt.Client, hubKey string, configuration
}
}
const (
// manualRecordingHeartbeatTimeout is how long the agent keeps a manual
// (live-view / remote) recording alive after the LAST viewer heartbeat. The
// frontend re-sends the record command every ~15s while the user stays on the
// page; if several heartbeats are missed (the viewer closed the tab, went idle
// or lost connectivity) the recorder auto-stops the recording so the camera
// doesn't record forever when the "stop" message never arrives.
manualRecordingHeartbeatTimeout = 45 * time.Second
// manualRecordingMaxDuration caps a single manual recording so a forgotten
// record button can't record indefinitely even while the viewer keeps sending
// heartbeats. After this the recording auto-stops and the viewer must press
// record again to continue.
manualRecordingMaxDuration = 5 * time.Minute
)
// manualRecordingExpired reports whether an active manual (live-view) recording
// has outlived its viewer heartbeat window or the maximum duration cap. When it
// has, it clears the manual-recording state (so the motion recorder lets the
// current clip close normally and broadcasts recording:false) and returns true.
// It is a no-op returning false when no manual recording is active.
func manualRecordingExpired(communication *models.Communication, now int64) bool {
if communication.IsRecordingManual.IsNotSet() {
return false
}
manualStart := communication.RecordingManualStart.Load()
maxDurationReached := manualStart > 0 && now-manualStart > manualRecordingMaxDuration.Milliseconds()
// The heartbeat timeout only applies once the viewer has proven it supports
// heartbeats (an older frontend that starts a recording but never heartbeats
// still records up to the max-duration cap instead of being cut off early).
heartbeatExpired := false
if communication.RecordingManualHeartbeatSeen.IsSet() {
lastHeartbeat := communication.RecordingManualHeartbeat.Load()
heartbeatExpired = lastHeartbeat > 0 && now-lastHeartbeat > manualRecordingHeartbeatTimeout.Milliseconds()
}
if !heartbeatExpired && !maxDurationReached {
return false
}
if heartbeatExpired {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, no viewer heartbeat within timeout.")
} else {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, maximum duration reached.")
}
communication.IsRecordingManual.UnSet()
communication.RecordingManualHeartbeat.Store(0)
communication.RecordingManualStart.Store(0)
communication.RecordingManualHeartbeatSeen.UnSet()
return true
}
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
autoClean := configuration.Config.AutoClean
if autoClean != "true" {
@@ -227,6 +276,9 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// Start each capture session with manual recording off, so a leftover
// request from before a restart/reconnect doesn't silently persist.
communication.IsRecordingManual.UnSet()
communication.RecordingManualHeartbeat.Store(0)
communication.RecordingManualStart.Store(0)
communication.RecordingManualHeartbeatSeen.UnSet()
if config.Capture.Recording == "false" {
log.Log.Info("capture.main.HandleRecordStream(): disabled, we will not record anything.")
@@ -674,7 +726,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// motion timestamp every iteration so the post-recording timeout
// never fires. The clip still rolls over at maxRecordingPeriod and
// is restarted below, until the viewer stops the manual recording.
if communication.IsRecordingManual.IsSet() {
// It also auto-stops when the viewer's heartbeat lapses (closed page
// or idle) or the max remote-recording duration is reached, so a
// missed "stop" message can't keep the camera recording forever.
if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, now) {
motionTimestamp = now
}
@@ -751,8 +806,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// If the viewer still has a manual recording running, this clip just
// rolled over at the max length — immediately kick off the next
// segment so recording stays continuous until they stop it.
if communication.IsRecordingManual.IsSet() {
// segment so recording stays continuous until they stop it. Skip the
// restart when the recording has expired (heartbeat lapsed or max
// duration reached), so it ends here instead of recording forever.
if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, time.Now().UnixMilli()) {
select {
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: time.Now().Unix(), NumberOfChanges: 100000000}:
default:

View File

@@ -6,7 +6,6 @@ import (
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"strings"
@@ -219,6 +218,16 @@ func GetSystemInfo() (models.System, error) {
return system, nil
}
// rawJSONOrEmptyArray returns pre-marshalled JSON bytes as a json.RawMessage.
// When the input is empty it falls back to an empty JSON array so the
// surrounding payload always stays valid JSON.
func rawJSONOrEmptyArray(b []byte) json.RawMessage {
if len(b) == 0 {
return json.RawMessage("[]")
}
return json.RawMessage(b)
}
func HandleHeartBeat(configuration *models.Configuration, communication *models.Communication, uptimeStart time.Time) {
log.Log.Debug("cloud.HandleHeartBeat(): started")
@@ -472,6 +481,14 @@ loop:
hasBackChannel = "true"
}
// Whether this camera records continuously (24/7) rather than on
// motion. The Hub live view uses this to disable the manual record
// button, which is a no-op in continuous mode (already recording).
continuousRecording := "false"
if config.Capture.Continuous == "true" {
continuousRecording = "true"
}
hub_encryption := "false"
if config.HubEncryption == "true" {
hub_encryption = "true"
@@ -496,48 +513,98 @@ loop:
// We need a hub URI and hub public key before we will send a heartbeat
if hubURI != "" && key != "" {
var object = fmt.Sprintf(`{
"key" : "%s",
"version" : "%s",
"hub_encryption": "%s",
"e2e_encryption": "%s",
"release" : "%s",
"cpuid" : "%s",
"clouduser" : "%s",
"cloudpublickey" : "%s",
"cameraname" : "%s",
"enterprise" : %t,
"hostname" : "%s",
"architecture" : "%s",
"totalMemory" : "%d",
"usedMemory" : "%d",
"freeMemory" : "%d",
"processMemory" : "%d",
"mac_list" : %s,
"ip_list" : %s,
"board" : "",
"disk1size" : "%s",
"disk3size" : "%s",
"diskvdasize" : "%s",
"uptime" : "%s",
"boot_time" : "%s",
"siteID" : "%s",
"onvif" : "%s",
"onvif_zoom" : "%s",
"onvif_pantilt" : "%s",
"onvif_presets": "%s",
"onvif_presets_list": %s,
"onvif_events_list": %s,
"cameraConnected": "%s",
"hasBackChannel": "%s",
"livePreviewHttp": true,
"numberoffiles" : "33",
"timestamp" : 1564747908,
"cameratype" : "IPCamera",
"docker" : true,
"kios" : false,
"raspberrypi" : false
}`, config.Key, kerberosAgentVersion, hub_encryption, e2e_encryption, system.Version, system.CPUId, username, key, name, isEnterprise, system.Hostname, system.Architecture, system.TotalMemory, system.UsedMemory, system.FreeMemory, system.ProcessUsedMemory, macs, ips, "0", "0", "0", uptimeString, boottimeString, config.HubSite, onvifEnabled, onvifZoom, onvifPanTilt, onvifPresets, onvifPresetsList, onvifEventsList, cameraConnected, hasBackChannel)
heartbeat := struct {
Key string `json:"key"`
Version string `json:"version"`
HubEncryption string `json:"hub_encryption"`
E2EEncryption string `json:"e2e_encryption"`
Release string `json:"release"`
CPUId string `json:"cpuid"`
CloudUser string `json:"clouduser"`
CloudPublicKey string `json:"cloudpublickey"`
CameraName string `json:"cameraname"`
Enterprise bool `json:"enterprise"`
Hostname string `json:"hostname"`
Architecture string `json:"architecture"`
TotalMemory string `json:"totalMemory"`
UsedMemory string `json:"usedMemory"`
FreeMemory string `json:"freeMemory"`
ProcessMemory string `json:"processMemory"`
MacList json.RawMessage `json:"mac_list"`
IPList json.RawMessage `json:"ip_list"`
Board string `json:"board"`
Disk1Size string `json:"disk1size"`
Disk3Size string `json:"disk3size"`
DiskVdaSize string `json:"diskvdasize"`
Uptime string `json:"uptime"`
BootTime string `json:"boot_time"`
SiteID string `json:"siteID"`
Onvif string `json:"onvif"`
OnvifZoom string `json:"onvif_zoom"`
OnvifPanTilt string `json:"onvif_pantilt"`
OnvifPresets string `json:"onvif_presets"`
OnvifPresetsList json.RawMessage `json:"onvif_presets_list"`
OnvifEventsList json.RawMessage `json:"onvif_events_list"`
CameraConnected string `json:"cameraConnected"`
HasBackChannel string `json:"hasBackChannel"`
ContinuousRecording string `json:"continuousRecording"`
LivePreviewHTTP bool `json:"livePreviewHttp"`
NumberOfFiles string `json:"numberoffiles"`
Timestamp int64 `json:"timestamp"`
CameraType string `json:"cameratype"`
Docker bool `json:"docker"`
Kios bool `json:"kios"`
RaspberryPi bool `json:"raspberrypi"`
}{
Key: config.Key,
Version: kerberosAgentVersion,
HubEncryption: hub_encryption,
E2EEncryption: e2e_encryption,
Release: system.Version,
CPUId: system.CPUId,
CloudUser: username,
CloudPublicKey: key,
CameraName: name,
Enterprise: isEnterprise,
Hostname: system.Hostname,
Architecture: system.Architecture,
TotalMemory: strconv.FormatUint(system.TotalMemory, 10),
UsedMemory: strconv.FormatUint(system.UsedMemory, 10),
FreeMemory: strconv.FormatUint(system.FreeMemory, 10),
ProcessMemory: strconv.FormatUint(system.ProcessUsedMemory, 10),
MacList: rawJSONOrEmptyArray(macs),
IPList: rawJSONOrEmptyArray(ips),
Board: "",
Disk1Size: "0",
Disk3Size: "0",
DiskVdaSize: "0",
Uptime: uptimeString,
BootTime: boottimeString,
SiteID: config.HubSite,
Onvif: onvifEnabled,
OnvifZoom: onvifZoom,
OnvifPanTilt: onvifPanTilt,
OnvifPresets: onvifPresets,
OnvifPresetsList: rawJSONOrEmptyArray(onvifPresetsList),
OnvifEventsList: rawJSONOrEmptyArray(onvifEventsList),
CameraConnected: cameraConnected,
HasBackChannel: hasBackChannel,
ContinuousRecording: continuousRecording,
LivePreviewHTTP: true,
NumberOfFiles: "33",
Timestamp: 1564747908,
CameraType: "IPCamera",
Docker: true,
Kios: false,
RaspberryPi: false,
}
objectBytes, err := json.Marshal(heartbeat)
if err != nil {
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling heartbeat: " + err.Error())
objectBytes = []byte("{}")
}
object := string(objectBytes)
// Get the private key to encrypt the data using symmetric encryption: AES.
privateKey := config.HubPrivateKey
@@ -551,11 +618,21 @@ loop:
// Base64 encode the encrypted data.
encryptedBase64 := base64.StdEncoding.EncodeToString(encrypted)
object = fmt.Sprintf(`{
"cloudpublicKey": "%s",
"encrypted" : %t,
"encryptedData" : "%s"
}`, config.HubKey, true, encryptedBase64)
encryptedPayload := struct {
CloudPublicKey string `json:"cloudpublicKey"`
Encrypted bool `json:"encrypted"`
EncryptedData string `json:"encryptedData"`
}{
CloudPublicKey: config.HubKey,
Encrypted: true,
EncryptedData: encryptedBase64,
}
encryptedBytes, err := json.Marshal(encryptedPayload)
if err != nil {
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling encrypted heartbeat: " + err.Error())
encryptedBytes = []byte("{}")
}
object = string(encryptedBytes)
}
var jsonStr = []byte(object)
@@ -586,43 +663,86 @@ loop:
secretAccessKey := config.KStorage.SecretAccessKey
if vaultURI != "" && accessKey != "" && secretAccessKey != "" {
var object = fmt.Sprintf(`{
"key" : "%s",
"version" : "%s",
"release" : "%s",
"cpuid" : "%s",
"clouduser" : "%s",
"cloudpublickey" : "%s",
"cameraname" : "%s",
"enterprise" : %t,
"hostname" : "%s",
"architecture" : "%s",
"totalMemory" : "%d",
"usedMemory" : "%d",
"freeMemory" : "%d",
"processMemory" : "%d",
"mac_list" : %s,
"ip_list" : %s,
"board" : "",
"disk1size" : "%s",
"disk3size" : "%s",
"diskvdasize" : "%s",
"uptime" : "%s",
"boot_time" : "%s",
"siteID" : "%s",
"onvif" : "%s",
"onvif_zoom" : "%s",
"onvif_pantilt" : "%s",
"onvif_presets": "%s",
"onvif_presets_list": %s,
"cameraConnected": "%s",
"numberoffiles" : "33",
"timestamp" : 1564747908,
"cameratype" : "IPCamera",
"docker" : true,
"kios" : false,
"raspberrypi" : false
}`, config.Key, kerberosAgentVersion, system.Version, system.CPUId, username, key, name, isEnterprise, system.Hostname, system.Architecture, system.TotalMemory, system.UsedMemory, system.FreeMemory, system.ProcessUsedMemory, macs, ips, "0", "0", "0", uptimeString, boottimeString, config.HubSite, onvifEnabled, onvifZoom, onvifPanTilt, onvifPresets, onvifPresetsList, cameraConnected)
heartbeat := struct {
Key string `json:"key"`
Version string `json:"version"`
Release string `json:"release"`
CPUId string `json:"cpuid"`
CloudUser string `json:"clouduser"`
CloudPublicKey string `json:"cloudpublickey"`
CameraName string `json:"cameraname"`
Enterprise bool `json:"enterprise"`
Hostname string `json:"hostname"`
Architecture string `json:"architecture"`
TotalMemory string `json:"totalMemory"`
UsedMemory string `json:"usedMemory"`
FreeMemory string `json:"freeMemory"`
ProcessMemory string `json:"processMemory"`
MacList json.RawMessage `json:"mac_list"`
IPList json.RawMessage `json:"ip_list"`
Board string `json:"board"`
Disk1Size string `json:"disk1size"`
Disk3Size string `json:"disk3size"`
DiskVdaSize string `json:"diskvdasize"`
Uptime string `json:"uptime"`
BootTime string `json:"boot_time"`
SiteID string `json:"siteID"`
Onvif string `json:"onvif"`
OnvifZoom string `json:"onvif_zoom"`
OnvifPanTilt string `json:"onvif_pantilt"`
OnvifPresets string `json:"onvif_presets"`
OnvifPresetsList json.RawMessage `json:"onvif_presets_list"`
CameraConnected string `json:"cameraConnected"`
NumberOfFiles string `json:"numberoffiles"`
Timestamp int64 `json:"timestamp"`
CameraType string `json:"cameratype"`
Docker bool `json:"docker"`
Kios bool `json:"kios"`
RaspberryPi bool `json:"raspberrypi"`
}{
Key: config.Key,
Version: kerberosAgentVersion,
Release: system.Version,
CPUId: system.CPUId,
CloudUser: username,
CloudPublicKey: key,
CameraName: name,
Enterprise: isEnterprise,
Hostname: system.Hostname,
Architecture: system.Architecture,
TotalMemory: strconv.FormatUint(system.TotalMemory, 10),
UsedMemory: strconv.FormatUint(system.UsedMemory, 10),
FreeMemory: strconv.FormatUint(system.FreeMemory, 10),
ProcessMemory: strconv.FormatUint(system.ProcessUsedMemory, 10),
MacList: rawJSONOrEmptyArray(macs),
IPList: rawJSONOrEmptyArray(ips),
Board: "",
Disk1Size: "0",
Disk3Size: "0",
DiskVdaSize: "0",
Uptime: uptimeString,
BootTime: boottimeString,
SiteID: config.HubSite,
Onvif: onvifEnabled,
OnvifZoom: onvifZoom,
OnvifPanTilt: onvifPanTilt,
OnvifPresets: onvifPresets,
OnvifPresetsList: rawJSONOrEmptyArray(onvifPresetsList),
CameraConnected: cameraConnected,
NumberOfFiles: "33",
Timestamp: 1564747908,
CameraType: "IPCamera",
Docker: true,
Kios: false,
RaspberryPi: false,
}
objectBytes, err := json.Marshal(heartbeat)
if err != nil {
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling vault heartbeat: " + err.Error())
objectBytes = []byte("{}")
}
object := string(objectBytes)
var jsonStr = []byte(object)
buffy := bytes.NewBuffer(jsonStr)

View File

@@ -76,6 +76,9 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
communication.HandleLiveHLS = make(chan string, 1)
communication.IsConfiguring = abool.New()
communication.IsRecordingManual = abool.New()
communication.RecordingManualHeartbeat = &atomic.Int64{}
communication.RecordingManualStart = &atomic.Int64{}
communication.RecordingManualHeartbeatSeen = abool.New()
cameraSettings := &models.Camera{}

View File

@@ -22,6 +22,7 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
var isPixelChangeThresholdReached = false
var changesToReturn = 0
var motionRectangle models.MotionRectangle
var motionRectangles []models.MotionRectangle
pixelThreshold := config.Capture.PixelChangeThreshold
// Might not be set in the config file, so set it to 150
@@ -29,13 +30,26 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
pixelThreshold = 150
}
if config.Capture.Continuous == "true" {
// In motion mode we always run detection. In CONTINUOUS mode recording is
// 24/7 so motion detection is normally skipped, BUT if a motion region is
// configured we still run it so the live view can visualise the motion boxes
// + region. In that case we only emit the motion EVENT — no motion-triggered
// recording (continuous already records, and the recorder's motion branch
// isn't draining HandleMotion in continuous mode).
continuousMode := config.Capture.Continuous == "true"
hasMotionRegion := config.Region != nil && len(config.Region.Polygon) > 0
log.Log.Info("computervision.main.ProcessMotion(): you've enabled continuous recording, so no motion detection required.")
if continuousMode && !hasMotionRegion {
log.Log.Info("computervision.main.ProcessMotion(): continuous recording enabled and no motion region configured, so no motion detection required.")
} else {
log.Log.Info("computervision.main.ProcessMotion(): motion detected is enabled, so starting the motion detection.")
if continuousMode {
log.Log.Info("computervision.main.ProcessMotion(): continuous recording enabled with a motion region, running motion detection for live-view visualisation only (no motion-triggered recording).")
} else {
log.Log.Info("computervision.main.ProcessMotion(): motion detected is enabled, so starting the motion detection.")
}
hubKey := config.HubKey
deviceKey := config.Key
@@ -100,12 +114,34 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
}
}
// Frame dimensions + the motion region polygon(s) in image space, shipped
// with each motion event so the live view can draw a motion-debug overlay
// (the boxes below + the detection region).
var imageCols, imageRows int
var regionPolygons [][]map[string]int
if config.Region != nil {
for _, polygon := range config.Region.Polygon {
var pts []map[string]int
for _, c := range polygon.Coordinates {
pts = append(pts, map[string]int{
"x": int(c.X * baseWidthRatio),
"y": int(c.Y * baseHeightRatio),
})
}
if len(pts) > 0 {
regionPolygons = append(regionPolygons, pts)
}
}
}
img := imageArray[0]
var coordinatesToCheck []int
if img != nil {
bounds := img.Bounds()
rows := bounds.Dy()
cols := bounds.Dx()
imageCols = cols
imageRows = rows
// Make fixed size array of uinty8
for y := 0; y < rows; y++ {
@@ -146,12 +182,16 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
log.Log.Debug("computervision.main.ProcessMotion(): " + err.Error() + ".")
}
if config.Capture.Motion != "false" {
// Run detection when motion is enabled, OR when we're in continuous
// mode with a region: there config.Capture.Motion (the motion-RECORDING
// switch) is irrelevant, so the configured region alone is enough to
// emit motion events for the live-view overlay.
if config.Capture.Motion != "false" || continuousMode {
if detectMotion {
// Remember additional information about the result of findmotion
isPixelChangeThresholdReached, changesToReturn, motionRectangle = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
isPixelChangeThresholdReached, changesToReturn, motionRectangle, motionRectangles = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
if isPixelChangeThresholdReached {
// If offline mode is disabled, send a message to the hub
@@ -164,6 +204,19 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
DeviceId: configuration.Config.Key,
Value: map[string]interface{}{
"timestamp": time.Now().Unix(),
// Live-view motion-debug overlay data. The boxes/region
// are in the MOTION frame's pixel space (width/height =
// the stream motion ran on, i.e. the sub stream when
// set). mainWidth/mainHeight are the MAIN stream's
// dimensions so the live view can extrapolate the
// boxes/region onto the high-res main view it shows —
// we know both, so no guessing from the <video> element.
"width": imageCols,
"height": imageRows,
"mainWidth": configuration.Config.Capture.IPCamera.Width,
"mainHeight": configuration.Config.Capture.IPCamera.Height,
"regions": motionRectangles,
"polygon": regionPolygons,
},
},
}
@@ -179,7 +232,12 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
}
}
if config.Capture.Recording != "false" {
// Trigger motion-based recording — but NOT in continuous mode:
// there the recorder runs the continuous branch and does not
// drain HandleMotion, so a (blocking) send would hang the motion
// loop. In continuous mode we only publish the motion event above
// for the live-view overlay.
if config.Capture.Recording != "false" && !continuousMode {
dataToPass := models.MotionDataPartial{
Timestamp: time.Now().Unix(),
NumberOfChanges: changesToReturn,
@@ -205,17 +263,19 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
log.Log.Debug("computervision.main.ProcessMotion(): stop the motion detection.")
}
func FindMotion(imageArray [3]*image.Gray, coordinatesToCheck []int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int, motionRectangle models.MotionRectangle) {
func FindMotion(imageArray [3]*image.Gray, coordinatesToCheck []int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int, motionRectangle models.MotionRectangle, motionRectangles []models.MotionRectangle) {
image1 := imageArray[0]
image2 := imageArray[1]
image3 := imageArray[2]
threshold := 60
changes, motionRectangle := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
return changes > pixelChangeThreshold, changes, motionRectangle
changes, motionRectangle, motionRectangles := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
return changes > pixelChangeThreshold, changes, motionRectangle, motionRectangles
}
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle) {
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle, []models.MotionRectangle) {
changes := 0
cols := img1.Bounds().Dx()
rows := img1.Bounds().Dy()
var pixelList [][]int
for i := 0; i < len(coordinatesToCheck); i++ {
pixel := coordinatesToCheck[i]
@@ -224,7 +284,7 @@ func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.
if (diff > threshold || diff < -threshold) && (diff2 > threshold || diff2 < -threshold) {
changes++
// Store the pixel coordinates where the change is detected
pixelList = append(pixelList, []int{pixel % img1.Bounds().Dx(), pixel / img1.Bounds().Dx()})
pixelList = append(pixelList, []int{pixel % cols, pixel / cols})
}
}
@@ -258,5 +318,118 @@ func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.
}
log.Log.Debugf("Motion rectangle: %+v", motionRectangle)
}
return changes, motionRectangle
// Cluster the changed pixels into separate bounding boxes so the live view can
// visualise WHERE motion happened (a single overall rectangle is useless when
// two objects move in opposite corners). Cheap grid-based connected components.
motionRectangles := clusterMotionRectangles(pixelList, cols, rows)
return changes, motionRectangle, motionRectangles
}
// clusterMotionRectangles groups the changed-pixel coordinates into a handful of
// bounding boxes using connected-components on a coarse grid (8-connectivity).
// It is intentionally lightweight — it runs only when the motion threshold is
// reached and the boxes are meant for a debug overlay, not precise detection.
func clusterMotionRectangles(pixelList [][]int, cols, rows int) []models.MotionRectangle {
if len(pixelList) == 0 || cols <= 0 || rows <= 0 {
return nil
}
// ~40 cells across the longest side keeps the grid small (cheap to cluster)
// while still separating distinct motion blobs.
const gridDim = 40
cellW := cols / gridDim
if cellW < 1 {
cellW = 1
}
cellH := rows / gridDim
if cellH < 1 {
cellH = 1
}
gCols := (cols + cellW - 1) / cellW
gRows := (rows + cellH - 1) / cellH
grid := make([]bool, gCols*gRows)
for _, p := range pixelList {
cx := p[0] / cellW
cy := p[1] / cellH
if cx >= 0 && cx < gCols && cy >= 0 && cy < gRows {
grid[cy*gCols+cx] = true
}
}
visited := make([]bool, gCols*gRows)
var rectangles []models.MotionRectangle
const maxBoxes = 12
stack := make([][2]int, 0, 64)
for cy := 0; cy < gRows; cy++ {
for cx := 0; cx < gCols; cx++ {
idx := cy*gCols + cx
if !grid[idx] || visited[idx] {
continue
}
// Flood-fill this component (8-connectivity) and track its extent.
minX, minY, maxX, maxY := cx, cy, cx, cy
cellCount := 0
stack = stack[:0]
stack = append(stack, [2]int{cx, cy})
visited[idx] = true
for len(stack) > 0 {
cur := stack[len(stack)-1]
stack = stack[:len(stack)-1]
ccx, ccy := cur[0], cur[1]
cellCount++
if ccx < minX {
minX = ccx
}
if ccy < minY {
minY = ccy
}
if ccx > maxX {
maxX = ccx
}
if ccy > maxY {
maxY = ccy
}
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
nx, ny := ccx+dx, ccy+dy
if nx < 0 || ny < 0 || nx >= gCols || ny >= gRows {
continue
}
nIdx := ny*gCols + nx
if grid[nIdx] && !visited[nIdx] {
visited[nIdx] = true
stack = append(stack, [2]int{nx, ny})
}
}
}
}
// Skip single-cell specks (sensor noise) unless it's the only motion.
if cellCount < 2 && len(pixelList) > 4 {
continue
}
x := minX * cellW
y := minY * cellH
w := (maxX - minX + 1) * cellW
h := (maxY - minY + 1) * cellH
if x+w > cols {
w = cols - x
}
if y+h > rows {
h = rows - y
}
rectangles = append(rectangles, models.MotionRectangle{X: x, Y: y, Width: w, Height: h})
if len(rectangles) >= maxBoxes {
return rectangles
}
}
}
return rectangles
}

View File

@@ -52,12 +52,30 @@ type Communication struct {
// recorder keeps recording (it does not auto-close on the post-recording
// timeout) until the viewer stops it again. It is independent of motion
// detection so it also works when nothing is moving.
IsRecordingManual *abool.AtomicBool
Queue *packets.Queue
SubQueue *packets.Queue
Image string
CameraConnected bool
MainStreamConnected bool
SubStreamConnected bool
HasBackChannel bool
IsRecordingManual *abool.AtomicBool
// RecordingManualHeartbeat holds the unix-milliseconds timestamp of the last
// heartbeat received from the live view while a manual recording is active.
// The frontend re-sends the record command every few seconds while the user
// stays on the page; if the heartbeats stop (the viewer closed the tab, went
// idle or lost connectivity) the recorder auto-stops the manual recording so
// it can't record forever when the "stop" message never arrives.
RecordingManualHeartbeat *atomic.Int64
// RecordingManualStart holds the unix-milliseconds timestamp at which the
// current manual recording started. It bounds a manual recording to a maximum
// duration (see capture.manualRecordingMaxDuration) so a forgotten record
// button can't record indefinitely even while the viewer stays active.
RecordingManualStart *atomic.Int64
// RecordingManualHeartbeatSeen is set once the current manual recording has
// received at least one heartbeat, i.e. the viewer proved it supports
// heartbeating. Only then does the recorder enforce the heartbeat timeout; a
// viewer that starts a recording but never heartbeats (an older frontend)
// still records up to the max-duration cap instead of being cut off early.
RecordingManualHeartbeatSeen *abool.AtomicBool
Queue *packets.Queue
SubQueue *packets.Queue
Image string
CameraConnected bool
MainStreamConnected bool
SubStreamConnected bool
HasBackChannel bool
}

View File

@@ -154,6 +154,15 @@ type RecordPayload struct {
// recording (and keeps it running), false stops it. Older clients that only
// send a timestamp default to false; the live view always sets it explicitly.
Recording bool `json:"recording"`
// Heartbeat marks a keep-alive re-send (with Recording=true) from a viewer
// that supports heartbeating, as opposed to the initial start (the record
// button). While a user stays on the page the live view re-sends the record
// command every few seconds; the agent uses this flag to (a) refresh the
// recording's keep-alive without restarting an already auto-stopped clip from
// a stray heartbeat, and (b) only enable the heartbeat-timeout auto-stop once
// it has actually seen a heartbeat — so older viewers that never heartbeat
// still record up to the max-duration cap instead of being cut off early.
Heartbeat bool `json:"heartbeat"`
}
// We received a preset position request, we'll request it through onvif and send it back.

View File

@@ -381,23 +381,46 @@ func HandleRecording(mqttClient mqtt.Client, hubKey string, payload models.Paylo
}
if recordPayload.Recording {
// Start a manual recording from the live view (record button). Keep it
// running until the viewer stops it again — the motion recorder honours
// communication.IsRecordingManual and won't auto-close on the
// post-recording timeout while it's set. We also inject a motion event
// so the recording starts immediately, even when nothing is moving.
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording started.")
communication.IsRecordingManual.Set()
select {
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: timestamp, NumberOfChanges: 100000000}:
default:
log.Log.Warning("routers.mqtt.main.HandleRecording(): motion channel full, manual recording start not queued.")
now := time.Now().UnixMilli()
if recordPayload.Heartbeat {
// Keep-alive from a viewer that supports heartbeats. Only refresh while
// a manual recording is actually running; if it already auto-stopped
// (heartbeat timeout / max duration) we IGNORE it so a stray heartbeat
// can't restart a recording we just ended. Seeing a heartbeat also arms
// the recorder's heartbeat-timeout auto-stop.
if communication.IsRecordingManual.IsSet() {
communication.RecordingManualHeartbeat.Store(now)
communication.RecordingManualHeartbeatSeen.Set()
log.Log.Debug("routers.mqtt.main.HandleRecording(): manual recording heartbeat received.")
} else {
log.Log.Debug("routers.mqtt.main.HandleRecording(): ignoring heartbeat, no active manual recording.")
}
} else {
// Explicit start from the live view (record button). Start a manual
// recording and keep it running — the motion recorder honours
// communication.IsRecordingManual and won't auto-close on the
// post-recording timeout while it's set. We also inject a motion event
// so the recording starts immediately, even when nothing is moving.
communication.RecordingManualHeartbeat.Store(now)
if communication.IsRecordingManual.SetToIf(false, true) {
communication.RecordingManualStart.Store(now)
communication.RecordingManualHeartbeatSeen.UnSet()
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording started.")
select {
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: timestamp, NumberOfChanges: 100000000}:
default:
log.Log.Warning("routers.mqtt.main.HandleRecording(): motion channel full, manual recording start not queued.")
}
}
}
} else {
// Stop the manual recording; the motion recorder closes the clip once the
// post-recording window elapses.
// post-recording window elapses. Clear the heartbeat/start markers too.
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording stopped.")
communication.IsRecordingManual.UnSet()
communication.RecordingManualHeartbeat.Store(0)
communication.RecordingManualStart.Store(0)
communication.RecordingManualHeartbeatSeen.UnSet()
}
}