Live view at head office, relayed through the agent's outbound connection
I got this wrong first time. "Head office cannot show live video cheaply" conflated TRUE VIDEO with SEEING THE CAMERA NOW, and only the first needs WebRTC and a TURN server. The shop PC is behind a router with no inbound route, so head office cannot pull the engine's MJPEG. It can answer the agent's outbound requests, which is the shape of everything else here: the server holds a poll open, the agent asks "is anyone watching?", and pushes JPEGs up for exactly as long as somebody is. Measured on the office camera: 98 KB full frame, 20.8 KB re-encoded at 640/q60, so one watcher costs ~83 KB/s. 47 frames arrived in 12 seconds - 4 fps, as configured. The UI says "about 4 frames a second" rather than letting anyone conclude the camera stutters. Nothing is uploaded when nobody is looking, which is the whole cost argument: Publish returns false once the last viewer goes, interest lapses on a timer each viewer refreshes as it reads (so a closed tab stops the upload within seconds), one push is capped at five minutes, and the UI streams one camera at a time. LiveHub is deliberately the opposite of the arrivals Hub. There a doorbell pushes nothing because nothing may be lost; here a dropped frame is the correct outcome, so each viewer has a one-slot buffer that is overwritten - the only frame worth having is the newest, and a queue would show an ever-growing delay behind the shop instead of dropping back to live. Ownership is proved once, before anything streams: the relay is keyed on a camera id, a hub does not know whose camera it holds, and a camera id is not a secret. Verified: another tenant gets 404, no session gets 401, and an agent cannot push into another site's camera. Also fixes a bug I introduced with it - the Live button was gated on `connected`, which is head office's last report and up to two minutes stale, so it hid itself during every reconnect. "Is that camera really down?" is exactly when somebody wants to look, and a hidden control says "you cannot" where the honest answer is "here is why". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
@@ -261,3 +261,70 @@ export function streamArrivals({ cursor, siteId, onPage, onError, signal }) {
|
||||
run()
|
||||
return () => { stopped = true }
|
||||
}
|
||||
|
||||
// streamCameraLive renders one camera's live view into an <img>.
|
||||
//
|
||||
// Frames arrive base64 over SSE for the same reason the snapshot is fetched
|
||||
// rather than linked: an <img> cannot send an Authorization header, and minting
|
||||
// a URL that works without a session — for LIVE video of a shop floor — would
|
||||
// be a far worse trade than the 33% base64 costs.
|
||||
//
|
||||
// The frame is written straight into `img.src` as a data URL rather than an
|
||||
// object URL. Object URLs would have to be revoked one per frame, several times
|
||||
// a second, and a single missed revoke is a leak that grows for as long as the
|
||||
// view is open. A data URL is owned by the element and replaced by the next one.
|
||||
export function streamCameraLive({ cameraId, img, onState, signal }) {
|
||||
let stopped = false
|
||||
|
||||
const run = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const { access } = tokens()
|
||||
const res = await fetch(`/api/cameras/${cameraId}/live`, {
|
||||
headers: { Authorization: 'Bearer ' + access, Accept: 'text/event-stream' },
|
||||
signal,
|
||||
})
|
||||
if (res.status === 401) { await refresh(); continue }
|
||||
if (!res.ok || !res.body) throw new Error('live view unavailable')
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (!stopped) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
let split
|
||||
while ((split = buf.indexOf('\n\n')) !== -1) {
|
||||
const chunk = buf.slice(0, split)
|
||||
buf = buf.slice(split + 2)
|
||||
let event = 'message', data = ''
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (line.startsWith('event: ')) event = line.slice(7).trim()
|
||||
else if (line.startsWith('data: ')) data = line.slice(6)
|
||||
}
|
||||
if (event === 'frame' && data) {
|
||||
if (img.current) img.current.src = 'data:image/jpeg;base64,' + data
|
||||
onState?.('live')
|
||||
} else if (event === 'waiting') {
|
||||
// The server has registered us; the shop PC has not started
|
||||
// pushing yet. Saying so beats an empty box, because the wait is
|
||||
// a real second or two while the agent is asked.
|
||||
onState?.('waiting')
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (stopped || signal?.aborted) return
|
||||
onState?.('reconnecting')
|
||||
}
|
||||
if (stopped) return
|
||||
// The server caps one push so a tab left open for a week does not leave
|
||||
// a shop uploading for a week. Reconnecting is how a viewer who IS still
|
||||
// watching carries on, so this is a normal event, not an error.
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
}
|
||||
}
|
||||
run()
|
||||
return () => { stopped = true }
|
||||
}
|
||||
|
||||
@@ -497,3 +497,42 @@ button.ghost.danger:hover { border-color: var(--bad); }
|
||||
.assistant { width: 100%; height: 60vh; position: static; border-left: 0;
|
||||
border-top: 1px solid var(--line); }
|
||||
}
|
||||
|
||||
|
||||
/* ------------------------------------------------------- live camera view */
|
||||
/* Deliberately a small affordance on the picture rather than a big play
|
||||
button: opening it makes a shop PC start uploading, so it is an action
|
||||
somebody chooses, not one a page does on their behalf. */
|
||||
.card.cam .watch {
|
||||
position: absolute; left: 10px; top: 10px; z-index: 2;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 9px; border-radius: 999px; border: 0; cursor: pointer;
|
||||
font: inherit; font-size: 11.5px; font-weight: 600; letter-spacing: .02em;
|
||||
color: #fff; background: rgba(12, 16, 20, .72);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.card.cam .watch:hover { background: rgba(12, 16, 20, .9); }
|
||||
.card.cam .watch i {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: #E5484D;
|
||||
box-shadow: 0 0 0 0 rgba(229, 72, 77, .7);
|
||||
animation: livepulse 2s infinite;
|
||||
}
|
||||
@keyframes livepulse {
|
||||
70% { box-shadow: 0 0 0 6px rgba(229, 72, 77, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card.cam .watch i { animation: none; }
|
||||
}
|
||||
|
||||
.drawer.live { max-width: 760px; }
|
||||
.liveshot {
|
||||
position: relative; background: #000; border-radius: 10px; overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.liveshot img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.livewait {
|
||||
position: absolute; inset: 0; display: grid; place-items: center;
|
||||
font-size: 13px; color: rgba(255, 255, 255, .78);
|
||||
background: rgba(0, 0, 0, .35);
|
||||
}
|
||||
|
||||
85
web/src/views/CameraLive.jsx
Normal file
85
web/src/views/CameraLive.jsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { streamCameraLive } from '../api.js'
|
||||
|
||||
// The live view of one camera.
|
||||
//
|
||||
// It is a few frames a second, not video, and the label says so rather than
|
||||
// letting somebody conclude the camera is stuttering. That is the honest limit
|
||||
// of relaying through the agent's outbound connection; true 25 fps would need
|
||||
// WebRTC and a TURN server, which is a different piece of infrastructure.
|
||||
//
|
||||
// **Nothing is uploaded from the shop until this component is mounted**, and it
|
||||
// stops within seconds of it going away. That is the whole reason a live view
|
||||
// is affordable at all, and why this is a deliberate action rather than
|
||||
// something every camera card does on its own.
|
||||
export default function CameraLive({ camera, onClose }) {
|
||||
const img = useRef(null)
|
||||
const [state, setState] = useState('waiting')
|
||||
// Nothing has arrived after a sensible wait. Its own state, because "the
|
||||
// shop PC has not answered" is a different thing to tell somebody than
|
||||
// "connecting", and leaving a spinner up forever tells them nothing at all.
|
||||
const [stalled, setStalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController()
|
||||
const stop = streamCameraLive({
|
||||
cameraId: camera.id, img,
|
||||
onState: s => { setState(s); if (s === 'live') setStalled(false) },
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
// Generous: the server holds the agent's poll, the agent then has to reach
|
||||
// the camera, and a cold start is a couple of seconds even when everything
|
||||
// works.
|
||||
const t = setTimeout(() => setStalled(true), 12000)
|
||||
return () => { stop(); ctrl.abort(); clearTimeout(t) }
|
||||
}, [camera.id])
|
||||
|
||||
// What to say when nothing arrives. The two causes need different actions -
|
||||
// one is the camera, the other is the PC - so they must not share a message.
|
||||
const stalledNote = camera.connected === false
|
||||
? 'This camera was not connecting when the shop PC last reported. Check it is powered on and reachable on the shop’s network.'
|
||||
: camera.connected == null
|
||||
? 'The shop PC has not reported on this camera yet. It may still be starting up.'
|
||||
: 'The shop PC is not sending frames. It may be offline, or its Behavision app may not be running.'
|
||||
|
||||
const note = stalled && state !== 'live'
|
||||
? stalledNote
|
||||
: {
|
||||
waiting: 'Asking the shop PC…',
|
||||
live: 'Live · about 4 frames a second',
|
||||
reconnecting: 'Reconnecting…',
|
||||
}[state]
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer live" onClick={e => e.stopPropagation()}>
|
||||
<header className="drawer-head">
|
||||
<div>
|
||||
<h2>{camera.label}</h2>
|
||||
<p className="sub">{camera.site}</p>
|
||||
</div>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
<div className="drawer-body">
|
||||
<div className="liveshot">
|
||||
{/* Seeded with the stored snapshot so the first second shows the
|
||||
camera rather than a black rectangle. It is the same view, a
|
||||
minute old, which is a far better place to start from than
|
||||
nothing. */}
|
||||
<img ref={img} alt={`Live view from ${camera.label}`} />
|
||||
{state !== 'live' && (
|
||||
<div className="livewait">
|
||||
<span>{stalled ? 'No picture yet' : note}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="hint" style={{ marginTop: 10 }}>
|
||||
{state === 'live'
|
||||
? 'Live · about 4 frames a second. The shop only uploads while this is open.'
|
||||
: note}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import Shot from './Shot.jsx'
|
||||
import CameraLive from './CameraLive.jsx'
|
||||
import { ago, Loading, Problem } from './Sites.jsx'
|
||||
import CameraSetup from './CameraSetup.jsx'
|
||||
|
||||
@@ -39,6 +40,10 @@ export default function Cameras({ user }) {
|
||||
usePolled(() => api.cameras(), 20000, [])
|
||||
const { data: sites } = usePolled(() => api.sites(), 0, [])
|
||||
const [editing, setEditing] = useState(null)
|
||||
// Only one camera streams at a time, on purpose. Every open view makes a
|
||||
// shop PC upload, so a grid that went live all at once would put an estate's
|
||||
// worth of cameras on the wire because somebody opened a page.
|
||||
const [watching, setWatching] = useState(null)
|
||||
|
||||
const canEdit = ['admin', 'owner', 'manager'].includes(user.role)
|
||||
const list = cams || []
|
||||
@@ -76,7 +81,8 @@ export default function Cameras({ user }) {
|
||||
<div className="grid cams">
|
||||
{list.map(c => (
|
||||
<CameraCard key={c.id} cam={c} canEdit={canEdit}
|
||||
onEdit={() => setEditing(c)} />
|
||||
onEdit={() => setEditing(c)}
|
||||
onWatch={() => setWatching(c)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -90,11 +96,15 @@ export default function Cameras({ user }) {
|
||||
onSaved={(_, opts) => { if (!opts?.keepOpen) setEditing(null); reload() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{watching && (
|
||||
<CameraLive camera={watching} onClose={() => setWatching(null)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CameraCard({ cam, canEdit, onEdit }) {
|
||||
function CameraCard({ cam, canEdit, onEdit, onWatch }) {
|
||||
// Three states, not two. A camera nobody has tried yet is not a camera that
|
||||
// is down, and telling an operator to check the cabling on a camera the shop
|
||||
// PC has not even seen sends them to the wrong building.
|
||||
@@ -136,6 +146,18 @@ function CameraCard({ cam, canEdit, onEdit }) {
|
||||
{cam.snapshot_at && (
|
||||
<span className="shot-age">{ago(cam.snapshot_at)}</span>
|
||||
)}
|
||||
|
||||
{/* Always offered, including when this card says the camera is down.
|
||||
`connected` is head office's LAST REPORT and can be two minutes
|
||||
stale, so gating on it hid the button during every reconnect - and
|
||||
"is that camera really down?" is precisely the moment somebody wants
|
||||
to look. A hidden control says "you cannot" when the honest answer
|
||||
is "here is why", which the live view itself can give.
|
||||
stopPropagation because the card itself opens Edit. */}
|
||||
<button className="watch" title="Watch this camera now"
|
||||
onClick={e => { e.stopPropagation(); onWatch() }}>
|
||||
<i aria-hidden="true" />Live
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connected and verified are different claims, and the gap between them
|
||||
|
||||
Reference in New Issue
Block a user