Add snapshot-first Frigate camera wall

This commit is contained in:
OpenClaw
2026-04-18 23:36:00 +00:00
parent 0f6b700ed2
commit 16464bc938
6 changed files with 87 additions and 18 deletions

View File

@@ -4,6 +4,8 @@ visionLabs Roku is a private Roku app scaffold for viewing home security cameras
The current starter path is Frigate-backed, with Roku reading a static inventory of cameras that are managed by Frigate instead of embedding raw camera credentials in the app. The current starter path is Frigate-backed, with Roku reading a static inventory of cameras that are managed by Frigate instead of embedding raw camera credentials in the app.
The first playback strategy is `snapshotFirst`, which lets the Roku app render a useful camera wall before the direct live stream endpoint shape is finalized.
## Product direction ## Product direction
- Brand family follows the user's existing `Labs` naming pattern. - Brand family follows the user's existing `Labs` naming pattern.
@@ -36,6 +38,7 @@ Roku is not a great target for raw RTSP feeds. With Frigate in the middle, the c
1. Set the real Frigate base URL in `config/cameras.json` 1. Set the real Frigate base URL in `config/cameras.json`
2. Map the four starter cameras in Frigate using the same IDs as the Roku config 2. Map the four starter cameras in Frigate using the same IDs as the Roku config
3. Load config into the UI 3. Verify that Frigate snapshots load at `/api/<camera>/latest.jpg`
4. Render a static multi-camera grid 4. Render the snapshot wall on Roku
5. Add edit mode and persistence strategy 5. Choose the direct live stream path for full playback
6. Add edit mode and persistence strategy

View File

@@ -2,6 +2,12 @@ sub init()
m.top.setFocus(true) m.top.setFocus(true)
m.backend = m.top.findNode("backend") m.backend = m.top.findNode("backend")
m.status = m.top.findNode("status") m.status = m.top.findNode("status")
m.cameraPosters = [
m.top.findNode("poster1"),
m.top.findNode("poster2"),
m.top.findNode("poster3"),
m.top.findNode("poster4")
]
m.cameraNodes = [ m.cameraNodes = [
m.top.findNode("cam1"), m.top.findNode("cam1"),
m.top.findNode("cam2"), m.top.findNode("cam2"),
@@ -31,9 +37,10 @@ sub loadConfig()
end if end if
cameras = config.cameras cameras = config.cameras
m.status.text = cameras.count().toStr() + " cameras loaded from static config" m.status.text = cameras.count().toStr() + " cameras loaded, snapshot-first prototype"
for i = 0 to m.cameraNodes.count() - 1 for i = 0 to m.cameraNodes.count() - 1
poster = m.cameraPosters[i]
node = m.cameraNodes[i] node = m.cameraNodes[i]
if i < cameras.count() then if i < cameras.count() then
@@ -48,9 +55,36 @@ sub loadConfig()
label = label + chr(10) + "Frigate ID: " + camera.frigateCamera label = label + chr(10) + "Frigate ID: " + camera.frigateCamera
end if end if
poster.uri = buildSnapshotUrl(config, camera)
node.text = label node.text = label
else else
poster.uri = ""
node.text = "Empty slot" node.text = "Empty slot"
end if end if
end for end for
end sub end sub
function buildSnapshotUrl(config as Object, camera as Object) as String
if not config.doesExist("backend") or not config.backend.doesExist("baseUrl") then
return ""
end if
baseUrl = config.backend.baseUrl
path = ""
if camera.doesExist("snapshotPath") then
path = camera.snapshotPath
else if camera.doesExist("frigateCamera") then
path = "/api/" + camera.frigateCamera + "/latest.jpg"
else
return ""
end if
if right(baseUrl, 1) = "/" and left(path, 1) = "/" then
return left(baseUrl, len(baseUrl) - 1) + path
else if right(baseUrl, 1) <> "/" and left(path, 1) <> "/" then
return baseUrl + "/" + path
end if
return baseUrl + path
end function

View File

@@ -36,35 +36,59 @@
text="Loading camera config..." text="Loading camera config..."
font="font:MediumSystemFont" font="font:MediumSystemFont"
color="0x8B949EFF" /> color="0x8B949EFF" />
<Label <Poster
id="cam1" id="poster1"
translation="[80, 360]" translation="[80, 360]"
width="760" width="760"
height="120" height="300"
loadDisplayMode="scaleToFit" />
<Label
id="cam1"
translation="[80, 675]"
width="760"
height="70"
text="Cam slot 1" text="Cam slot 1"
font="font:MediumSystemFont" font="font:MediumSystemFont"
color="0xFFFFFFFF" /> color="0xFFFFFFFF" />
<Label <Poster
id="cam2" id="poster2"
translation="[980, 360]" translation="[980, 360]"
width="760" width="760"
height="120" height="300"
loadDisplayMode="scaleToFit" />
<Label
id="cam2"
translation="[980, 675]"
width="760"
height="70"
text="Cam slot 2" text="Cam slot 2"
font="font:MediumSystemFont" font="font:MediumSystemFont"
color="0xFFFFFFFF" /> color="0xFFFFFFFF" />
<Poster
id="poster3"
translation="[80, 760]"
width="760"
height="300"
loadDisplayMode="scaleToFit" />
<Label <Label
id="cam3" id="cam3"
translation="[80, 580]" translation="[80, 1015]"
width="760" width="760"
height="120" height="50"
text="Cam slot 3" text="Cam slot 3"
font="font:MediumSystemFont" font="font:MediumSystemFont"
color="0xFFFFFFFF" /> color="0xFFFFFFFF" />
<Poster
id="poster4"
translation="[980, 760]"
width="760"
height="300"
loadDisplayMode="scaleToFit" />
<Label <Label
id="cam4" id="cam4"
translation="[980, 580]" translation="[980, 1015]"
width="760" width="760"
height="120" height="50"
text="Cam slot 4" text="Cam slot 4"
font="font:MediumSystemFont" font="font:MediumSystemFont"
color="0xFFFFFFFF" /> color="0xFFFFFFFF" />

View File

@@ -3,6 +3,7 @@
"backend": { "backend": {
"type": "frigate", "type": "frigate",
"baseUrl": "https://home.labs.casa/ccab4aaf_frigate", "baseUrl": "https://home.labs.casa/ccab4aaf_frigate",
"mode": "snapshotFirst",
"notes": "Keep raw camera credentials in Frigate or go2rtc, not in the Roku repo." "notes": "Keep raw camera credentials in Frigate or go2rtc, not in the Roku repo."
}, },
"layout": { "layout": {
@@ -16,6 +17,7 @@
"name": "Cam 195", "name": "Cam 195",
"frigateCamera": "cam195", "frigateCamera": "cam195",
"host": "192.168.0.195", "host": "192.168.0.195",
"snapshotPath": "/api/cam195/latest.jpg",
"onvif": true "onvif": true
}, },
{ {
@@ -23,6 +25,7 @@
"name": "Cam 196", "name": "Cam 196",
"frigateCamera": "cam196", "frigateCamera": "cam196",
"host": "192.168.0.196", "host": "192.168.0.196",
"snapshotPath": "/api/cam196/latest.jpg",
"onvif": true "onvif": true
}, },
{ {
@@ -30,6 +33,7 @@
"name": "Cam 201", "name": "Cam 201",
"frigateCamera": "cam201", "frigateCamera": "cam201",
"host": "192.168.0.201", "host": "192.168.0.201",
"snapshotPath": "/api/cam201/latest.jpg",
"onvif": true "onvif": true
}, },
{ {
@@ -37,6 +41,7 @@
"name": "Cam 228", "name": "Cam 228",
"frigateCamera": "cam228", "frigateCamera": "cam228",
"host": "192.168.0.228", "host": "192.168.0.228",
"snapshotPath": "/api/cam228/latest.jpg",
"onvif": true "onvif": true
} }
] ]

View File

@@ -27,11 +27,13 @@ Keep camera credentials inside:
1. Set the real Frigate base URL in `config/cameras.json` 1. Set the real Frigate base URL in `config/cameras.json`
2. Confirm the Frigate camera IDs match `cam195`, `cam196`, `cam201`, and `cam228` 2. Confirm the Frigate camera IDs match `cam195`, `cam196`, `cam201`, and `cam228`
3. Decide which Frigate live endpoint format the Roku app should consume first 3. Verify that each camera snapshot is reachable at `/api/<camera>/latest.jpg`
4. Add snapshot and live tile loading in the SceneGraph UI 4. Add snapshot tile loading in the SceneGraph UI
5. Decide which direct live endpoint format the Roku app should consume after snapshots are working
## Why this shape works ## Why this shape works
- Roku does not want to manage ONVIF directly - Roku does not want to manage ONVIF directly
- Roku is happier when Frigate handles the camera-side protocol mess - Roku is happier when Frigate handles the camera-side protocol mess
- This keeps the app focused on layout, browsing, and playback - This keeps the app focused on layout, browsing, and playback
- Snapshot-first is the lowest-risk way to prove the app shape before live playback is finalized

View File

@@ -4,12 +4,12 @@
- Static packaged Frigate-backed camera config - Static packaged Frigate-backed camera config
- Simple Roku SceneGraph shell - Simple Roku SceneGraph shell
- One screen with a fixed camera grid - One screen with a fixed snapshot-first camera grid
- Frigate-managed live feeds in a Roku-friendly format - Frigate-managed live feeds in a Roku-friendly format
## Phase 2 ## Phase 2
- Config loader and camera tile rendering - Config loader and snapshot tile rendering
- Snapshot fallback if a stream is unavailable - Snapshot fallback if a stream is unavailable
- Focus movement and remote navigation - Focus movement and remote navigation
@@ -30,4 +30,5 @@
- Roku does not directly fit raw RTSP workflows well - Roku does not directly fit raw RTSP workflows well
- Frigate should own camera auth and camera-side protocol details - Frigate should own camera auth and camera-side protocol details
- HLS is the safest first-class playback target - HLS is the safest first-class playback target
- Snapshot-first is the safest prototype path while live endpoint details are still being verified
- Packaged JSON is fine for V1 but not enough for runtime editing - Packaged JSON is fine for V1 but not enough for runtime editing