From 9d2e2c2ac01202bafb966db785b82bb4a34a9749 Mon Sep 17 00:00:00 2001 From: sunfire Date: Mon, 6 Jul 2026 15:17:15 -0400 Subject: [PATCH] Feat: Spatial deduplication and multi-object tracking improvements - Add spatial deduplication algorithm to bridge broken Frigate tracking events - Implement multi-object viewport overlap splitting logic - Extract pure grouping and splitting logic and add unit tests - Enhance test-event configuration resolution and mock sequences --- .github/workflows/release.yml | 17 +- old_overlay.js | Bin 0 -> 9574 bytes package-lock.json | 6 +- package.json | 2 +- src/grouping.js | 194 +++++++++++++++++ src/main.js | 396 +++++++++++++++++++++++++++++++--- src/preload.js | 6 +- src/renderer/indicator.html | 38 ++++ src/renderer/overlay.js | 152 +++++++++++-- src/setup/setup.html | 22 ++ src/setup/setup.js | 19 +- src/updater.js | 4 +- test-event.js | 134 ++++++++++++ test/grouping.test.js | 170 +++++++++++++++ 14 files changed, 1105 insertions(+), 55 deletions(-) create mode 100644 old_overlay.js create mode 100644 src/grouping.js create mode 100644 src/renderer/indicator.html create mode 100644 test-event.js create mode 100644 test/grouping.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71bb128..6c4aacf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,9 +14,16 @@ jobs: strategy: fail-fast: false matrix: - os: - - macos-latest - - windows-latest + include: + - os: macos-latest + name: macos-arm64 + args: --mac --arm64 + - os: macos-latest + name: macos-intel + args: --mac --x64 + - os: windows-latest + name: windows + args: --win runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -24,10 +31,10 @@ jobs: with: node-version: 20 - run: npm ci - - run: npm run dist -- --publish never + - run: npm run dist -- --publish never ${{ matrix.args }} - uses: actions/upload-artifact@v4 with: - name: ${{ matrix.os }} + name: ${{ matrix.name }} path: | dist/*.dmg dist/*.zip diff --git a/old_overlay.js b/old_overlay.js new file mode 100644 index 0000000000000000000000000000000000000000..249fb450583f77d7e4a6382042ec645c86c08068 GIT binary patch literal 9574 zcmchc-Huz;6~}jPEAb8vYVgp)X+lLopcG9)kSc)`F#%N?MPg!mCIgw7)OMN6b6`KGul~ogJiMYSORMpLF6}=b+@9)4ZHHjdbSYG}GDV z($DjIlpgE*JoC7n9*X)bbC~PbCd)e(k8}ODC#uK~s;PcI62&mR)S=;vLCS@AKru+~ zru}p?-PZpH`gTL_&+@r}K0Ok@sm^iw4Shcp=cDJp{Mk3@Zwnd2bSIn{rhAf%El`i7 z%e12VKK*M+cPl@wDb6Gb+oz(v)EoPw+;1iK#pge``t6d-dmR)t&-?OMH@EN8S69tb zLwO*csyPt7lS+^8(m$3Oyq%xc6j(lAq4?{P0?X(67JY@5Q^hx0`(65aNrQeouc;o1 z(n=EH8v_xdyRIGllvmnyxhsn@3 z5(h`xTsFXIDsO{~U1bzLFQ@yNQvD#ti<@kZ)6`vOnw%D-^wk};Dn$%M%> z*-*H2$)J)POUkoY;XJzdm-=*5QvSTNrwX4gDb!_Z8OYi-7LiAn3~CWlCc^GqUfWep z;p0-sMY4%nOg4jVZVfWZq?{`QAL_O4t`Pw{()K_et7B74MeyI*r9` zrkvy7neIf-_VpAcllSFQ&Qlpr8tN1}aOw}z;JS3MZelY}>JQOB5$}uaS4Z4XzntXSx|d$j>ssz7e!KGjHR<(w zdi5zJUZ|r$=4rOfEx5kWxZFrxQJ#yE?(6OnSLUZZd0--*pkb`fyG6Qqzm3Fpl(&o7 zw*GxpKBQlJMgOmU!pE=ugg&lO(YDoMQoP|<1m&Ke^q%ek_#NhcxQ$SHnQnZ7dn~4p z-j42CMj3RmYeG6a#zHUof!^Q%$u%kl>6iNLx)96@*>uV?oj(`SO;f5Noi#YKzK~v< z%4^TLDW|G5JO^n*aVhz0{Z1c-snKZFh2h;$YyR^(+m2=%x&N#B)ScPa7MWe6&M~!J zefue@neph0%|k`?zGCxG^<|Pd^vyali^U?)!S%%sSB=Objmbt;wzGm2@gkZp)piVytf5wgz*diC&60 zP?x5P%P_|dITkCcQ`Xp3dXt*%GxQyDo%0qDfW^b<0h}i+*b) zADrb3DV4aUo$1_YL1|ud8D(y>`LB_usXpL&tex3|e7~|eh#ZS;PapYhqTLmN zbB5i1x3Wc6d6-N=MWJuR)iTv>qt`7Pk$yhquDZLBwpZ~k&x`NM9)m3D=l`B`nG0Ku zaOrbREunCWeIZ`?mEOa2bfet6=Wu732?UW#0aukkHjh+L@% zKCfFXFwc2!SaYV-gy@~ofeJ&5IRy-;dvlnv>$Y#&vJrYr6Pk)lp z1qupR@7D|&SJD;HmQCBLe#PeX_0#QipSwSOx4j?u$I9+m8gGxTY>CgycJ4;5!%_DG zS?xWBpR?TS_my|UM>%Kc_?gsglV+LI-_ghD#s0F!8~GBSZqt4GHpsHAO<#JAbvjs` zh*>~|Ej!{S+YCA$3ro~1tba>yw6{!rLEk(B{fXTTbH%`G(c8ii)s_2@9b}9&GuygO zxYj1tjqN(}nm%?B`_czQoT`@C1ZR=OYrb*Dvo;~}X%m`OZ~itiOD=SG(lJhLYHIZr zdCF%VFygm%$;5s%M-N~xeI=6=6*gMUxm_lZe(5%~wKE96^iATXrFDL>{{)%4R%t9; zzhm-9#h7>honc#KtXAmAd9|)~cd`AxU5zZhv74gQ^j%RB>z#Y1_MP93;Fqf(A4F_x&s_!th05Gl?hs2_1V(lZ;aP%SG%U+++S*t*3XwyXn`L$1xLDYdCb9s)y}&tQ*~vZJRo6x9h)7 C`UtK7 literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json index 2108f64..6bccdb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { - "name": "frigate-overlay", + "name": "peek", "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "frigate-overlay", + "name": "peek", "version": "0.4.3", "license": "MIT", "dependencies": { "mqtt": "^5.10.0" }, "devDependencies": { - "electron": "^33.0.0", + "electron": "^33.4.11", "electron-builder": "^25.1.8" } }, diff --git a/package.json b/package.json index 1376284..05943ea 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "mqtt": "^5.10.0" }, "devDependencies": { - "electron": "^33.0.0", + "electron": "^33.4.11", "electron-builder": "^25.1.8" } } diff --git a/src/grouping.js b/src/grouping.js new file mode 100644 index 0000000..477127f --- /dev/null +++ b/src/grouping.js @@ -0,0 +1,194 @@ +function getOverlaySize(config, prefs, dynamicWidth = null) { + const height = config.height || 300 + let width + if (prefs.cropToObject) { + const ratioStr = prefs.cropRatio || '16:9' + const parts = ratioStr.split(':') + const ratio = parts.length === 2 ? Number(parts[0]) / Number(parts[1]) : (16/9) + width = Math.round(height * ratio) + } else { + width = dynamicWidth || config.width || Math.round(height * (16 / 9)) + } + return { width, height } +} + +function getViewportForBox(boxRelative, camera, cameraDetectMap, config, prefs) { + const detect = cameraDetectMap[camera] || { width: 1280, height: 720 } + const VW = detect.width + const VH = detect.height + const { width: winW, height: winH } = getOverlaySize(config, prefs) + + if (!boxRelative || !prefs.cropToObject) { + return { minX: 0, minY: 0, maxX: 1, maxY: 1 } + } + + const minX = boxRelative[0] + const minY = boxRelative[1] + const maxX = boxRelative[2] + const maxY = boxRelative[3] + + const boxW = maxX - minX + const boxH = maxY - minY + const cx = (minX + maxX) / 2 + const cy = (minY + maxY) / 2 + + const objCx = cx * VW + const objCy = cy * VH + + const winCx = winW / 2 + const winCy = winH / 2 + + const objW = boxW * VW + const objH = boxH * VH + + const idealScale = Math.min(winW / (objW * 2), winH / (objH * 2)) + const minScaleToCover = Math.max(winW / VW, winH / VH) + + const S = Math.max(minScaleToCover, Math.min(minScaleToCover * 4, idealScale)) + + let tx = winCx - (objCx * S) + let ty = winCy - (objCy * S) + + const minTx = winW - (VW * S) + const minTy = winH - (VH * S) + + tx = Math.min(0, Math.max(minTx, tx)) + ty = Math.min(0, Math.max(minTy, ty)) + + return { + minX: -tx / (VW * S), + maxX: (winW - tx) / (VW * S), + minY: -ty / (VH * S), + maxY: (winH - ty) / (VH * S) + } +} + +function viewportsOverlap(vp1, vp2) { + return !(vp1.maxX < vp2.minX || vp1.minX > vp2.maxX || vp1.maxY < vp2.minY || vp1.minY > vp2.maxY) +} + +function getCombinedBoxForWindow(winId, eventGroupMap, recentEvents) { + let minX = 1, minY = 1, maxX = 0, maxY = 0 + let found = false + for (const [evtId, targetId] of eventGroupMap.entries()) { + if (targetId === winId) { + const hist = recentEvents.get(evtId) + if (hist && hist.box) { + minX = Math.min(minX, hist.box[0]) + minY = Math.min(minY, hist.box[1]) + maxX = Math.max(maxX, hist.box[2]) + maxY = Math.max(maxY, hist.box[3]) + found = true + } + } + } + return found ? [minX, minY, maxX, maxY] : null +} + +function getCombinedBoxForWindowExcluding(winId, excludeEvtId, eventGroupMap, recentEvents) { + let minX = 1, minY = 1, maxX = 0, maxY = 0 + let found = false + for (const [evtId, targetId] of eventGroupMap.entries()) { + if (targetId === winId && evtId !== excludeEvtId) { + const hist = recentEvents.get(evtId) + if (hist && hist.box) { + minX = Math.min(minX, hist.box[0]) + minY = Math.min(minY, hist.box[1]) + maxX = Math.max(maxX, hist.box[2]) + maxY = Math.max(maxY, hist.box[3]) + found = true + } + } + } + return found ? [minX, minY, maxX, maxY] : null +} + +function getGroupForNewEvent(event, cameraDetectMap, config, prefs, activeWindows, pendingEvents, eventGroupMap, recentEvents) { + if (prefs.showAllObjectsInFrame !== false) { + return event.camera + } + + if (!event.boxRelative) { + return event.id + } + + const vpB = getViewportForBox(event.boxRelative, event.camera, cameraDetectMap, config, prefs) + const candidates = [...activeWindows.keys(), ...pendingEvents.keys()] + + for (const winId of candidates) { + const w = activeWindows.get(winId) + if (w && typeof w.isDestroyed === 'function' && w.isDestroyed()) continue + + const combinedBox = getCombinedBoxForWindow(winId, eventGroupMap, recentEvents) + if (combinedBox) { + const vpA = getViewportForBox(combinedBox, event.camera, cameraDetectMap, config, prefs) + if (viewportsOverlap(vpA, vpB)) { + return winId + } + } + } + + return event.id +} + +function checkSplitForEvent(event, cameraDetectMap, config, prefs, activeWindows, pendingEvents, eventGroupMap, recentEvents) { + if (prefs.showAllObjectsInFrame !== false) { + return { shouldSplit: false, action: null, splitEventIds: [] } + } + + const currentGroup = eventGroupMap.get(event.id) + if (!currentGroup) { + return { shouldSplit: false, action: null, splitEventIds: [] } + } + + const hasWindow = activeWindows.has(currentGroup) || pendingEvents.has(currentGroup) + if (!hasWindow) { + return { shouldSplit: false, action: null, splitEventIds: [] } + } + + const w = activeWindows.get(currentGroup) + if (w && typeof w.isDestroyed === 'function' && w.isDestroyed()) { + return { shouldSplit: false, action: null, splitEventIds: [] } + } + + if (!event.boxRelative) { + return { shouldSplit: false, action: null, splitEventIds: [] } + } + + const combinedBoxOthers = getCombinedBoxForWindowExcluding(currentGroup, event.id, eventGroupMap, recentEvents) + if (!combinedBoxOthers) { + // Sole event in group, no need to split + return { shouldSplit: false, action: null, splitEventIds: [] } + } + + const vpOthers = getViewportForBox(combinedBoxOthers, event.camera, cameraDetectMap, config, prefs) + const vpSelf = getViewportForBox(event.boxRelative, event.camera, cameraDetectMap, config, prefs) + + if (!viewportsOverlap(vpOthers, vpSelf)) { + if (event.id === currentGroup) { + // Group leader is splitting. Split all other events out instead. + const otherEventIds = [] + for (const [evtId, targetId] of eventGroupMap.entries()) { + if (targetId === currentGroup && evtId !== event.id) { + otherEventIds.push(evtId) + } + } + return { shouldSplit: true, action: 'split_others', splitEventIds: otherEventIds } + } else { + // Non-leader event is splitting. Split itself out. + return { shouldSplit: true, action: 'split_self', splitEventIds: [event.id] } + } + } + + return { shouldSplit: false, action: null, splitEventIds: [] } +} + +module.exports = { + getOverlaySize, + getViewportForBox, + viewportsOverlap, + getCombinedBoxForWindow, + getCombinedBoxForWindowExcluding, + getGroupForNewEvent, + checkSplitForEvent +} diff --git a/src/main.js b/src/main.js index 78d810b..f05b9a7 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,7 @@ const { readConfig, saveConfig } = require('./config') const { readPrefs, writePrefs } = require('./prefs') const updater = require('./updater') const frigateAuth = require('./frigate-auth') +const grouping = require('./grouping') app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required') @@ -18,7 +19,19 @@ if (!gotInstanceLock) { if (process.platform === 'darwin') app.on('activate', () => openSetup()) } -let win = null +const activeWindows = new Map() +let indicatorWin = null +const pendingEvents = new Map() +const closedEvents = new Set() +const eventGroupMap = new Map() + +function markEventClosed(id) { + closedEvents.add(id) + if (closedEvents.size > 500) { + const first = closedEvents.keys().next().value + closedEvents.delete(first) + } +} let setupWin = null let updateWin = null let tray = null @@ -33,7 +46,10 @@ let mqttClient = null let cameraStreamMap = {} let cameraDetectMap = {} let certProcSet = false +// currentDynamicWidth removed const knownCameras = new Set() +const eventAliases = new Map() +const recentEvents = new Map() const DISMISS_OPTIONS = [ { label: '3 seconds', value: 3 }, @@ -49,7 +65,7 @@ function httpToWs(url) { } function streamUrl(camera) { - const streamName = cameraStreamMap[camera] || camera + const streamName = prefs.highResStream ? camera : (cameraStreamMap[camera] || camera) return `${httpToWs(config.frigateUrl)}/live/webrtc/api/ws?src=${encodeURIComponent(streamName)}` } @@ -100,25 +116,76 @@ function prettyName(camera) { return camera.replace(/[_-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } -function overlaySize() { - return { width: config.width || 380, height: config.height || 300 } +function overlaySize(dynamicWidth = null) { + return grouping.getOverlaySize(config, prefs, dynamicWidth) } -function positionWindow() { +function getViewportForBox(boxRelative, camera) { + return grouping.getViewportForBox(boxRelative, camera, cameraDetectMap, config, prefs) +} + +function viewportsOverlap(vp1, vp2) { + return grouping.viewportsOverlap(vp1, vp2) +} + +function positionWindows() { const area = screen.getPrimaryDisplay().workArea - const { width, height } = overlaySize() const margin = config.margin != null ? config.margin : 24 const corner = config.corner || 'top-right' - let x = area.x + area.width - width - margin - let y = area.y + margin - if (corner.includes('left')) x = area.x + margin - if (corner.includes('bottom')) y = area.y + area.height - height - margin - win.setBounds({ x, y, width, height }) + + let currentY = margin + if (corner.includes('bottom')) currentY = area.height - margin + + const windows = Array.from(activeWindows.values()) + windows.forEach((w, i) => { + if (w.isDestroyed()) return + const { width, height } = overlaySize(w.dynamicWidth) + let x = area.x + area.width - width - margin + if (corner.includes('left')) x = area.x + margin + + let y = area.y + currentY + if (corner.includes('bottom')) { + y = area.y + currentY - height + currentY -= (height + margin) + } else { + currentY += (height + margin) + } + w.setBounds({ x, y, width, height }) + }) + + // Position indicatorWin + if (indicatorWin && !indicatorWin.isDestroyed()) { + if (pendingEvents.size > 0) { + const { width } = overlaySize() // default approx width + const indHeight = 32 + let x = area.x + area.width - width - margin + if (corner.includes('left')) x = area.x + margin + + let y = area.y + currentY + if (corner.includes('bottom')) { + y = area.y + currentY - indHeight + } + indicatorWin.setBounds({ x, y, width, height: indHeight }) + indicatorWin.webContents.send('set-count', pendingEvents.size) + indicatorWin.showInactive() + } else { + indicatorWin.hide() + } + } } -function createWindow() { +function updateAudio() { + const windows = Array.from(activeWindows.values()) + windows.forEach((w, i) => { + if (!w.isDestroyed()) { + w.webContents.send('set-muted', !prefs.sound || i !== 0) + } + }) +} + +function createEventWindow(eventId) { const { width, height } = overlaySize() - win = new BrowserWindow({ + const w = new BrowserWindow({ width, height, frame: false, @@ -140,10 +207,73 @@ function createWindow() { backgroundThrottling: false } }) - win.setAlwaysOnTop(true, 'screen-saver') - win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) - win.loadFile(path.join(__dirname, 'renderer', 'index.html')) - positionWindow() + w.setAlwaysOnTop(true, 'screen-saver') + w.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + w.loadFile(path.join(__dirname, 'renderer', 'index.html')) + w.eventId = eventId + w.dynamicWidth = null + w.isLoading = true + w.eventQueue = [] + + w.webContents.on('did-finish-load', () => { + w.isLoading = false + for (const ev of w.eventQueue) { + w.webContents.send('frigate-event', ev) + } + w.eventQueue = [] + }) + + w.on('closed', () => { + activeWindows.delete(eventId) + + if (pendingEvents.size > 0 && activeWindows.size < 4) { + const firstPending = pendingEvents.keys().next().value + const pendingEvent = pendingEvents.get(firstPending) + pendingEvents.delete(firstPending) + + const newWin = createEventWindow(firstPending) + activeWindows.set(firstPending, newWin) + newWin.showInactive() + pendingEvent.sound = prefs.sound + pendingEvent.type = 'new' + newWin.eventQueue.push(pendingEvent) + newWin.webContents.on('did-finish-load', () => { + updateAudio() + }) + } + + positionWindows() + updateAudio() + }) + + return w +} + +function initIndicatorWin() { + if (indicatorWin) return + indicatorWin = new BrowserWindow({ + width: 300, + height: 32, + frame: false, + transparent: true, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + alwaysOnTop: true, + hasShadow: false, + show: false, + backgroundColor: '#00000000', + webPreferences: { + nodeIntegration: true, + contextIsolation: false + } + }) + indicatorWin.setAlwaysOnTop(true, 'screen-saver') + indicatorWin.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + indicatorWin.loadFile(path.join(__dirname, 'renderer', 'indicator.html')) } function defaultPrefs() { @@ -157,9 +287,13 @@ function defaultPrefs() { snapshot: true, dismissSeconds: config.dismissSeconds != null ? config.dismissSeconds : 8, clickAction: 'event', + cropToObject: false, + cropRatio: '16:9', + highResStream: false, showAllObjectsInFrame: true, showBoundingBoxes: true, autoUpdate: false, + updateRepo: '', showDock: false, openAtLogin: false, skipVersion: '', @@ -176,9 +310,13 @@ function loadPrefs() { snapshot: saved.snapshot != null ? saved.snapshot : base.snapshot, dismissSeconds: saved.dismissSeconds != null ? saved.dismissSeconds : base.dismissSeconds, clickAction: saved.clickAction != null ? saved.clickAction : base.clickAction, + cropToObject: saved.cropToObject != null ? saved.cropToObject : base.cropToObject, + cropRatio: saved.cropRatio != null ? saved.cropRatio : base.cropRatio, + highResStream: saved.highResStream != null ? saved.highResStream : base.highResStream, showAllObjectsInFrame: saved.showAllObjectsInFrame != null ? saved.showAllObjectsInFrame : base.showAllObjectsInFrame, showBoundingBoxes: saved.showBoundingBoxes != null ? saved.showBoundingBoxes : base.showBoundingBoxes, autoUpdate: saved.autoUpdate != null ? saved.autoUpdate : base.autoUpdate, + updateRepo: saved.updateRepo != null ? saved.updateRepo : base.updateRepo, showDock: saved.showDock != null ? saved.showDock : base.showDock, openAtLogin: saved.openAtLogin != null ? saved.openAtLogin : base.openAtLogin, skipVersion: saved.skipVersion != null ? saved.skipVersion : base.skipVersion, @@ -208,6 +346,9 @@ function applyRuntimePrefs(opts) { if (!opts) return if (typeof opts.sound === 'boolean') prefs.sound = opts.sound if (typeof opts.snapshot === 'boolean') prefs.snapshot = opts.snapshot + if (typeof opts.cropToObject === 'boolean') prefs.cropToObject = opts.cropToObject + if (opts.cropRatio) prefs.cropRatio = opts.cropRatio + if (typeof opts.highResStream === 'boolean') prefs.highResStream = opts.highResStream if (opts.dismissSeconds != null) prefs.dismissSeconds = opts.dismissSeconds if (opts.clickAction) prefs.clickAction = opts.clickAction if (typeof opts.showAllObjectsInFrame === 'boolean') prefs.showAllObjectsInFrame = opts.showAllObjectsInFrame @@ -335,6 +476,59 @@ function handleEvent(data) { if (data.type === 'new' && score < minScore) return const subLabel = Array.isArray(after.sub_label) ? after.sub_label[0] : after.sub_label + const detect = cameraDetectMap[after.camera] + let boxRelative = null + if (detect && after.box && after.box.length === 4) { + boxRelative = [ + after.box[0] / detect.width, + after.box[1] / detect.height, + after.box[2] / detect.width, + after.box[3] / detect.height + ] + } + + let streamUrlPath = streamUrl(after.camera) + if (after.camera === '__MOCK_CAMERA__') { + streamUrlPath = '__MOCK_IMAGE__' + boxRelative = [500/1280, 150/720, 750/1280, 650/720] // Static math for Big Buck Bunny + } + + let realId = after.id + while (eventAliases.has(realId)) { + realId = eventAliases.get(realId) + } + after.id = realId + + if (data.type === 'new') { + const now = Date.now() + let bestMatch = null + let minDistance = 0.2 + + for (const [id, hist] of recentEvents.entries()) { + if (hist.camera !== after.camera || hist.label !== after.label) continue + if (now - hist.time > 15000) continue + + if (hist.box && boxRelative) { + const cx1 = (hist.box[0] + hist.box[2]) / 2 + const cy1 = (hist.box[1] + hist.box[3]) / 2 + const cx2 = (boxRelative[0] + boxRelative[2]) / 2 + const cy2 = (boxRelative[1] + boxRelative[3]) / 2 + + const dist = Math.sqrt((cx1 - cx2) ** 2 + (cy1 - cy2) ** 2) + if (dist < minDistance) { + minDistance = dist + bestMatch = id + } + } + } + + if (bestMatch) { + eventAliases.set(after.id, bestMatch) + after.id = bestMatch + data.type = 'update' + } + } + const event = { id: after.id, type: data.type, @@ -345,20 +539,149 @@ function handleEvent(data) { plate: after.recognized_license_plate || null, zones: after.entered_zones || [], box: prefs.showBoundingBoxes !== false ? normalizeBox(after.box, after.camera) : null, - streamUrl: streamUrl(after.camera), + streamUrl: streamUrlPath, poster: prefs.snapshot ? snapshotUrl(after.camera) : null, sound: prefs.sound, dismiss: prefs.dismissSeconds, clickUrl: clickUrlForEvent(after.camera, after.id), - showAllObjectsInFrame: prefs.showAllObjectsInFrame !== false + boxRelative, + cropToObject: prefs.cropToObject, + cropRatio: prefs.cropRatio || '16:9', + showAllObjectsInFrame: true + } + + let targetId = prefs.showAllObjectsInFrame !== false ? after.camera : after.id + + if (prefs.showAllObjectsInFrame === false) { + if (data.type === 'end') { + if (eventGroupMap.has(after.id)) { + targetId = eventGroupMap.get(after.id) + eventGroupMap.delete(after.id) + } + } else { + let currentGroup = eventGroupMap.get(after.id) + + if (currentGroup && (activeWindows.has(currentGroup) || pendingEvents.has(currentGroup))) { + const splitResult = grouping.checkSplitForEvent( + event, cameraDetectMap, config, prefs, activeWindows, pendingEvents, eventGroupMap, recentEvents + ) + if (splitResult.shouldSplit) { + const wOld = activeWindows.get(currentGroup) + if (splitResult.action === 'split_self') { + if (wOld && !wOld.isDestroyed()) { + wOld.webContents.send('frigate-event', { ...event, type: 'end' }) + } + eventGroupMap.delete(after.id) + currentGroup = null + data.type = 'new' + event.type = 'new' + } else if (splitResult.action === 'split_others') { + for (const otherId of splitResult.splitEventIds) { + const otherHist = recentEvents.get(otherId) + if (otherHist) { + if (wOld && !wOld.isDestroyed()) { + wOld.webContents.send('frigate-event', { + id: otherId, + type: 'end', + camera: otherHist.camera, + label: otherHist.label, + box: otherHist.boxAbsolute + }) + } + eventGroupMap.delete(otherId) + handleEvent({ + type: 'new', + after: { + id: otherId, + camera: otherHist.camera, + label: otherHist.label, + box: otherHist.boxAbsolute, + score: otherHist.score + } + }) + } + } + } + } + } + + if (!currentGroup) { + targetId = grouping.getGroupForNewEvent( + event, cameraDetectMap, config, prefs, activeWindows, pendingEvents, eventGroupMap, recentEvents + ) + eventGroupMap.set(after.id, targetId) + } else { + targetId = currentGroup + } + } + } + + if (closedEvents.has(after.id)) return + + if (data.type === 'update' && !activeWindows.has(targetId) && !pendingEvents.has(targetId)) { + data.type = 'new' + event.type = 'new' } - if (!win || win.isDestroyed()) return if (data.type === 'new') { - positionWindow() - win.showInactive() + if (activeWindows.has(targetId)) { + const w = activeWindows.get(targetId) + if (w && !w.isDestroyed()) { + if (w.isLoading) { + w.eventQueue.push(event) + } else { + w.webContents.send('frigate-event', event) + } + } + } else if (activeWindows.size >= 4) { + pendingEvents.set(targetId, event) + positionWindows() + } else { + const w = createEventWindow(targetId) + activeWindows.set(targetId, w) + positionWindows() + w.showInactive() + w.eventQueue.push(event) + w.webContents.on('did-finish-load', () => { + updateAudio() + }) + } + } else { + if (pendingEvents.has(targetId)) { + if (data.type === 'end') { + pendingEvents.delete(targetId) + markEventClosed(targetId) + } else { + pendingEvents.set(targetId, event) + } + positionWindows() + } else { + const w = activeWindows.get(targetId) + if (w && !w.isDestroyed()) { + if (w.isLoading) { + w.eventQueue.push(event) + } else { + w.webContents.send('frigate-event', event) + } + } + } + } + + recentEvents.set(after.id, { + camera: after.camera, + label: after.label, + box: boxRelative, + boxAbsolute: after.box, + score: score, + time: Date.now() + }) + + if (Math.random() < 0.05) { + const now = Date.now() + for (const [id, hist] of recentEvents.entries()) { + if (now - hist.time > 60000) recentEvents.delete(id) + } } - win.webContents.send('frigate-event', event) } function startMqtt() { @@ -436,7 +759,7 @@ function startApp() { appStarted = true loadPrefs() applyOpenAtLogin(prefs.openAtLogin) - createWindow() + initIndicatorWin() createTray() initFrigateAuth().then(() => fetchFrigateConfig(frigateToken)) startMqtt() @@ -539,7 +862,7 @@ const NOTIFY_THROTTLE_MS = 24 * 60 * 60 * 1000 async function checkForUpdates(manual) { let latest try { - latest = await updater.getLatest() + latest = await updater.getLatest(prefs.updateRepo || undefined) } catch (err) { if (manual) { dialog.showMessageBox({ type: 'error', title: 'Peek', message: 'Could not check for updates.', detail: err.message }) @@ -686,8 +1009,21 @@ app.whenReady().then(() => { initFrigateAuth() }) - ipcMain.on('overlay-hide', () => { - if (win && !win.isDestroyed()) win.hide() + ipcMain.on('overlay-hide', (e, eventIds) => { + const w = BrowserWindow.fromWebContents(e.sender) + if (w && !w.isDestroyed()) { + if (Array.isArray(eventIds)) { + eventIds.forEach(id => markEventClosed(id)) + } + w.close() + } + }) + ipcMain.on('overlay-resize', (e, { width, height }) => { + const w = BrowserWindow.fromWebContents(e.sender) + if (w && !w.isDestroyed()) { + w.dynamicWidth = width + positionWindows() + } }) ipcMain.on('overlay-open-url', (e, url) => { if (typeof url === 'string' && config && config.frigateUrl && url.startsWith(config.frigateUrl)) { @@ -703,6 +1039,7 @@ app.whenReady().then(() => { : [] return { autoUpdate: !!(p && p.autoUpdate), + updateRepo: (p && p.updateRepo) || '', showDock: !!(p && p.showDock), openAtLogin: !!(p && p.openAtLogin), clickAction: (p && p.clickAction) || 'event', @@ -710,6 +1047,9 @@ app.whenReady().then(() => { started: appStarted, sound: !!(p && p.sound), snapshot: !!(p && p.snapshot), + cropToObject: !!(p && p.cropToObject), + cropRatio: (p && p.cropRatio) || '16:9', + highResStream: !!(p && p.highResStream), dismissSeconds: p && p.dismissSeconds != null ? p.dismissSeconds : 8, showAllObjectsInFrame: p && p.showAllObjectsInFrame !== false, showBoundingBoxes: p && p.showBoundingBoxes !== false, @@ -725,6 +1065,7 @@ app.whenReady().then(() => { config = cfg startApp() prefs.autoUpdate = wantUpdates + if (opts && opts.updateRepo !== undefined) prefs.updateRepo = opts.updateRepo prefs.showDock = wantDock prefs.openAtLogin = wantOpenAtLogin savePrefs() @@ -743,6 +1084,7 @@ app.whenReady().then(() => { config.frigatePassword !== cfg.frigatePassword const wasUpdates = !!prefs.autoUpdate prefs.autoUpdate = wantUpdates + if (opts && opts.updateRepo !== undefined) prefs.updateRepo = opts.updateRepo prefs.showDock = wantDock prefs.openAtLogin = wantOpenAtLogin applyRuntimePrefs(opts) diff --git a/src/preload.js b/src/preload.js index 7dc8876..d0c575e 100644 --- a/src/preload.js +++ b/src/preload.js @@ -2,6 +2,8 @@ const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('overlay', { onEvent: (callback) => ipcRenderer.on('frigate-event', (event, data) => callback(data)), - hide: () => ipcRenderer.send('overlay-hide'), - openUrl: (url) => ipcRenderer.send('overlay-open-url', url) + hide: (eventIds) => ipcRenderer.send('overlay-hide', eventIds), + resize: (width, height) => ipcRenderer.send('overlay-resize', { width, height }), + openUrl: (url) => ipcRenderer.send('overlay-open-url', url), + onSetMuted: (callback) => ipcRenderer.on('set-muted', (event, muted) => callback(muted)) }) diff --git a/src/renderer/indicator.html b/src/renderer/indicator.html new file mode 100644 index 0000000..b8bce4d --- /dev/null +++ b/src/renderer/indicator.html @@ -0,0 +1,38 @@ + + + + + + + +
+0 more active events
+ + + diff --git a/src/renderer/overlay.js b/src/renderer/overlay.js index 035cfbe..a342f31 100644 --- a/src/renderer/overlay.js +++ b/src/renderer/overlay.js @@ -72,6 +72,80 @@ function labelText(label) { return entry ? `${entry[0]} ${entry[1]}` : label } +function updateCrop() { + if (!stream || activeEvents.size === 0) return + + const firstEvent = activeEvents.values().next().value + if (!firstEvent.cropToObject) { + if (stream) { + stream.style.transition = 'transform 0.1s ease-out' + stream.style.transform = '' + stream.style.transformOrigin = '50% 50%' + bboxCanvas.style.transition = 'transform 0.1s ease-out' + bboxCanvas.style.transform = '' + bboxCanvas.style.transformOrigin = '50% 50%' + } + return + } + + const videoWidth = stream.video.videoWidth + const videoHeight = stream.video.videoHeight + if (!videoWidth || !videoHeight) return + + let minX = 1, minY = 1, maxX = 0, maxY = 0 + for (const ev of activeEvents.values()) { + if (ev.boxRelative) { + minX = Math.min(minX, ev.boxRelative[0]) + minY = Math.min(minY, ev.boxRelative[1]) + maxX = Math.max(maxX, ev.boxRelative[2]) + maxY = Math.max(maxY, ev.boxRelative[3]) + } + } + + if (maxX <= minX || maxY <= minY) return + + const boxW = maxX - minX + const boxH = maxY - minY + const cx = (minX + maxX) / 2 + const cy = (minY + maxY) / 2 + + const VW = videoWidth + const VH = videoHeight + + const objCx = cx * VW + const objCy = cy * VH + + const winW = window.innerWidth + const winH = window.innerHeight + const winCx = winW / 2 + const winCy = winH / 2 + + const objW = boxW * VW + const objH = boxH * VH + + const idealScale = Math.min(winW / (objW * 2), winH / (objH * 2)) + const minScaleToCover = Math.max(winW / VW, winH / VH) + + const S = Math.max(minScaleToCover, Math.min(minScaleToCover * 4, idealScale)) + + let tx = winCx - (objCx * S) + let ty = winCy - (objCy * S) + + const minTx = winW - (VW * S) + const minTy = winH - (VH * S) + + tx = Math.min(0, Math.max(minTx, tx)) + ty = Math.min(0, Math.max(minTy, ty)) + + stream.style.transition = 'transform 0.1s ease-out' + stream.style.transformOrigin = '0 0' + stream.style.transform = `translate(${tx}px, ${ty}px) scale(${S})` + + bboxCanvas.style.transition = 'transform 0.1s ease-out' + bboxCanvas.style.transformOrigin = '0 0' + bboxCanvas.style.transform = `translate(${tx}px, ${ty}px) scale(${S})` +} + function syncCanvasSize() { const w = bboxCanvas.offsetWidth const h = bboxCanvas.offsetHeight @@ -171,7 +245,7 @@ function renderDetections() { infoEl.style.display = extras.length ? 'block' : 'none' } -function applyVideoSettings(muted) { +function applyVideoSettings(event, muted) { if (!stream) return if (stream.video) { stream.video.muted = muted @@ -179,35 +253,74 @@ function applyVideoSettings(muted) { stream.video.disableRemotePlayback = true stream.video.addEventListener('playing', () => { poster.style.opacity = '0' - drawBoxes() + const videoWidth = stream.video.videoWidth + const videoHeight = stream.video.videoHeight + if (videoWidth && videoHeight) { + if (!event.cropToObject) { + const ratio = videoWidth / videoHeight + const newWidth = Math.round(window.innerHeight * ratio) + window.overlay.resize(newWidth, window.innerHeight) + stream.style.width = '100%' + stream.style.height = '100%' + bboxCanvas.style.width = '100%' + bboxCanvas.style.height = '100%' + } else { + stream.style.width = videoWidth + 'px' + stream.style.height = videoHeight + 'px' + bboxCanvas.style.width = videoWidth + 'px' + bboxCanvas.style.height = videoHeight + 'px' + } + + stream.style.maxWidth = 'none' + stream.style.position = 'absolute' + stream.style.left = '0' + stream.style.top = '0' + + drawBoxes() + updateCrop() + } }, { once: true }) } else { - requestAnimationFrame(() => applyVideoSettings(muted)) + requestAnimationFrame(() => applyVideoSettings(event, muted)) } } -function startStream(url, muted) { +function startStream(event, muted) { stopStream() - stream = document.createElement('video-stream') - stream.background = true - stream.mode = 'webrtc,mse' - stream.src = url + if (event.streamUrl === '__MOCK_IMAGE__') { + stream = document.createElement('div') + stream.style.backgroundImage = 'url("https://picsum.photos/id/237/1280/720")' + stream.style.backgroundSize = '100% 100%' + stream.style.zIndex = '1' + + stream.video = document.createElement('div') + stream.video.videoWidth = 1280 + stream.video.videoHeight = 720 + stream.video.style = {} + stream.video.addEventListener = (evt, cb) => { + if (evt === 'playing') setTimeout(() => cb(), 100) + } + } else { + stream = document.createElement('video-stream') + stream.background = true + stream.mode = 'webrtc,mse' + stream.src = event.streamUrl + } videoBox.appendChild(stream) - applyVideoSettings(muted) + applyVideoSettings(event, muted) } function stopStream() { if (stream) { - stream.ondisconnect() + if (stream.ondisconnect) stream.ondisconnect() stream.remove() stream = null } } - function show(event) { clearTimeout(dismissTimer) - const reuseCard = card.classList.contains('show') && event.streamUrl === activeStreamUrl + const reuseCard = activeStreamUrl && event.streamUrl === activeStreamUrl if (!reuseCard || !event.showAllObjectsInFrame) { activeEvents.clear() @@ -232,7 +345,7 @@ function show(event) { poster.style.opacity = '0' poster.removeAttribute('src') } - startStream(event.streamUrl, !event.sound) + startStream(event, !event.sound) card.classList.remove('hidden') requestAnimationFrame(() => card.classList.add('show')) } @@ -240,6 +353,9 @@ function show(event) { function hide() { clearTimeout(dismissTimer) resetTimerBar() + + const eventIds = Array.from(activeEvents.keys()) + clearBoxes() activeEvents.clear() colorMap.clear() @@ -251,7 +367,7 @@ function hide() { setTimeout(() => { stopStream() card.classList.add('hidden') - window.overlay.hide() + window.overlay.hide(eventIds) }, 320) } @@ -262,12 +378,14 @@ window.overlay.onEvent((event) => { activeEvents.set(event.id, event) renderDetections() drawBoxes() + updateCrop() } else if (event.type === 'end' && activeEvents.has(event.id)) { activeEvents.delete(event.id) colorMap.delete(event.id) if (activeEvents.size > 0) { renderDetections() drawBoxes() + updateCrop() } else { clearBoxes() if (event.dismiss > 0) { @@ -278,6 +396,12 @@ window.overlay.onEvent((event) => { } }) +window.overlay.onSetMuted && window.overlay.onSetMuted((muted) => { + if (stream && stream.video) { + stream.video.muted = muted + } +}) + closeBtn.addEventListener('click', (e) => { e.stopPropagation() hide() diff --git a/src/setup/setup.html b/src/setup/setup.html index 3198443..1f22018 100644 --- a/src/setup/setup.html +++ b/src/setup/setup.html @@ -84,6 +84,19 @@

Notifications

Show an instant snapshot while the stream connects + +
+ + +
+
+