From d471614e6a9c18d2a5ba739ccda8ab1656803b61 Mon Sep 17 00:00:00 2001
From: stroblme
Date: Wed, 2 Sep 2026 10:15:14 +0200
Subject: [PATCH] Push a frame instead of storing and fetching it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01YC4u66vjzW54fnHu5Juhh9
---
backend/fluksio/__init__.py | 5 +-
backend/fluksio/api/routes/artifacts.py | 7 +-
backend/fluksio/api/routes/flows.py | 97 ++++++++++-
backend/fluksio/core/config.py | 11 ++
backend/fluksio/flow/artifacts.py | 122 +++++++++++++-
backend/fluksio/flow/connector.py | 10 +-
backend/fluksio/flow/runs.py | 5 +
backend/fluksio/main.py | 51 +++++-
backend/tests/api/routes/test_panels.py | 80 +++++++++
backend/tests/flow/test_workers.py | 81 ++++++++++
backend/tests/test_artifacts.py | 75 ++++++++-
docker/compose.yml | 5 +
docs/code/nodes.md | 14 +-
docs/code/workers.md | 8 +
docs/interface/dashboards.md | 21 +--
docs/reference/configuration.md | 12 ++
docs/reference/connector-contract.md | 16 +-
docs/reference/payload-types.md | 24 ++-
frontend/scripts/capture-screenshots.mjs | 13 +-
.../src/components/Dashboard/MediaWidget.tsx | 79 +--------
.../src/components/Flow/EdgeInspector.tsx | 7 +-
frontend/src/components/Flow/LiveEdge.tsx | 4 +
.../src/components/Flow/MessageSparkline.tsx | 8 +-
frontend/src/components/Flow/ValuePreview.tsx | 53 +++---
frontend/src/components/Flow/liveStore.ts | 83 ++++++++++
frontend/src/components/Flow/useFlowSocket.ts | 25 +++
frontend/src/lib/media.check.ts | 63 ++++++++
frontend/src/lib/media.ts | 152 ++++++++++++++++++
worker/fluksio_worker/worker_main.py | 117 ++++++++++++--
29 files changed, 1101 insertions(+), 147 deletions(-)
create mode 100644 frontend/src/lib/media.check.ts
create mode 100644 frontend/src/lib/media.ts
diff --git a/backend/fluksio/__init__.py b/backend/fluksio/__init__.py
index 7a296da..2709d71 100644
--- a/backend/fluksio/__init__.py
+++ b/backend/fluksio/__init__.py
@@ -61,7 +61,10 @@ def emit(**ports: Any) -> None:
def save_artifact(
- source: Any, name: str = "", media_type: str = "application/octet-stream"
+ source: Any,
+ name: str = "",
+ media_type: str = "application/octet-stream",
+ volatile: bool = False,
) -> dict[str, Any]:
"""Put bytes in the artifact store and return a reference to them."""
raise RuntimeError(_OUTSIDE.format(name="save_artifact", verb="save to"))
diff --git a/backend/fluksio/api/routes/artifacts.py b/backend/fluksio/api/routes/artifacts.py
index 471737e..2019547 100644
--- a/backend/fluksio/api/routes/artifacts.py
+++ b/backend/fluksio/api/routes/artifacts.py
@@ -80,6 +80,7 @@ async def put_artifact(
request: Request,
name: str = Query(default=""),
media_type: str = Query(default=""),
+ volatile: bool = Query(default=False),
) -> Any:
"""Store the request body and answer with the reference to it.
@@ -87,6 +88,10 @@ async def put_artifact(
legitimate a body here as a checkpoint, and neither should have to fit in
memory twice. Capped, because nothing else here was: any account, and any
worker credential, could otherwise fill the data volume.
+
+ ``volatile`` puts it in the ring instead of the store — a frame a screen is
+ watching now, which the oldest of falls out of memory rather than being
+ kept. A worker on another host publishing a camera sends this.
"""
store = _store(request)
cap = settings.MAX_ARTIFACT_BYTES
@@ -112,7 +117,7 @@ async def put_artifact(
# as long as the upload lasts.
await run_in_threadpool(handle.write, chunk)
return await run_in_threadpool(
- store.put_file, Path(handle.name), media_type, name
+ store.put_file, Path(handle.name), media_type, name, volatile
)
finally:
Path(handle.name).unlink(missing_ok=True)
diff --git a/backend/fluksio/api/routes/flows.py b/backend/fluksio/api/routes/flows.py
index 02d85aa..7c6992f 100644
--- a/backend/fluksio/api/routes/flows.py
+++ b/backend/fluksio/api/routes/flows.py
@@ -28,6 +28,7 @@ from fluksio.api.deps import (
user_from_token,
)
from fluksio.core.db import engine
+from fluksio.flow.artifacts import is_reference
from fluksio.flow.controller import FlowController
from fluksio.flow.dashboards import DashboardStore
from fluksio.flow.events import event_bus
@@ -931,6 +932,88 @@ def event_for_panel(event: dict[str, Any], only: set[str]) -> bool:
# should not be handed the whole queue in one message.
MAX_FRAME_EVENTS = 64
+#: How many message names one socket may ask for bytes on. A screen draws a
+#: handful of tiles; this is only here so a client cannot ask for the whole
+#: namespace and be served every frame of it.
+MAX_MEDIA_NAMES = 32
+
+
+def wanted_names(
+ frame: str, only: set[str] | None, previous: set[str]
+) -> set[str] | None:
+ """What this client is asking to be sent bytes for, or None if it said
+ something else.
+
+ A client asks by name — the tiles it is drawing — and is answered only for
+ the names a panel credential would have been allowed anyway. Bytes are the
+ expensive thing on this socket, so nothing is pushed until something says
+ it is looking at it.
+ """
+ try:
+ payload = orjson.loads(frame)
+ except orjson.JSONDecodeError:
+ return None
+ if not isinstance(payload, dict) or payload.get("type") != "media":
+ return None
+ names = payload.get("names")
+ if not isinstance(names, list):
+ return set()
+ asked = {str(name) for name in names[:MAX_MEDIA_NAMES]}
+ return asked if only is None else asked & only
+
+
+def media_frames(
+ events: list[dict[str, Any]], wanted: set[str], store: Any
+) -> list[bytes]:
+ """The bytes behind the media values in this batch, one frame each.
+
+ Referenced-then-fetched costs a round trip per frame, which is what keeps
+ the message plane at a glance rather than a view. Pushing the bytes down
+ the socket that already carries the event closes that, and only for the
+ frames a client said it was drawing.
+
+ Only what the ring holds: the durable store is what a fetch is for, and a
+ checkpoint has no business being pushed at anyone. The newest value per
+ name wins, so a client that fell behind is not handed a backlog of frames
+ it would only draw over.
+ """
+ if not wanted or store is None or getattr(store, "volatile", None) is None:
+ return []
+
+ newest: dict[str, dict[str, Any]] = {}
+ for event in events:
+ if event.get("type") != "message_value":
+ continue
+ name = str(event.get("name") or "")
+ if name not in wanted or not is_reference(event.get("value")):
+ continue
+ newest[name] = event
+
+ frames: list[bytes] = []
+ for name, event in newest.items():
+ value = event["value"]
+ digest = str(value.get("digest") or "")
+ path = store.volatile.path(digest)
+ if path is None:
+ continue
+ try:
+ payload = path.read_bytes()
+ except OSError:
+ continue
+ header = orjson.dumps(
+ {
+ "type": "media",
+ "name": name,
+ "digest": digest,
+ "media_type": str(value.get("media_type") or ""),
+ "ts": event.get("ts"),
+ }
+ )
+ # Length-prefixed, so one frame carries both halves and the reader
+ # never has to guess where the JSON stops.
+ frames.append(len(header).to_bytes(4, "big") + header + payload)
+ return frames
+
async def _send(websocket: WebSocket, events: list[dict[str, Any]]) -> None:
"""One event, or a batch of them under `events`.
@@ -962,12 +1045,16 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
await websocket.close(code=1008)
return
only = panel_scope(token, websocket.app)
+ # Nothing until a client says it is drawing something: bytes are what this
+ # socket cannot afford to send speculatively.
+ wanted: set[str] = set()
await websocket.accept()
controller: FlowController | None = getattr(
websocket.app.state, "flow_controller", None
)
+ store = getattr(websocket.app.state, "artifact_store", None)
async def send_snapshot() -> None:
if controller is not None:
@@ -989,7 +1076,10 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
# stream — a keepalive, or anything else it decides to
# say, used to be read as the client going away and cost
# it every live update from then on.
- receiver.exception()
+ if receiver.exception() is None:
+ asked = wanted_names(receiver.result(), only, wanted)
+ if asked is not None:
+ wanted = asked
receiver = asyncio.create_task(websocket.receive_text())
if sender not in done:
continue
@@ -1017,6 +1107,7 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
# token — and that would widen this socket to
# everything on the bus.
only = panel_scope(token, websocket.app) or set()
+ wanted &= only
if out:
await _send(websocket, out)
out = []
@@ -1025,6 +1116,10 @@ async def flow_events(websocket: WebSocket, token: str = "") -> None:
continue
out.append(event)
if out:
+ # The bytes first: a tile that has the frame when the
+ # value lands draws it in one pass rather than two.
+ for frame in media_frames(out, wanted, store):
+ await websocket.send_bytes(frame)
await _send(websocket, out)
except (WebSocketDisconnect, RuntimeError):
# A peer that goes away mid-send takes the RuntimeError route
diff --git a/backend/fluksio/core/config.py b/backend/fluksio/core/config.py
index 1a2710b..c1f344e 100644
--- a/backend/fluksio/core/config.py
+++ b/backend/fluksio/core/config.py
@@ -150,6 +150,17 @@ class Settings(BaseSettings):
# Storing bytes and recording the reference are two steps; this is the
# window between them.
ARTIFACT_GC_GRACE_S: int = 3600
+ #: Where frames a flow only shows live are held: memory rather than the
+ #: data volume, so a camera at ten frames a second is not writing to an SD
+ #: card. Empty works it out — a directory under `/dev/shm` named for this
+ #: data directory, so two instances on one host do not trim each other —
+ #: and falls back to the temporary directory where there is no `/dev/shm`.
+ ARTIFACT_VOLATILE_DIR: Path | None = None
+ #: How much the volatile ring holds before the oldest frames fall out of
+ #: it; 0 turns it off, and a volatile save then lands in the durable store
+ #: like any other. Under Docker's default 64 MB `/dev/shm`: raise
+ #: `shm_size` with it.
+ ARTIFACT_VOLATILE_BYTES: int = 48 * 1024 * 1024
# Without a Redis host the engine keeps its state in memory.
REDIS_HOST: str | None = None
REDIS_PORT: int = 6379
diff --git a/backend/fluksio/flow/artifacts.py b/backend/fluksio/flow/artifacts.py
index 616e1ec..a1d5ad5 100644
--- a/backend/fluksio/flow/artifacts.py
+++ b/backend/fluksio/flow/artifacts.py
@@ -52,6 +52,10 @@ class ArtifactStore:
def __init__(self, root: Path) -> None:
self.root = root
self.root.mkdir(parents=True, exist_ok=True)
+ #: A ring of frames held in memory, for media a flow only shows live.
+ #: Set by the engine at startup; every lookup below falls through to
+ #: it, which is what lets a volatile reference be an ordinary one.
+ self.volatile: VolatileStore | None = None
def _path(self, digest: str) -> Path:
body = digest[len(DIGEST_PREFIX) :]
@@ -60,14 +64,25 @@ class ArtifactStore:
return self.root / body[:2] / body
def put(
- self, chunks: Iterable[bytes], name: str = "", media_type: str = ""
+ self,
+ chunks: Iterable[bytes],
+ name: str = "",
+ media_type: str = "",
+ volatile: bool = False,
) -> dict[str, Any]:
"""Store a stream and return the reference to it.
Written to a temporary file first and moved into place once the digest
is known, so a half-written artifact never has a name anyone can find.
A file already there is left alone: identical content is identical.
+
+ ``volatile`` puts it in the ring instead, where it is held in memory
+ and falls out once newer frames need the room. The reference is the
+ same shape either way — what differs is how long the bytes last, and a
+ caller that wants one kept returns it from a run.
"""
+ if volatile and self.volatile is not None:
+ return self.volatile.put(chunks, name=name, media_type=media_type)
digester = hashlib.sha256()
size = 0
handle = tempfile.NamedTemporaryFile(dir=self.root, delete=False)
@@ -98,21 +113,52 @@ class ArtifactStore:
}
def put_file(
- self, path: Path, media_type: str = "", name: str = ""
+ self,
+ path: Path,
+ media_type: str = "",
+ name: str = "",
+ volatile: bool = False,
) -> dict[str, Any]:
with path.open("rb") as handle:
return self.put(
iter(lambda: handle.read(CHUNK), b""),
name=name or path.name,
media_type=media_type,
+ volatile=volatile,
)
def path(self, digest: str) -> Path | None:
- """Where the bytes are, or None if this store does not have them."""
+ """Where the bytes are, or None if this store does not have them.
+
+ The ring is looked in second, so everything that resolves a digest —
+ serving one over HTTP, checking a run input still exists, a panel's
+ scope — reaches a live frame without knowing there are two stores.
+ """
if not valid_digest(digest):
return None
target = self._path(digest)
- return target if target.exists() else None
+ if target.exists():
+ return target
+ if self.volatile is not None:
+ return self.volatile.path(digest)
+ return None
+
+ def adopt(self, digest: str) -> bool:
+ """Copy a volatile artifact into this store, so it outlives the ring.
+
+ What makes "emitted media is not kept, returned media is" true: a run
+ recording a reference calls this, and the frame stops being one the
+ next few seconds can evict.
+ """
+ if self.volatile is None or not valid_digest(digest):
+ return False
+ if self._path(digest).exists():
+ return True
+ source = self.volatile.path(digest)
+ if source is None:
+ return False
+ self.put_file(source)
+ return True
def read(self, digest: str) -> Iterator[bytes]:
target = self.path(digest)
@@ -150,3 +196,71 @@ class ArtifactStore:
except OSError:
logger.warning("Could not remove artifact %s", entry.name)
return removed
+
+
+class VolatileStore(ArtifactStore):
+ """A bounded ring of artifacts, held wherever memory is cheaper than disk.
+
+ A camera publishing ten frames a second is ten files a second, and on the
+ wall panel this is built for that disk is an SD card. So the frames go to a
+ memory-backed directory instead and the oldest fall out once the ring is
+ full: nothing sweeps it, because a frame nobody kept is not worth a pass
+ over the store to find.
+
+ It is an ``ArtifactStore``, digest layout and all, which is what lets a
+ frame be an ordinary reference — the dtype check, the panel's scope, a
+ node opening one with ``load_artifact`` and the widget fetching one all
+ work on it unchanged.
+ """
+
+ def __init__(self, root: Path, limit_bytes: int) -> None:
+ super().__init__(root)
+ self.limit_bytes = limit_bytes
+
+ def put(
+ self,
+ chunks: Iterable[bytes],
+ name: str = "",
+ media_type: str = "",
+ volatile: bool = False,
+ ) -> dict[str, Any]:
+ reference = super().put(chunks, name=name, media_type=media_type)
+ self.trim()
+ return reference
+
+ def trim(self) -> int:
+ """Drop the oldest until the ring is inside its bound.
+
+ Also called on a timer, because a worker in this container writes here
+ itself and the engine never sees that ``put``.
+
+ # ponytail: a scandir per trim. An in-memory index if a ring of
+ # thousands of frames ever shows up in a profile.
+ """
+ if self.limit_bytes <= 0:
+ return 0
+ entries: list[tuple[float, int, Path]] = []
+ total = 0
+ for entry in self.root.glob("*/*"):
+ try:
+ stat = entry.stat()
+ except OSError:
+ continue
+ if not entry.is_file():
+ continue
+ entries.append((stat.st_mtime, stat.st_size, entry))
+ total += stat.st_size
+ if total <= self.limit_bytes:
+ return 0
+
+ removed = 0
+ for _mtime, size, entry in sorted(entries):
+ if total <= self.limit_bytes:
+ break
+ try:
+ entry.unlink()
+ except OSError:
+ continue
+ total -= size
+ removed += 1
+ return removed
diff --git a/backend/fluksio/flow/connector.py b/backend/fluksio/flow/connector.py
index 1cfe897..de7a378 100644
--- a/backend/fluksio/flow/connector.py
+++ b/backend/fluksio/flow/connector.py
@@ -141,6 +141,7 @@ class ConnectorNode(Node):
data: bytes,
name: str = "",
media_type: str = "application/octet-stream",
+ volatile: bool = False,
) -> dict[str, Any]:
"""Store bytes and return the reference to publish on a media port.
@@ -149,6 +150,11 @@ class ConnectorNode(Node):
store and the reference names them, which is what an ``image``,
``audio`` or ``video`` port carries.
+ ``volatile`` is what a camera publishes with: the bytes go to a ring
+ held in memory rather than to the data volume, are pushed to whatever
+ screen is watching, and last seconds. Use it for a frame; leave it off
+ for a recording somebody asked to keep.
+
Only available once the node has started — the store belongs to the
engine, and is handed over then.
"""
@@ -156,7 +162,9 @@ class ConnectorNode(Node):
raise RuntimeError(
"no artifact store: a connector can only save bytes once it has started"
)
- return self._artifacts.put([data], name=name, media_type=media_type)
+ return self._artifacts.put(
+ [data], name=name, media_type=media_type, volatile=volatile
+ )
async def start(self, app: FastAPI | None = None) -> None:
self._artifacts = getattr(app.state, "artifact_store", None) if app else None
diff --git a/backend/fluksio/flow/runs.py b/backend/fluksio/flow/runs.py
index 9aa6e36..867bc83 100644
--- a/backend/fluksio/flow/runs.py
+++ b/backend/fluksio/flow/runs.py
@@ -1313,6 +1313,11 @@ class RunService:
with Session(db_engine) as session:
session.merge(row)
for message, ref in outcome.artifacts.items():
+ # A run recording a reference is what "returned media is
+ # kept" means: copied out of the volatile ring, or the row
+ # would outlive the bytes it names by a few seconds.
+ if self._artifacts is not None:
+ self._artifacts.adopt(str(ref.get("digest") or ""))
session.merge(
RunArtifact(
run_id=run_id,
diff --git a/backend/fluksio/main.py b/backend/fluksio/main.py
index 4d408a6..c6c2020 100644
--- a/backend/fluksio/main.py
+++ b/backend/fluksio/main.py
@@ -1,16 +1,19 @@
import asyncio
import contextlib
+import hashlib
import inspect
import logging
+import tempfile
from collections.abc import AsyncIterator, Callable, Coroutine
from contextlib import AbstractAsyncContextManager, asynccontextmanager
+from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
-from fluksio_worker.worker_main import ARTIFACT_DIR_ENV
+from fluksio_worker.worker_main import ARTIFACT_DIR_ENV, ARTIFACT_VOLATILE_DIR_ENV
from starlette.middleware.cors import CORSMiddleware
from fluksio import __version__
@@ -23,7 +26,7 @@ from fluksio.core.db import engine as db_engine
from fluksio.core.db import prepare
from fluksio.flow import logs, modules
from fluksio.flow.alerts import AlertManager
-from fluksio.flow.artifacts import ArtifactStore
+from fluksio.flow.artifacts import ArtifactStore, VolatileStore
from fluksio.flow.controller import FlowController, RebuildBusy
from fluksio.flow.dashboards import DashboardStore
from fluksio.flow.events import event_bus
@@ -113,6 +116,43 @@ async def _sweep_artifacts(store: ArtifactStore, controller: FlowController) ->
logger.exception("Artifact sweep failed")
+#: How often the ring is measured. Seconds rather than the sweep's hour: it is
+#: bounded by size and the frames arriving are what push the old ones out.
+VOLATILE_TRIM_S = 5.0
+
+
+async def _trim_volatile(ring: VolatileStore) -> None:
+ """Hold the volatile ring inside its bound.
+
+ ``put`` trims what it wrote, but a worker in this container writes to the
+ ring itself and the engine never sees that one — so the bound needs
+ something of its own watching it.
+ """
+ if ring.limit_bytes <= 0:
+ return
+ while True:
+ await asyncio.sleep(VOLATILE_TRIM_S)
+ try:
+ await run_in_threadpool(ring.trim)
+ except Exception:
+ logger.exception("Could not trim the volatile artifact ring")
+
+
+def _volatile_root() -> Path:
+ """Where the ring goes: memory if this machine has some to lend.
+
+ Named for the data directory rather than fixed, so two engines on one host
+ have a ring each instead of quietly evicting each other's frames.
+ """
+ configured = settings.ARTIFACT_VOLATILE_DIR
+ if configured is not None:
+ return configured
+ tag = hashlib.sha256(str(settings.DATA_DIR.resolve()).encode()).hexdigest()[:8]
+ shm = Path("/dev/shm")
+ parent = shm if shm.is_dir() else Path(tempfile.gettempdir())
+ return parent / f"fluksio-volatile-{tag}"
+
+
def _mcp_sessions() -> AbstractAsyncContextManager[None]:
"""The MCP session manager's run scope, or nothing when MCP is off."""
if not settings.MCP_ENABLED:
@@ -205,6 +245,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Beside the flows rather than in them: an artifact is what a run produced,
# not something anyone wrote, so it has no business in the git repository.
artifacts = ArtifactStore(settings.FLOWS_DIR.parent / "artifacts")
+ # Frames a flow only shows live, held in memory and bounded by size.
+ # The store falls through to it, so a volatile reference is an
+ # ordinary one everywhere but in how long its bytes last.
+ volatile = VolatileStore(_volatile_root(), settings.ARTIFACT_VOLATILE_BYTES)
+ artifacts.volatile = volatile
app.state.artifact_store = artifacts
accountant = ResourceAccountant(
cpus=settings.FLOW_CPUS, gpus=settings.FLOW_GPUS
@@ -223,6 +268,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# is given a URL instead. Node code calls the same two functions.
env={
ARTIFACT_DIR_ENV: str(artifacts.root),
+ ARTIFACT_VOLATILE_DIR_ENV: str(volatile.root),
# Every slot can be busy at once, so a worker left to size its own
# thread pool to the machine means as many processes as there are
# slots, each believing it has the whole of it. A node that says
@@ -292,6 +338,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
_background(alerts.run(), "alert-manager")
_background(MetricsCollector(event_bus).run(), "metrics-collector")
_background(_sweep_artifacts(artifacts, controller), "artifact-gc")
+ _background(_trim_volatile(volatile), "artifact-ring")
await controller.start()
started.append(controller.stop)
run_service.start()
diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py
index 331f534..0efdda0 100644
--- a/backend/tests/api/routes/test_panels.py
+++ b/backend/tests/api/routes/test_panels.py
@@ -838,3 +838,83 @@ def test_a_panel_fetches_only_the_media_its_tiles_are_showing(
).status_code
== 403
)
+
+
+def test_a_socket_pushes_only_the_frames_it_was_asked_for(tmp_path) -> None:
+ """Bytes are the one thing this socket cannot send speculatively.
+
+ A frame a second per subscriber is affordable; every frame to every open
+ editor tab is not. So nothing goes until a client names what it is drawing,
+ and a panel credential can only name what it was already allowed to see.
+ """
+ import orjson
+
+ from fluksio.api.routes.flows import media_frames, wanted_names
+ from fluksio.flow.artifacts import ArtifactStore, VolatileStore
+
+ store = ArtifactStore(tmp_path / "artifacts")
+ store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
+ frame = store.put([b"\x89PNG..."], name="f.png", media_type="image/png",
+ volatile=True)
+ kept = store.put([b"checkpoint"], name="w.pt")
+
+ asking = orjson.dumps({"type": "media", "names": ["cam.frame", "other.frame"]})
+ # A person's socket gets what it asked for; a panel's is intersected with
+ # what it draws, so naming a message is not a way around the scope.
+ assert wanted_names(asking.decode(), None, set()) == {"cam.frame", "other.frame"}
+ assert wanted_names(asking.decode(), {"cam.frame"}, set()) == {"cam.frame"}
+ # Anything else on this socket leaves the set alone.
+ assert wanted_names('{"type":"ping"}', None, set()) is None
+ assert wanted_names("not json", None, set()) is None
+
+ events = [
+ {"type": "message_value", "name": "cam.frame", "value": frame, "ts": 1.0},
+ {"type": "message_value", "name": "other.frame", "value": frame, "ts": 1.0},
+ {"type": "message_value", "name": "cam.model", "value": kept, "ts": 1.0},
+ {"type": "node_log", "name": "cam.frame", "value": frame},
+ ]
+ frames = media_frames(events, {"cam.frame", "cam.model"}, store)
+
+ # One frame: the ring's, for the name that asked. The durable checkpoint is
+ # what a fetch is for, and a log is not a value.
+ assert len(frames) == 1
+ length = int.from_bytes(frames[0][:4], "big")
+ header = orjson.loads(frames[0][4 : 4 + length])
+ assert header == {
+ "type": "media",
+ "name": "cam.frame",
+ "digest": frame["digest"],
+ "media_type": "image/png",
+ "ts": 1.0,
+ }
+ assert frames[0][4 + length :] == b"\x89PNG..."
+
+ # Nothing asked for, nothing sent.
+ assert media_frames(events, set(), store) == []
+
+
+def test_only_the_newest_frame_of_a_batch_is_pushed(tmp_path) -> None:
+ """A client that fell behind is not handed frames it would only draw over."""
+ import orjson
+
+ from fluksio.api.routes.flows import media_frames
+ from fluksio.flow.artifacts import ArtifactStore, VolatileStore
+
+ store = ArtifactStore(tmp_path / "artifacts")
+ store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
+ first = store.put([b"one"], media_type="image/png", volatile=True)
+ second = store.put([b"two"], media_type="image/png", volatile=True)
+
+ frames = media_frames(
+ [
+ {"type": "message_value", "name": "cam.frame", "value": first, "ts": 1.0},
+ {"type": "message_value", "name": "cam.frame", "value": second, "ts": 2.0},
+ ],
+ {"cam.frame"},
+ store,
+ )
+
+ assert len(frames) == 1
+ length = int.from_bytes(frames[0][:4], "big")
+ assert orjson.loads(frames[0][4 : 4 + length])["digest"] == second["digest"]
+ assert frames[0][4 + length :] == b"two"
diff --git a/backend/tests/flow/test_workers.py b/backend/tests/flow/test_workers.py
index 335c708..1e9798b 100644
--- a/backend/tests/flow/test_workers.py
+++ b/backend/tests/flow/test_workers.py
@@ -730,3 +730,84 @@ def test_cancelling_reaches_a_node_running_in_a_child(pool):
assert pool.cancel("demo.slow"), "the parent must find a child's node"
thread.join(timeout=10)
assert failed
+
+
+def test_a_node_saves_a_frame_to_the_ring(tmp_path):
+ """A camera writes to memory, and the next node reads it from there.
+
+ The point of the ring being an ordinary store: node code says one word
+ more, and everything downstream — the dtype, ``load_artifact``, the widget
+ — carries on not knowing there are two of them.
+ """
+ from fluksio_worker.worker_main import ARTIFACT_VOLATILE_DIR_ENV
+
+ from fluksio.flow.artifacts import VolatileStore
+
+ store = ArtifactStore(tmp_path / "artifacts")
+ store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
+ pool = PythonWorkerPool(
+ python=sys.executable,
+ size=1,
+ env={
+ ARTIFACT_DIR_ENV: str(store.root),
+ ARTIFACT_VOLATILE_DIR_ENV: str(store.volatile.root),
+ },
+ )
+ pool.start()
+ try:
+ ref = pool.run(
+ "demo",
+ "frame",
+ "import fluksio\n"
+ "def process():\n"
+ " return {'frame': fluksio.save_artifact(\n"
+ " b'\\x89PNG' + b'p' * 64, 'f.png',\n"
+ " media_type='image/png', volatile=True)}\n",
+ {},
+ "demo.frame",
+ timeout=10,
+ )["frame"]
+
+ assert MessageSpec(name="frame", dtype=DType.IMAGE).check(ref) is None
+ # In the ring, and nowhere near the data volume.
+ assert store.volatile.path(ref["digest"]) is not None
+ assert not (store.root / ref["digest"][7:9]).exists()
+
+ read = pool.run(
+ "demo",
+ "read",
+ "import fluksio\n"
+ "def process(frame):\n"
+ " with open(fluksio.load_artifact(frame), 'rb') as f:\n"
+ " return {'size': len(f.read())}\n",
+ {"frame": ref},
+ "demo.read",
+ timeout=10,
+ )
+ assert read == {"size": 68}
+ finally:
+ pool.stop()
+
+
+def test_the_fetch_cache_is_bounded(tmp_path):
+ """A worker downloading a media stream fills its cache with chunks nothing
+ will ask for twice, and content addressing means nothing ever expires."""
+ import os
+
+ from fluksio_worker.worker_main import ARTIFACT_CACHE_BYTES_ENV, _trim_cache
+
+ cache = tmp_path / "cache"
+ cache.mkdir()
+ for index in range(5):
+ entry = cache / f"chunk{index}"
+ entry.write_bytes(b"c" * 1000)
+ os.utime(entry, (index, index))
+
+ os.environ[ARTIFACT_CACHE_BYTES_ENV] = "3000"
+ try:
+ _trim_cache(str(cache))
+ finally:
+ del os.environ[ARTIFACT_CACHE_BYTES_ENV]
+
+ left = sorted(entry.name for entry in cache.iterdir())
+ assert left == ["chunk2", "chunk3", "chunk4"]
diff --git a/backend/tests/test_artifacts.py b/backend/tests/test_artifacts.py
index 2b8a3e4..3364899 100644
--- a/backend/tests/test_artifacts.py
+++ b/backend/tests/test_artifacts.py
@@ -3,10 +3,13 @@ import time
from datetime import UTC, datetime
import pytest
+from fastapi.testclient import TestClient
from sqlmodel import Session, col, select
+from fluksio.core.config import settings
+
from fluksio.core.db import engine as db_engine
-from fluksio.flow.artifacts import ArtifactStore
+from fluksio.flow.artifacts import ArtifactStore, VolatileStore
from fluksio.flow.runs import new_run_id, sweep_artifacts
from fluksio.flow.state import MemoryState
from fluksio.models import Run, RunArtifact
@@ -108,3 +111,73 @@ def test_a_streamed_chunk_falls_out_once_the_message_moves_on(store):
assert sweep_artifacts(store, state, grace_s=5) == 1
assert store.path(first["digest"]) is None
assert store.path(second["digest"]) is not None
+
+
+def test_a_ring_drops_the_oldest_once_it_is_full(tmp_path):
+ """What makes a camera affordable: the room is fixed, not the history."""
+ ring = VolatileStore(tmp_path / "ring", limit_bytes=3000)
+ refs = [ring.put([bytes([i]) * 1000], name=f"{i}.bin") for i in range(5)]
+
+ held = [ref for ref in refs if ring.path(ref["digest"]) is not None]
+ assert len(held) == 3
+ # The newest three, in order: eviction is by age, not by chance.
+ assert held == refs[2:]
+
+
+def test_a_volatile_reference_resolves_like_any_other(tmp_path):
+ """A frame is an ordinary reference, which is what keeps the rest honest.
+
+ Everything that resolves a digest — serving one, checking a run's input,
+ a panel's scope — goes through ``path``, so the ring has to answer there.
+ """
+ store = ArtifactStore(tmp_path / "artifacts")
+ store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
+
+ ref = store.put([b"frame"], name="f.png", media_type="image/png", volatile=True)
+
+ assert store.path(ref["digest"]) is not None
+ # In the ring, and not on the volume the sweep is about.
+ assert not (store.root / ref["digest"][7:9]).exists()
+
+
+def test_a_recorded_frame_is_copied_out_of_the_ring(tmp_path):
+ """Emitted media is not kept; returned media is."""
+ store = ArtifactStore(tmp_path / "artifacts")
+ store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
+ ref = store.put([b"kept"], name="f.png", volatile=True)
+
+ assert store.adopt(ref["digest"]) is True
+
+ store.volatile.collect(set())
+ assert store.path(ref["digest"]) is not None
+ assert store.adopt("sha256:" + "0" * 64) is False
+
+
+def test_an_upload_may_ask_for_the_ring(
+ client: TestClient, superuser_token_headers: dict[str, str]
+) -> None:
+ """A camera on another host publishes over HTTP and wants the ring too."""
+ store = client.app.state.artifact_store
+ previous = store.volatile
+ store.volatile = VolatileStore(
+ store.root.parent / "test-ring", limit_bytes=1_000_000
+ )
+ try:
+ url = f"{settings.API_V1_STR}/artifacts"
+ answer = client.put(
+ f"{url}?name=f.png&media_type=image%2Fpng&volatile=1",
+ headers=superuser_token_headers,
+ content=b"\x89PNG-frame",
+ )
+ assert answer.status_code == 200, answer.text
+ digest = answer.json()["digest"]
+
+ assert store.volatile.path(digest) is not None
+ assert not (store.root / digest[7:9]).exists()
+ # And it is served back like anything else, which is what the widget
+ # falls back on when the socket did not push it.
+ served = client.get(f"{url}/{digest}", headers=superuser_token_headers)
+ assert served.status_code == 200
+ assert served.content == b"\x89PNG-frame"
+ finally:
+ store.volatile = previous
diff --git a/docker/compose.yml b/docker/compose.yml
index d8d359a..160e594 100644
--- a/docker/compose.yml
+++ b/docker/compose.yml
@@ -141,6 +141,11 @@ services:
volumes:
- app-flow-data:/data
+ # `/dev/shm`, where frames a flow only shows live are held. Docker's
+ # default is 64 MB, which the ring's own bound sits just under; raise both
+ # together (ARTIFACT_VOLATILE_BYTES) for more cameras or bigger frames.
+ shm_size: 128mb
+
# Deep health: fails when the event loop is wedged or Redis is gone, not
# just when the process is dead. Autoheal restarts on unhealthy.
healthcheck:
diff --git a/docs/code/nodes.md b/docs/code/nodes.md
index f399297..3ef2a18 100644
--- a/docs/code/nodes.md
+++ b/docs/code/nodes.md
@@ -137,7 +137,8 @@ def process(speech): # an `audio` port
def process(camera_url):
for index, jpeg in enumerate(grab(camera_url)): # a generator
frame = fluksio.save_artifact(
- jpeg, f"frame-{index:05d}.jpg", media_type="image/jpeg"
+ jpeg, f"frame-{index:05d}.jpg",
+ media_type="image/jpeg", volatile=True,
)
frame["meta"] = {"seq": index}
yield {"frame": frame} # an `image` stream port
@@ -148,12 +149,21 @@ has to match, so a node declaring `audio` never receives a video by accident.
See [Payload types](../reference/payload-types.md#image-audio-video) for what
each carries and what rates are realistic.
+`volatile=True` is for a frame rather than a result. The bytes go to a ring in
+memory instead of the data volume, and the engine pushes them down the
+websocket to whichever screens are drawing that message — so a camera runs at
+ten frames a second without writing anything to disk. They last as long as it
+takes newer frames to need the room. Leave it off for a clip somebody asked to
+keep.
+
!!! warning "Emitted media is not kept; returned media is"
Only what a node *returns* is recorded against its run. Frames yielded
along the way are replaced in state by the next one, and the artifact sweep
removes bytes nothing refers to any more, which is what stops a camera
- filling the disk. If a particular frame matters, return it.
+ filling the disk. If a particular frame matters, return it: a volatile one
+ is copied out of the ring when the run records it, so returning it is also
+ what makes it outlive the next few seconds.
## Printing
diff --git a/docs/code/workers.md b/docs/code/workers.md
index 24b6bae..2655f37 100644
--- a/docs/code/workers.md
+++ b/docs/code/workers.md
@@ -141,6 +141,14 @@ filesystem writes to it directly; one that does not fetches and uploads over
HTTP, using the artifact endpoint beside the socket it already has. Either way
your node code is the same two calls.
+A fetch is cached on the worker by digest, since content addressing means an
+entry is never stale. Nothing expires on its own, so the cache is bounded by
+size and the oldest fall out: `FLUKSIO_ARTIFACT_CACHE` says where it lives
+(default a directory in the temporary directory) and
+`FLUKSIO_ARTIFACT_CACHE_BYTES` how much it holds (default 1 GiB). Worth raising
+where a worker reads the same large inputs repeatedly, and worth leaving alone
+where it reads a media stream — those are chunks nothing asks for twice.
+
## Seeing what is attached
```sh
diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md
index daaab5f..3c3a2fd 100644
--- a/docs/interface/dashboards.md
+++ b/docs/interface/dashboards.md
@@ -105,21 +105,24 @@ so an answer to a different question is ignored.
A media widget draws what its message points at: a picture, a clip with
controls, a video. Media does not travel as a message; a reference to it does.
-The tile fetches the bytes behind whichever reference the message holds,
-and redraws when a new one arrives.
+The tile takes the bytes behind whichever reference the message holds, and
+redraws when a new one arrives.
**Crop or fit** decides how a picture fills the tile. **Play as it arrives**
starts a clip by itself, though a browser only plays sound once somebody has
touched the page, so a screen nobody has tapped stays silent.
-Rate is the thing to get right. A frame every second or two is a glance at a
-door, and works; through the portal, make that every few seconds. Live video is
-not something to push through the message plane at all. Put the camera's own
-address in **Live stream** and the browser plays it from source, leaving the
-messages to carry the occasional still that a flow can actually react to.
+Rate is the thing to get right, and it is decided by the node publishing rather
+than by the tile. A frame the engine is holding in memory is pushed down the
+same websocket that carries the value, so ten a second is a real view on the
+local network; a stored one is fetched, a round trip each, which suits a glance
+at a door every second or two. Through the portal everything is fetched, so
+make it every few seconds there. Above that, put the camera's own address in
+**Live stream** and the browser plays it from source, leaving the messages to
+carry the occasional still that a flow can actually react to.
-Panels see media the same way, and only their own: a screen may fetch the bytes
-its own tiles are showing and nothing else.
+Panels see media the same way, and only their own: a screen is sent, and may
+fetch, the bytes its own tiles are showing and nothing else.
## Player tiles
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 50f41d8..b40d595 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -131,6 +131,9 @@ warning into a refusal to start.
| `OBS_RETENTION_DAYS` | `30` | how long metrics, events and run records are kept |
| `ARTIFACT_GC_INTERVAL_S` | `3600` | how often artifact bytes nothing refers to are swept away; 0 never sweeps |
| `ARTIFACT_GC_GRACE_S` | `3600` | how long a freshly written artifact is spared, whatever refers to it |
+| `MAX_ARTIFACT_BYTES` | `2147483648` | the largest body `PUT /artifacts` will take; 0 removes the limit |
+| `ARTIFACT_VOLATILE_DIR` | worked out | where frames a flow only shows live are held; empty picks a directory under `/dev/shm` named for the data directory, and falls back to the temporary directory |
+| `ARTIFACT_VOLATILE_BYTES` | `50331648` | how much that ring holds before the oldest frames fall out; 0 turns it off and volatile saves land in the store |
The three concurrency limits are also flags on `fluksio serve`
(`--max-workers`, `--max-cascades`, `--max-runs`), as is the card count,
@@ -146,6 +149,15 @@ sweep is what keeps a flow streaming media from filling the disk. It stands
aside entirely while a run is in flight, since a node may store a checkpoint
long before it returns the reference to it.
+Frames saved with `volatile=True` skip all of that. They go to a ring in memory
+instead of the volume, the oldest falling out once the newest need the room,
+and the engine pushes them down the websocket to whichever screens are drawing
+them — which is what a camera at ten frames a second needs and the store cannot
+give it. A frame a run *records* is copied into the store on the way, so
+returned media is kept and emitted media is not. Under Docker the ring lives in
+the container's `/dev/shm`, whose default is 64 MB: raise `shm_size` alongside
+`ARTIFACT_VOLATILE_BYTES`.
+
A node that declares nothing is not accounted against `FLOW_CPUS`; it runs on
the shared pool and is given `FLOW_CPUS / FLOW_MAX_WORKERS` as a thread cap, so
several at once cannot each size themselves to the whole machine. Setting
diff --git a/docs/reference/connector-contract.md b/docs/reference/connector-contract.md
index 0033505..9f897b8 100644
--- a/docs/reference/connector-contract.md
+++ b/docs/reference/connector-contract.md
@@ -177,7 +177,9 @@ connector publishes a reference to it instead:
async def poll(self) -> dict[str, Any] | None:
jpeg = await asyncio.to_thread(self._grab)
return {
- "frame": self.save_artifact(jpeg, "frame.jpg", media_type="image/jpeg")
+ "frame": self.save_artifact(
+ jpeg, "frame.jpg", media_type="image/jpeg", volatile=True
+ )
}
```
@@ -186,10 +188,18 @@ async def poll(self) -> dict[str, Any] | None:
works once the node has started, since the store is the engine's and is handed
over then.
+`volatile=True` is what a camera publishes with. The frame goes to a ring in
+memory rather than the data volume and is pushed down the websocket to
+whichever screens are drawing it, so a wall panel sees ten frames a second and
+the SD card under it is never written to. Frames last until newer ones need the
+room; leave the flag off for a reading somebody asked to keep.
+
Each reading is a new artifact, which the poll loop publishes because its
digest differs from the last. Set `poll_interval` to what somebody actually
-wants to look at: a frame every second or two is a glance, and live video
-belongs on the camera's own stream rather than in the graph.
+wants to look at. Through a portal the bytes are fetched rather than pushed, so
+a remote panel wants a frame every second or two; higher rates than that are
+for the local network, and full-rate video still belongs on the camera's own
+stream.
## Lifecycle
diff --git a/docs/reference/payload-types.md b/docs/reference/payload-types.md
index 82f9a4d..12feca7 100644
--- a/docs/reference/payload-types.md
+++ b/docs/reference/payload-types.md
@@ -117,19 +117,27 @@ sequence numbers are for whoever consumes the media.
Bytes still never travel as a message. A camera publishes one reference per
frame and a microphone one per chunk, which makes a media stream an ordinary
-[streaming port](../concepts/flows.md#streaming-ports), and each frame an
-artifact. What that costs is worth knowing before pointing a camera at it:
+[streaming port](../concepts/flows.md#streaming-ports). What that costs
+depends on how the bytes reach the screen:
| Rate | Where it works |
|---|---|
| A clip a second (speech) | anywhere, including through the portal |
-| A frame every second or two (a glance at a door) | locally; through the portal, every few seconds |
-| Live video, 15–30 fps | not here — see below |
+| Ten frames a second (a camera worth watching) | on the local network, with `volatile=True` |
+| A frame every second or two (a glance at a door) | anywhere, including through the portal |
+| Full-rate video, 30 fps and up | not here — see below |
-Real-time video is not a message-plane problem: every frame would be an
-artifact, an event and a fetch. Point a media widget's **stream URL** at
-whatever the camera already serves and the browser plays it from source; the
-messages then carry the occasional still, and the flow reacts to those.
+The difference is one flag. A frame saved with
+[`volatile=True`](../code/nodes.md#media) is held in memory and pushed down the
+websocket in front of the value naming it, so a screen draws it without asking
+for anything. A stored one is fetched instead: a round trip per frame, which is
+a glance rather than a view. Through a portal every frame is fetched, so a
+remote panel is in the second row whatever the flag says.
+
+Above that, video is not a message-plane problem — every frame would still be
+an event. Point a media widget's **stream URL** at whatever the camera already
+serves and the browser plays it from source; the messages then carry the
+occasional still, and the flow reacts to those.
### `json`
diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs
index d8d55de..84735a4 100644
--- a/frontend/scripts/capture-screenshots.mjs
+++ b/frontend/scripts/capture-screenshots.mjs
@@ -199,13 +199,18 @@ async function captureDashboards(page, dir) {
*
* Skipped unless the media example is seeded (root `make seed-example-media`),
* since it is the one shot that needs a source of frames. The bytes arrive as
- * a blob — the tile fetches them with the session's credential, which no `img`
- * could carry on its own — so a `blob:` source is the proof the whole path ran
- * rather than that a picture is merely present.
+ * a blob — pushed down the socket, or fetched with the session's credential,
+ * which no `img` could carry on its own — so a `blob:` source is the proof the
+ * whole path ran rather than that a picture is merely present.
+ *
+ * The one page that cannot wait for `networkidle`: a camera publishing several
+ * frames a second is a socket that never goes quiet, which is the point of it.
+ * The blob source below is a stronger wait anyway — it says a frame arrived,
+ * where idleness only ever said the page stopped asking.
*/
async function captureMedia(page, dir) {
const answer = await page.goto(`${APP_URL}/view/camera`, {
- waitUntil: "networkidle",
+ waitUntil: "domcontentloaded",
})
if (!answer?.ok()) return
diff --git a/frontend/src/components/Dashboard/MediaWidget.tsx b/frontend/src/components/Dashboard/MediaWidget.tsx
index 97976c6..7fd34c3 100644
--- a/frontend/src/components/Dashboard/MediaWidget.tsx
+++ b/frontend/src/components/Dashboard/MediaWidget.tsx
@@ -1,82 +1,17 @@
-import { useEffect, useState } from "react"
-
-import { OpenAPI } from "@/client"
-import { apiToken } from "@/lib/portal"
+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"
-/** An artifact reference, as a message carries one. */
-type MediaRef = {
- digest?: string
- media_type?: string
- name?: string
- size?: number
-}
-
-const isRef = (value: unknown): value is MediaRef =>
- typeof value === "object" &&
- value !== null &&
- typeof (value as MediaRef).digest === "string" &&
- (value as MediaRef).digest!.startsWith("sha256:")
-
-/**
- * A local URL for an artifact's bytes, refreshed whenever the digest changes.
- *
- * Not the endpoint itself: `/artifacts/{digest}` takes a bearer token, and no
- * `` or `
) : scalar === null ? (
-
+
) : null}
diff --git a/frontend/src/components/Flow/LiveEdge.tsx b/frontend/src/components/Flow/LiveEdge.tsx
index c694ba4..2ebd5b7 100644
--- a/frontend/src/components/Flow/LiveEdge.tsx
+++ b/frontend/src/components/Flow/LiveEdge.tsx
@@ -8,6 +8,7 @@ import {
} from "@xyflow/react"
import { memo, useEffect, useRef, useState } from "react"
+import { describeArtifact, isRef } from "@/lib/media"
import { duration } from "@/lib/motion"
import { cn } from "@/lib/utils"
import type { FlowEdgeData } from "./deriveEdges"
@@ -66,6 +67,9 @@ function formatValue(value: unknown): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2)
}
if (typeof value === "string") return value
+ // A media reference serialised whole is a line of hash: what belongs in a
+ // chip this size is what kind of bytes crossed the edge.
+ if (isRef(value)) return describeArtifact(value as Record)
return JSON.stringify(value) ?? ""
}
diff --git a/frontend/src/components/Flow/MessageSparkline.tsx b/frontend/src/components/Flow/MessageSparkline.tsx
index f2e370b..dd110ee 100644
--- a/frontend/src/components/Flow/MessageSparkline.tsx
+++ b/frontend/src/components/Flow/MessageSparkline.tsx
@@ -119,7 +119,13 @@ export function MessageSparkline({
)
}
- return
+ return (
+
+ )
}
// The value is live here, so the dot on the newest reading is earned. The
diff --git a/frontend/src/components/Flow/ValuePreview.tsx b/frontend/src/components/Flow/ValuePreview.tsx
index 6d36803..6ddc74e 100644
--- a/frontend/src/components/Flow/ValuePreview.tsx
+++ b/frontend/src/components/Flow/ValuePreview.tsx
@@ -3,7 +3,8 @@ import { useState } from "react"
import type { DType } from "@/client"
import { Marquee } from "@/components/Common/Marquee"
-import { cn, si } from "@/lib/utils"
+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.
@@ -19,11 +20,6 @@ function count(n: number, one: string, many = `${one}s`): string {
return `${n} ${n === 1 ? one : many}`
}
-/** Whether this is an artifact reference rather than data of its own. */
-function isArtifact(value: Record): boolean {
- return typeof value.digest === "string" && value.digest.startsWith("sha256:")
-}
-
/**
* What a structured value *is*, in the space a value would have taken.
*
@@ -38,21 +34,7 @@ function summarize(value: object, dtype?: DType): string {
}
const record = value as Record
- if (isArtifact(record)) {
- const name =
- typeof record.name === "string" && record.name ? record.name : ""
- const size = typeof record.size === "number" ? `${si(record.size)}B` : ""
- // The media type when there is one: what kind of bytes these are is the
- // first thing worth knowing about a frame or a clip, and it is what the
- // port's own type was declared against.
- const media =
- typeof record.media_type === "string" &&
- record.media_type &&
- record.media_type !== "application/octet-stream"
- ? record.media_type
- : "artifact"
- return [media, name, size].filter(Boolean).join(" · ")
- }
+ if (isRef(record)) return describeArtifact(record)
if (Array.isArray(record.lines)) {
const points = record.lines.reduce(
@@ -77,13 +59,39 @@ function summarize(value: object, dtype?: DType): string {
* 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 (
+
+ )
+}
+
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
@@ -91,6 +99,8 @@ export function ValuePreview({
}) {
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 (
@@ -118,6 +128,7 @@ export function ValuePreview({
/>
+ {image ? : null}
{open ? (
>()
let connected = false
const connectionListeners = new Set()
+/**
+ * Object URLs for frames the engine pushed, keyed by digest.
+ *
+ * Small on purpose: a camera's frames are worth exactly as long as the next
+ * one takes to arrive, and a tab that held every one of them would grow
+ * without bound. The oldest is revoked when the room runs out.
+ */
+const BYTES_LIMIT = 8
+const bytes = new Map()
+
+/**
+ * Which messages this page is drawing bytes for, and how many tiles each.
+ *
+ * Refcounted because two tiles may show one camera and the first to unmount
+ * must not stop the second's frames. The socket sends the key set whenever it
+ * changes; nothing is pushed for a name nobody is looking at.
+ */
+const wanted = new Map()
+const wantedListeners = new Set()
+
function notify(key: string) {
for (const listener of listeners.get(key) ?? []) listener()
}
@@ -247,6 +267,53 @@ export const liveStore = {
isConnected() {
return connected
},
+ /**
+ * Take a frame the socket pushed, as an object URL the tile can draw.
+ *
+ * Content addressing means a digest already here is the same bytes, so the
+ * blob is dropped rather than replacing an identical one.
+ */
+ setBytes(digest: string, blob: Blob) {
+ if (bytes.has(digest)) return
+ bytes.set(digest, URL.createObjectURL(blob))
+ while (bytes.size > BYTES_LIMIT) {
+ const oldest = bytes.keys().next().value
+ if (oldest === undefined) break
+ const url = bytes.get(oldest)
+ bytes.delete(oldest)
+ if (url) URL.revokeObjectURL(url)
+ notify(`bytes:${oldest}`)
+ }
+ notify(`bytes:${digest}`)
+ },
+ getBytes(digest: string) {
+ return bytes.get(digest)
+ },
+ /** Ask for this message's frames while the caller is drawing it. */
+ wantBytes(name: string) {
+ wanted.set(name, (wanted.get(name) ?? 0) + 1)
+ if (wanted.get(name) === 1)
+ for (const listener of wantedListeners) listener()
+ return () => {
+ const left = (wanted.get(name) ?? 1) - 1
+ if (left > 0) {
+ wanted.set(name, left)
+ return
+ }
+ wanted.delete(name)
+ for (const listener of wantedListeners) listener()
+ }
+ },
+ wantedNames() {
+ return [...wanted.keys()]
+ },
+ /** Told when the set changes, so the socket can say so. */
+ onWanted(listener: Listener) {
+ wantedListeners.add(listener)
+ return () => {
+ wantedListeners.delete(listener)
+ }
+ },
reset() {
for (const key of values.keys()) notify(`value:${key}`)
values.clear()
@@ -264,6 +331,13 @@ export const liveStore = {
notify("logs")
for (const flow of paused) notify(`paused:${flow}`)
paused.clear()
+ for (const [digest, url] of bytes) {
+ URL.revokeObjectURL(url)
+ notify(`bytes:${digest}`)
+ }
+ bytes.clear()
+ // `wanted` is deliberately kept: the tiles asking are still mounted, and a
+ // reconnecting socket has to say what they want all over again.
},
}
@@ -274,6 +348,15 @@ export function useLiveValue(name: string | undefined): LiveValue | undefined {
)
}
+/** The pushed bytes for a digest, if the socket carried them. */
+export function useLiveBytes(digest: string | undefined): string | undefined {
+ return useSyncExternalStore(
+ (listener) =>
+ digest ? subscribeKey(`bytes:${digest}`, listener) : () => {},
+ () => (digest ? bytes.get(digest) : undefined),
+ )
+}
+
export function useNodeStatus(nodeId: string): LiveStatus | undefined {
return useSyncExternalStore(
(listener) => subscribeKey(`status:${nodeId}`, listener),
diff --git a/frontend/src/components/Flow/useFlowSocket.ts b/frontend/src/components/Flow/useFlowSocket.ts
index acff808..6efd0fa 100644
--- a/frontend/src/components/Flow/useFlowSocket.ts
+++ b/frontend/src/components/Flow/useFlowSocket.ts
@@ -7,6 +7,7 @@ import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
import { healthKeys } from "@/components/Health/queries"
import { runKeys } from "@/components/Runs/queries"
import { connectionStore } from "@/lib/connectionStore"
+import { parseMediaFrame } from "@/lib/media"
import { apiToken } from "@/lib/portal"
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
import { flowKeys } from "./queries"
@@ -160,9 +161,25 @@ function schedule() {
retry = Math.min(retry * 2, RECONNECT_MAX)
}
+/**
+ * Say which messages this page wants frames for.
+ *
+ * Bytes are the one thing the socket does not send unasked: a camera is
+ * hundreds of kilobytes a second and most tabs are drawing no media at all. So
+ * a Media tile or a thumbnail registers its message, and this tells the engine
+ * — again on every reconnect, since the new socket knows nothing.
+ */
+function sendWanted() {
+ if (socket?.readyState !== WebSocket.OPEN) return
+ socket.send(JSON.stringify({ type: "media", names: liveStore.wantedNames() }))
+}
+
+liveStore.onWanted(sendWanted)
+
function connect() {
if (watchers === 0 || socket || timer) return
const ws = new WebSocket(socketUrl())
+ ws.binaryType = "arraybuffer"
socket = ws
ws.onopen = () => {
@@ -185,6 +202,7 @@ function connect() {
]) {
client?.invalidateQueries({ queryKey })
}
+ sendWanted()
}
const handle = (message: FlowEvent) => {
@@ -318,6 +336,13 @@ function connect() {
// frame rather than the connection: an exception thrown here escapes into
// `window.onerror` and leaves whatever it had already applied behind.
try {
+ if (event.data instanceof ArrayBuffer) {
+ // Media, sent in front of the value that names it — so the tile has
+ // the bytes by the time it hears the message changed.
+ const frame = parseMediaFrame(event.data)
+ if (frame) liveStore.setBytes(frame.header.digest, frame.bytes)
+ return
+ }
const payload = JSON.parse(event.data)
if (payload?.type === "batch") {
// A cascade publishes a dozen events at once and the engine coalesces
diff --git a/frontend/src/lib/media.check.ts b/frontend/src/lib/media.check.ts
new file mode 100644
index 0000000..537f0bc
--- /dev/null
+++ b/frontend/src/lib/media.check.ts
@@ -0,0 +1,63 @@
+/**
+ * The framing a pushed frame arrives in, and what a malformed one must do.
+ *
+ * Run: `bun src/lib/media.check.ts` (there is no unit runner; the suite in
+ * `tests/` drives a running stack).
+ */
+import assert from "node:assert/strict"
+
+import { describeArtifact, isRef, parseMediaFrame } from "./media"
+
+function frame(header: object, payload: Uint8Array): ArrayBuffer {
+ const encoded = new TextEncoder().encode(JSON.stringify(header))
+ const out = new Uint8Array(4 + encoded.length + payload.length)
+ new DataView(out.buffer).setUint32(0, encoded.length)
+ out.set(encoded, 4)
+ out.set(payload, 4 + encoded.length)
+ return out.buffer
+}
+
+const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47])
+const parsed = parseMediaFrame(
+ frame(
+ {
+ type: "media",
+ name: "cam.frame",
+ digest: `sha256:${"a".repeat(64)}`,
+ media_type: "image/png",
+ ts: 1,
+ },
+ bytes,
+ ),
+)
+assert.ok(parsed)
+assert.equal(parsed.header.name, "cam.frame")
+assert.equal(parsed.header.media_type, "image/png")
+assert.equal(parsed.bytes.size, 4)
+assert.equal(parsed.bytes.type, "image/png")
+
+// A frame the client cannot read is dropped, never thrown on: this runs on
+// every socket message.
+assert.equal(parseMediaFrame(new ArrayBuffer(0)), null)
+assert.equal(parseMediaFrame(new ArrayBuffer(2)), null)
+assert.equal(parseMediaFrame(frame({ name: "n" }, bytes)), null)
+// A length longer than the frame is truncation, not a payload.
+const short = new Uint8Array(8)
+new DataView(short.buffer).setUint32(0, 999)
+assert.equal(parseMediaFrame(short.buffer), null)
+
+assert.ok(isRef({ digest: `sha256:${"b".repeat(64)}` }))
+assert.equal(isRef({ digest: "nope" }), false)
+assert.equal(isRef(null), false)
+
+assert.equal(
+ describeArtifact({ media_type: "image/png", name: "f.png", size: 48000 }),
+ "image/png · f.png · 48kB",
+)
+// No media type is still worth summarising as something.
+assert.equal(
+ describeArtifact({ media_type: "application/octet-stream", size: 12 }),
+ "artifact · 12B",
+)
+
+console.log("media.check.ts ok")
diff --git a/frontend/src/lib/media.ts b/frontend/src/lib/media.ts
new file mode 100644
index 0000000..4b96e0f
--- /dev/null
+++ b/frontend/src/lib/media.ts
@@ -0,0 +1,152 @@
+/**
+ * Media references, and the bytes behind them.
+ *
+ * A frame never travels as a message — the reference does. What the bytes cost
+ * to get is what limits the rate: fetched, it is a round trip per frame and a
+ * camera is a glance; pushed down the socket that already carries the value,
+ * it is a view. Both paths are here, and a caller does not choose between
+ * them: the hook takes whichever arrived.
+ */
+import { useEffect, useState } from "react"
+
+import { OpenAPI } from "@/client"
+import { liveStore, useLiveBytes } from "@/components/Flow/liveStore"
+import { apiToken } from "@/lib/portal"
+import { si } from "@/lib/utils"
+
+/** An artifact reference, as a message carries one. */
+export type MediaRef = {
+ digest?: string
+ media_type?: string
+ name?: string
+ size?: number
+}
+
+export const isRef = (value: unknown): value is MediaRef =>
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as MediaRef).digest === "string" &&
+ (value as MediaRef).digest!.startsWith("sha256:")
+
+/**
+ * What a reference *is*, in the space a value would have taken: what kind of
+ * bytes, what they were called, how many of them.
+ */
+export function describeArtifact(record: Record): string {
+ const name = typeof record.name === "string" && record.name ? record.name : ""
+ const size = typeof record.size === "number" ? `${si(record.size)}B` : ""
+ // The media type when there is one: what kind of bytes these are is the
+ // first thing worth knowing about a frame or a clip, and it is what the
+ // port's own type was declared against.
+ const media =
+ typeof record.media_type === "string" &&
+ record.media_type &&
+ record.media_type !== "application/octet-stream"
+ ? record.media_type
+ : "artifact"
+ return [media, name, size].filter(Boolean).join(" · ")
+}
+
+/** The header a pushed frame carries in front of its bytes. */
+export type MediaFrame = {
+ name: string
+ digest: string
+ media_type: string
+ ts?: number
+}
+
+/**
+ * Split a binary socket frame into what it says and what it carries.
+ *
+ * Four bytes of header length, the header as JSON, then the bytes. Returns
+ * null for anything that does not parse, since a frame the client cannot read
+ * is one to drop rather than one to crash on.
+ */
+export function parseMediaFrame(
+ buffer: ArrayBuffer,
+): { header: MediaFrame; bytes: Blob } | null {
+ if (buffer.byteLength < 4) return null
+ const length = new DataView(buffer).getUint32(0)
+ if (length <= 0 || 4 + length > buffer.byteLength) return null
+ try {
+ const header = JSON.parse(
+ new TextDecoder().decode(new Uint8Array(buffer, 4, length)),
+ ) as MediaFrame
+ if (
+ typeof header?.digest !== "string" ||
+ typeof header?.name !== "string"
+ ) {
+ return null
+ }
+ return {
+ header,
+ bytes: new Blob([new Uint8Array(buffer, 4 + length)], {
+ type: header.media_type || "application/octet-stream",
+ }),
+ }
+ } catch {
+ return null
+ }
+}
+
+/**
+ * A local URL for an artifact's bytes, refreshed whenever the digest changes.
+ *
+ * Pushed bytes are used where the socket sent them, which is the fast path and
+ * costs no request at all. Otherwise they are fetched: `/artifacts/{digest}`
+ * takes a bearer token and no `` or `