Cameras on devices (openpanel C1): frames in memory, a binary uplink, demand
- Volatile frames are held in memory with a byte-bounded queue instead of the /dev/shm ring scanned on every put; workers keep writing to the directory, which is trimmed on a timer. A volatile upload no longer spools to disk. ARTIFACT_VOLATILE_BYTES=0 turns the ring off. - A device sends camera frames as binary messages on its socket, held and published in one step and credited to its device node; refused frames are logged and dropped. Its snapshot carries demand and the uplink (binary, or http through the portal). - One demand registry for every socket's wanted media, the tunnel's included, plus what running flows read, mapped onto on-demand ports and published as device_demand; at most 8 media names per socket. - Per-message deflate off in serve and dev; a device may fetch the last 8 digests of a message; published dashboards cached per store revision; a revoked device socket is closed with 1008. - Media tiles want bytes only while on screen; the Devices tab previews a wired camera; the device node warns when a flow reads an on-demand port.
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import { useRef } from "react"
|
||||
|
||||
import { useOnScreen } from "@/hooks/useOnScreen"
|
||||
import { isRef, useArtifactUrl } from "@/lib/media"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useBoundValue } from "./dataContext"
|
||||
@@ -15,74 +18,126 @@ import type { WidgetProps } from "./widgets"
|
||||
*/
|
||||
export function MediaWidget({ widget }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const stream = text(cfg.stream_url)
|
||||
const live = useBoundValue(message || undefined)
|
||||
const value = live?.value
|
||||
const ref = isRef(value) ? value : null
|
||||
const url = useArtifactUrl(ref, message || undefined)
|
||||
|
||||
const kind = (ref?.media_type ?? text(cfg.dtype)).split("/")[0]
|
||||
const fit = text(cfg.fit) === "contain" ? "object-contain" : "object-cover"
|
||||
const label = widget.title || ref?.name || message
|
||||
|
||||
// A camera serving its own stream is played from source: the engine carries
|
||||
// references at whatever rate the flow publishes them, which is not video.
|
||||
if (stream && kind !== "audio") {
|
||||
return (
|
||||
<img
|
||||
src={stream}
|
||||
alt={label}
|
||||
className={cn("h-full w-full rounded-md", fit)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!message && !stream) {
|
||||
return <p className="text-muted-foreground">Pick a message.</p>
|
||||
}
|
||||
if (!ref) {
|
||||
return <p className="text-muted-foreground">Nothing published yet.</p>
|
||||
}
|
||||
if (!url) {
|
||||
return <p className="text-muted-foreground">Loading…</p>
|
||||
}
|
||||
|
||||
if (kind === "audio") {
|
||||
return (
|
||||
<audio
|
||||
// Keyed on the digest so a new clip replaces the element rather than
|
||||
// leaving the last one's playhead on it.
|
||||
key={ref.digest}
|
||||
src={url}
|
||||
controls
|
||||
autoPlay={Boolean(cfg.autoplay)}
|
||||
className="w-full"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === "video") {
|
||||
return (
|
||||
<video
|
||||
key={ref.digest}
|
||||
src={url}
|
||||
controls
|
||||
autoPlay={Boolean(cfg.autoplay)}
|
||||
className={cn("h-full w-full rounded-md", fit)}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={label}
|
||||
className={cn("h-full w-full rounded-md", fit)}
|
||||
<MediaView
|
||||
message={text(cfg.message)}
|
||||
stream={text(cfg.stream_url)}
|
||||
dtype={text(cfg.dtype)}
|
||||
fit={text(cfg.fit) === "contain" ? "contain" : "cover"}
|
||||
autoplay={Boolean(cfg.autoplay)}
|
||||
label={widget.title}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile's body, for anywhere a message's media is shown.
|
||||
*
|
||||
* Frames are asked for only while the tile is on screen, in a tab being
|
||||
* looked at: a camera is only sent while somebody watches it, and a tile
|
||||
* scrolled away or behind another tab is nobody. A clip keeps playing either
|
||||
* way.
|
||||
*/
|
||||
export function MediaView({
|
||||
message,
|
||||
stream = "",
|
||||
dtype = "",
|
||||
fit = "cover",
|
||||
autoplay = false,
|
||||
label,
|
||||
}: {
|
||||
message: string
|
||||
stream?: string
|
||||
dtype?: string
|
||||
fit?: "cover" | "contain"
|
||||
autoplay?: boolean
|
||||
label?: string
|
||||
}) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const shown = useOnScreen(box)
|
||||
const live = useBoundValue(message || undefined)
|
||||
const value = live?.value
|
||||
const ref = isRef(value) ? value : null
|
||||
const kind = (ref?.media_type ?? dtype).split("/")[0]
|
||||
const playing = kind === "audio" || kind === "video"
|
||||
const active = shown || playing
|
||||
const url = useArtifactUrl(
|
||||
active ? ref : null,
|
||||
active ? message || undefined : undefined,
|
||||
)
|
||||
|
||||
const objectFit = fit === "contain" ? "object-contain" : "object-cover"
|
||||
const alt = label || ref?.name || message
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={box}
|
||||
className="flex min-h-0 w-full flex-1 flex-col justify-center"
|
||||
>
|
||||
{body()}
|
||||
</div>
|
||||
)
|
||||
|
||||
function body() {
|
||||
// A camera serving its own stream is played from source: the engine
|
||||
// carries references at whatever rate the flow publishes them, which is
|
||||
// not video.
|
||||
if (stream && kind !== "audio") {
|
||||
return (
|
||||
<img
|
||||
src={stream}
|
||||
alt={alt}
|
||||
className={cn("h-full w-full rounded-md", objectFit)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!message && !stream) {
|
||||
return <p className="text-muted-foreground">Pick a message.</p>
|
||||
}
|
||||
if (!ref) {
|
||||
return <p className="text-muted-foreground">Nothing published yet.</p>
|
||||
}
|
||||
if (!url) {
|
||||
return <p className="text-muted-foreground">Loading…</p>
|
||||
}
|
||||
|
||||
if (kind === "audio") {
|
||||
return (
|
||||
<audio
|
||||
// Keyed on the digest so a new clip replaces the element rather than
|
||||
// leaving the last one's playhead on it.
|
||||
key={ref.digest}
|
||||
src={url}
|
||||
controls
|
||||
autoPlay={autoplay}
|
||||
className="w-full"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
)
|
||||
}
|
||||
|
||||
if (kind === "video") {
|
||||
return (
|
||||
<video
|
||||
key={ref.digest}
|
||||
src={url}
|
||||
controls
|
||||
autoPlay={autoplay}
|
||||
className={cn("h-full w-full rounded-md", objectFit)}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt}
|
||||
className={cn("h-full w-full rounded-md", objectFit)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
FlowsService,
|
||||
} from "@/client"
|
||||
import { initials, resolveIcon } from "@/components/Dashboard/icons"
|
||||
import { MediaView } from "@/components/Dashboard/MediaWidget"
|
||||
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { flowsQueryOptions } from "@/components/Flow/queries"
|
||||
import { InfoTip, PanelSection } from "@/components/Flow/SidePanel"
|
||||
@@ -139,6 +140,7 @@ export function DeviceDetail({ id }: { id: string }) {
|
||||
<section className={cn(CARD, "grid gap-4")}>
|
||||
<Basics device={device} canEdit={canEdit} onChange={write} />
|
||||
</section>
|
||||
<Camera device={device} />
|
||||
<section className={CARD}>
|
||||
<Dashboards device={device} canEdit={canEdit} onChange={write} />
|
||||
</section>
|
||||
@@ -216,6 +218,38 @@ function Basics({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the device's camera sees, once a flow wires its `camera` port.
|
||||
*
|
||||
* Watching is what turns the camera on: it sends frames only while this
|
||||
* preview, a tile or a flow reading them wants them.
|
||||
*/
|
||||
function Camera({ device }: { device: DevicePublic }) {
|
||||
const bound = (device.bindings ?? []).find(
|
||||
(binding) => binding.port === "camera" && binding.direction === "in",
|
||||
)
|
||||
if (!bound) return null
|
||||
return (
|
||||
<section className={CARD}>
|
||||
<PanelSection
|
||||
title="Camera"
|
||||
help="Live while this preview is on screen. The camera sends frames only while someone watches them here or on a dashboard, or a running flow reads them."
|
||||
>
|
||||
<div
|
||||
className="flex aspect-video w-full overflow-hidden rounded-md bg-muted text-center text-sm"
|
||||
data-testid="device-camera"
|
||||
>
|
||||
<MediaView
|
||||
message={bound.message}
|
||||
fit="contain"
|
||||
label={`${device.title || device.id} camera`}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Which dashboards the device shows, in the order its rail carries them. */
|
||||
function Dashboards({
|
||||
device,
|
||||
|
||||
@@ -511,6 +511,19 @@ function FlowEditorInner({
|
||||
}
|
||||
}, [key])
|
||||
|
||||
// What the flow's nodes read, for a device node to say that a camera read
|
||||
// here sends all the time.
|
||||
const readers = useMemo(() => {
|
||||
const found = new Set<string>()
|
||||
for (const node of definitions) {
|
||||
if (node.type === DEVICE_TYPE) continue
|
||||
for (const spec of node.requires ?? []) {
|
||||
if (spec.name) found.add(qualify(flowName, spec.name))
|
||||
}
|
||||
}
|
||||
return found
|
||||
}, [definitions, flowName])
|
||||
|
||||
/**
|
||||
* Where clicking an endpoint takes you: the thing it stands for.
|
||||
*
|
||||
@@ -1508,6 +1521,7 @@ function FlowEditorInner({
|
||||
nodeTypes={nodeTypeInfo ?? []}
|
||||
origin={flowDoc.origin}
|
||||
suggestions={suggestions}
|
||||
readers={readers}
|
||||
expanded={editorExpanded}
|
||||
onToggleExpand={() => setEditorExpanded((wide) => !wide)}
|
||||
onChange={updateNode}
|
||||
|
||||
@@ -56,6 +56,7 @@ import useAuth from "@/hooks/useAuth"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { inCodeEditor, useShortcuts } from "@/lib/shortcuts"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { qualify } from "./deriveEdges"
|
||||
import { DtypeValue } from "./FlowBoundary"
|
||||
import {
|
||||
IDENTIFIER,
|
||||
@@ -508,11 +509,14 @@ function DeviceSettings({
|
||||
node,
|
||||
flow,
|
||||
suggestions,
|
||||
readers,
|
||||
onChange,
|
||||
}: {
|
||||
node: NodeDef_Input
|
||||
flow: string
|
||||
suggestions: PortSuggestions
|
||||
/** Qualified messages another node of this flow reads. */
|
||||
readers: ReadonlySet<string>
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
}) {
|
||||
const { data } = useQuery(devicesQueryOptions())
|
||||
@@ -591,6 +595,7 @@ function DeviceSettings({
|
||||
flow={flow}
|
||||
config={config}
|
||||
reported={reported}
|
||||
readers={readers}
|
||||
suggestions={suggestions.provides}
|
||||
onChange={ports("provides")}
|
||||
onConfig={setConfig}
|
||||
@@ -619,6 +624,7 @@ function DevicePortList({
|
||||
flow,
|
||||
config,
|
||||
reported,
|
||||
readers,
|
||||
suggestions,
|
||||
onChange,
|
||||
onConfig,
|
||||
@@ -632,6 +638,8 @@ function DevicePortList({
|
||||
config: Record<string, unknown>
|
||||
/** What the device says it has, or null when it has not said. */
|
||||
reported: ReportedPort[] | null
|
||||
/** Qualified messages another node of this flow reads. */
|
||||
readers?: ReadonlySet<string>
|
||||
suggestions: string[]
|
||||
/** `renamed` is a port taking another name, or dropped (`to` of null). */
|
||||
onChange: (next: MessageSpec[], renamed?: [string, string | null]) => void
|
||||
@@ -809,6 +817,18 @@ function DevicePortList({
|
||||
The device does not report this port.
|
||||
</p>
|
||||
) : null}
|
||||
{said?.on_demand &&
|
||||
spec.name &&
|
||||
readers?.has(qualify(flow, spec.name)) ? (
|
||||
<p
|
||||
className="pl-5.5 text-xs text-status-warning"
|
||||
data-testid="device-port-always-on"
|
||||
>
|
||||
A node in this flow reads every frame, so {port} sends all the
|
||||
time, also while nobody watches. Read snapshot or person
|
||||
instead.
|
||||
</p>
|
||||
) : null}
|
||||
{schema ? (
|
||||
<div className="pl-5.5">
|
||||
<ParamsForm
|
||||
@@ -1771,6 +1791,7 @@ function PanelBody({
|
||||
nodeType,
|
||||
origin,
|
||||
suggestions,
|
||||
readers,
|
||||
expanded,
|
||||
onChange,
|
||||
onRenameMessage,
|
||||
@@ -1783,6 +1804,7 @@ function PanelBody({
|
||||
nodeType: NodeTypeInfo | undefined
|
||||
origin: FlowOrigin | null | undefined
|
||||
suggestions: PortSuggestions
|
||||
readers: ReadonlySet<string>
|
||||
expanded: boolean
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onRenameMessage: (previous: string, next: string) => void
|
||||
@@ -1954,6 +1976,7 @@ function PanelBody({
|
||||
node={node}
|
||||
flow={flow}
|
||||
suggestions={suggestions}
|
||||
readers={readers}
|
||||
onChange={editNode}
|
||||
/>
|
||||
) : (
|
||||
@@ -2133,6 +2156,7 @@ export function NodePanel({
|
||||
nodeTypes,
|
||||
origin,
|
||||
suggestions,
|
||||
readers,
|
||||
expanded,
|
||||
onChange,
|
||||
onRenameMessage,
|
||||
@@ -2148,6 +2172,8 @@ export function NodePanel({
|
||||
/** Set when the flow was declared in code elsewhere; its bodies are generated. */
|
||||
origin: FlowOrigin | null | undefined
|
||||
suggestions: PortSuggestions
|
||||
/** Qualified messages the flow's nodes read, a device node's aside. */
|
||||
readers: ReadonlySet<string>
|
||||
expanded: boolean
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onRenameMessage: (previous: string, next: string) => void
|
||||
@@ -2201,6 +2227,7 @@ export function NodePanel({
|
||||
nodeType={nodeType}
|
||||
origin={origin}
|
||||
suggestions={suggestions}
|
||||
readers={readers}
|
||||
expanded={expanded}
|
||||
onChange={onChange}
|
||||
onRenameMessage={onRenameMessage}
|
||||
|
||||
Reference in New Issue
Block a user