Add snapshot-first Frigate camera wall
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user