Files
app/frontend/src/components/Dashboard/MediaWidget.tsx
T
Melvin Strobl ecc4bee4bb 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.
2026-10-16 20:43:14 +02:00

144 lines
3.8 KiB
TypeScript

import { useRef } from "react"
import { useOnScreen } from "@/hooks/useOnScreen"
import { isRef, useArtifactUrl } from "@/lib/media"
import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
import { config, text } from "./ui/core/config"
import type { WidgetProps } from "./widgets"
/**
* What a message's bytes look like: a frame, a clip, a segment.
*
* Media never travels as a message — the reference does. The bytes come down
* the socket in front of it where the engine is holding the frame in memory,
* and are fetched from the artifact store otherwise; either way this tile only
* asks for what it is drawing. A camera serving its own stream is still played
* from source: point `stream_url` at it and the browser does the work.
*/
export function MediaWidget({ widget }: WidgetProps) {
const cfg = config(widget)
return (
<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)}
/>
)
}
}