The rate the media dtypes could carry was one frame every second or two: each
was a file on the data volume, an event on the socket, and a request back for
the bytes. This closes both halves of that, and they are one feature.
`save_artifact(..., volatile=True)` writes to a `VolatileStore` — the same
content-addressed store, in `/dev/shm`, bounded by size with the oldest falling
out (`ARTIFACT_VOLATILE_BYTES`, 48 MB under the container's raised `shm_size`).
Nothing sweeps it: a frame nobody kept is not worth walking the store to find.
`ArtifactStore.path` falls through to it, which is what lets a volatile frame be
an ordinary reference everywhere else — the dtype check, a panel's digest scope,
`load_artifact` in a node, and the widget's own fetch all work on one unchanged.
`adopt` copies one into the store when a run records it, so "returned media is
kept, emitted media is not" stays true.
The bytes then go down the flows websocket as a length-prefixed binary frame,
sent just ahead of the `message_value` naming them, so a tile has the frame when
it hears the value moved. Nothing is pushed unasked: a client names the messages
it is drawing (`{"type":"media","names":[…]}`), a panel's list is intersected
with the scope it already had, and only the newest frame per name in a batch is
sent — a client that fell behind is not handed frames it would draw over. The
tunnel relays text only, so a screen reached through a portal falls back to
fetching, which is why the rate table now has two rows.
Around the edges: the remote worker's fetch cache is bounded at last
(`FLUKSIO_ARTIFACT_CACHE_BYTES`), since content addressing means nothing in it
ever expires and a media stream fills it with chunks nothing asks for twice; a
port carrying an image draws the frame in the node panel rather than only
saying `image/png · frame.png · 1.79kB`; and an edge chip says that much instead
of a line of hash. The media screenshot stops waiting for `networkidle` — a
camera is a socket that never goes quiet, which is the point of it.
145 lines
4.6 KiB
TypeScript
145 lines
4.6 KiB
TypeScript
import { ChevronRight } from "lucide-react"
|
|
import { useState } from "react"
|
|
|
|
import type { DType } from "@/client"
|
|
import { Marquee } from "@/components/Common/Marquee"
|
|
import { describeArtifact, isRef, useArtifactUrl } from "@/lib/media"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
/**
|
|
* How much of a structured value is worth unfolding in a side panel.
|
|
*
|
|
* The cap sits on the `pre` itself rather than on a `ScrollArea`: that one
|
|
* sizes its viewport in percent, which resolves to the content's own height
|
|
* against a box that has only a maximum, so a long value spills out of the
|
|
* panel or popover and paints over whatever is below it.
|
|
*/
|
|
const MAX_HEIGHT = "max-h-48"
|
|
|
|
function count(n: number, one: string, many = `${one}s`): string {
|
|
return `${n} ${n === 1 ? one : many}`
|
|
}
|
|
|
|
/**
|
|
* What a structured value *is*, in the space a value would have taken.
|
|
*
|
|
* The shape is what you want while wiring — that this port carries a record of
|
|
* three fields, or a checkpoint of sixty kilobytes — and the contents are what
|
|
* you want once something looks wrong. Serialising the whole thing into a line
|
|
* answers the second question badly and the first one not at all.
|
|
*/
|
|
function summarize(value: object, dtype?: DType): string {
|
|
if (Array.isArray(value)) {
|
|
return `${dtype ?? "list"} · ${count(value.length, "item")}`
|
|
}
|
|
|
|
const record = value as Record<string, unknown>
|
|
if (isRef(record)) return describeArtifact(record)
|
|
|
|
if (Array.isArray(record.lines)) {
|
|
const points = record.lines.reduce(
|
|
(total: number, line: unknown) =>
|
|
total +
|
|
(Array.isArray((line as { points?: unknown[] })?.points)
|
|
? ((line as { points: unknown[] }).points.length as number)
|
|
: 0),
|
|
0,
|
|
)
|
|
return `series · ${count(record.lines.length, "line")}, ${count(points, "point")}`
|
|
}
|
|
|
|
return `${dtype ?? "record"} · ${count(Object.keys(record).length, "field")}`
|
|
}
|
|
|
|
/**
|
|
* What a message is carrying: its shape at rest, its contents on request.
|
|
*
|
|
* A scalar simply reads, scrolling itself when it is longer than the room it
|
|
* was given. Anything structured shows what it is and unfolds when asked —
|
|
* which is what keeps a panel four hundred pixels wide from being pushed open
|
|
* by one checkpoint reference.
|
|
*/
|
|
/**
|
|
* The frame itself, where the value is one.
|
|
*
|
|
* A port carrying an image says `image/png · frame.png · 48kB`, which is the
|
|
* right answer while wiring and the wrong one while pointing a camera. Drawn
|
|
* small: this is a side panel, and the tile is where a frame is looked at.
|
|
*/
|
|
function Thumbnail({ value, name }: { value: object; name?: string }) {
|
|
const url = useArtifactUrl(value, name)
|
|
if (!url) return null
|
|
return (
|
|
<img
|
|
src={url}
|
|
alt={String((value as { name?: string }).name ?? "frame")}
|
|
className="mt-1 max-h-24 w-full rounded-sm object-contain"
|
|
data-testid="value-preview-thumbnail"
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function ValuePreview({
|
|
value,
|
|
name,
|
|
dtype,
|
|
defaultOpen = false,
|
|
className,
|
|
}: {
|
|
value: unknown
|
|
/**
|
|
* The message this value is on. Only needed to draw a live frame: it is what
|
|
* asks the engine to push the bytes for it.
|
|
*/
|
|
name?: string
|
|
dtype?: DType
|
|
/** Start unfolded, where there is room for it — an inspector, not a row. */
|
|
defaultOpen?: boolean
|
|
className?: string
|
|
}) {
|
|
const [open, setOpen] = useState(defaultOpen)
|
|
const structured = value !== null && typeof value === "object"
|
|
const image =
|
|
structured && isRef(value) && (value.media_type ?? "").startsWith("image/")
|
|
|
|
if (!structured) {
|
|
return (
|
|
<Marquee
|
|
text={value === undefined ? "" : String(value)}
|
|
className={cn("block font-mono text-xs", className)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={cn("min-w-0", className)}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((was) => !was)}
|
|
aria-expanded={open}
|
|
className="flex w-full min-w-0 items-center gap-1 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
data-testid="value-preview-toggle"
|
|
>
|
|
<ChevronRight
|
|
className={cn(
|
|
"size-3 shrink-0 transition-transform duration-[var(--duration-fast)]",
|
|
open && "rotate-90",
|
|
)}
|
|
/>
|
|
<Marquee text={summarize(value, dtype)} className="flex-1 font-mono" />
|
|
</button>
|
|
{image ? <Thumbnail value={value as object} name={name} /> : null}
|
|
{open ? (
|
|
<pre
|
|
className={cn(
|
|
"mt-1 overflow-auto whitespace-pre-wrap break-all font-mono text-xs",
|
|
MAX_HEIGHT,
|
|
)}
|
|
>
|
|
{JSON.stringify(value, null, 2)}
|
|
</pre>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|