Media dtypes: image, audio and video as narrowed artifact references
Docs / docs (push) Successful in 30s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m7s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m54s
pre-commit / pre-commit (push) Failing after 4m24s
Test Backend / test-backend (push) Successful in 3m8s
Compose Smoke Test / test-compose (push) Successful in 40s
Playwright Tests / merge-reports (push) Successful in 1m33s

A port may now declare `image`, `audio` or `video`. Each is the artifact
reference the engine already had, narrowed by the `media_type` on it, so a
speech recogniser declares what it eats rather than taking any bytes at all and
finding out. Bytes still never travel as a message and nothing on the wire
stops being JSON: a camera publishes one reference per frame, a microphone one
per chunk, and a reference may carry a `meta` dict nothing here interprets.

Streaming media is therefore an ordinary streaming port — with one change to
what that means. An emission used to journal an item with no payload, so
downstream read whatever was current when the item was claimed; a consumer
slower than its producer saw only the newest chunk and the ones between were
lost. That is right for a training curve and wrong for a second of speech, so
an emission now journals a `kind="emission"` item carrying its values, and the
executor hands them to the nodes reading that message instead of writing them
to state again. The value in state stays the latest, which is what everything
else reads, and the wave is filtered by what actually changed rather than
walking everything reachable. No queue serialization change — the existing
`outputs` field carries it.

Continuous media makes the store's missing GC a real problem, so this closes
it: `sweep_artifacts` runs hourly, keeps every digest a `run_artifact` row
records or a live message holds, spares anything written in the last hour, and
stands aside entirely while a run is in flight, since a node may store a
checkpoint long before it returns the reference to it. That also collects the
orphans a deleted flow has always left behind. `ARTIFACT_GC_INTERVAL_S=0` turns
it off.

Around the edges: `GET /artifacts/{digest}` serves the media type the caller
passes and answers ranged requests, so a browser plays a clip rather than
downloading it; `PUT` spools to disk instead of holding the whole body in
memory, as does `save_artifact` given a path; a Media widget draws whatever its
message points at, and a wall panel may fetch the bytes its own tiles are
showing and nothing else; and a connector gets `save_artifact`, for a device
whose readings are bytes.

What this cannot do is live video: a frame every second or two is a glance, and
the honest answer above that is the camera's own stream, which the widget takes
as a URL and the browser plays from source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 23:44:55 +02:00
co-authored by Claude Opus 5
parent 8be7e424ba
commit 0ffcabfdb9
37 changed files with 1271 additions and 62 deletions
+15
View File
@@ -105,6 +105,21 @@ Keep the first version off the wire. A boolean setting the code checks before
it sends — `artnet`'s `transmit` is the example — lets a flow be built and
watched in the logs before anything physically moves.
## If the reading is bytes
A camera or a microphone publishes a reference rather than the bytes:
```python
async def poll(self):
jpeg = await asyncio.to_thread(self._grab)
return {"frame": self.save_artifact(jpeg, "f.jpg", media_type="image/jpeg")}
```
Place the node with an `image`-typed output port and a Media widget draws each
frame as it lands. `fluksio-connector-test-media` publishes test frames and
tones this way, so the whole path can be wired up with no camera in the room —
copy it if yours is a media device.
## Try it without a device
Give `poll` something predictable first and confirm the values reach the canvas
+33
View File
@@ -110,6 +110,39 @@ Because the address is the content's hash, a sweep whose fifty configs share
one preprocessed input stores it once, and a reference stays valid wherever the
store is reachable from — including on another machine.
### Media
Say what the bytes are and the port can be typed for them:
```python
def process(speech): # an `audio` port
clip = fluksio.load_artifact(speech)
words = transcribe(clip)
return {"transcript": words} # a `str` port
```
```python
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"
)
frame["meta"] = {"seq": index}
yield {"frame": frame} # an `image` stream port
```
An `image`, `audio` or `video` port is an artifact reference whose media type
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.
!!! 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.
## Printing
`print` works and is captured. The first 16 KB per call is kept and shown in
+19 -3
View File
@@ -79,8 +79,8 @@ schema, so they all behave the same way. The full list is in
### Ports are typed
A port declares a `dtype`: `float`, `int`, `str`, `bool`, `json`, `record`,
`list`, `series` or `artifact`. Every value that passes through is checked
against it.
`list`, `series`, `artifact`, or one of the media types `image`, `audio` and
`video`. Every value that passes through is checked against it.
Types are not decoration. They are what lets the dashboard editor offer you
only the messages a gauge can actually draw, and what lets the canvas refuse a
@@ -88,7 +88,23 @@ binding before anything runs. See [Payload types](../reference/payload-types.md)
Everything on the wire is JSON. Bytes — a checkpoint, an image, a model —
travel as an `artifact`: the bytes go to a content-addressed store and the
message carries a small reference to them.
message carries a small reference to them. The media types are that same
reference, saying what kind of bytes are behind it.
### Streaming ports
A port marked `stream` produces repeatedly *during* one execution rather than
once at the end: a training loss, a progress fraction, a frame from a camera, a
second of speech. A node publishes on one by being a generator and yielding, or
by calling `fluksio.emit`.
Each value is delivered to the nodes reading it, in the order it was produced —
so a recogniser slower than the microphone in front of it still sees every
chunk rather than only the newest. What is in state remains the latest value,
which is what everything else reads, and what a run keeps is the whole series.
An `interval` on a streaming port thins what reaches the canvas without
thinning the run's record of it.
### Nodes are pure
+21
View File
@@ -34,6 +34,7 @@ and dragging is off. Picking a widget and editing its settings still works.
| **Agenda** | `list` | upcoming items, e.g. from a calendar connector |
| **Forecast** | `list` | a short outlook strip |
| **Notification** | `record` | title, body and severity — what an alert channel writes |
| **Media** | `image`, `audio`, `video` | a camera frame, a clip; see *Media tiles* below |
| **Clock** | — | the time, in a size a wall can read |
Every widget carries a **title**, and **Show title** decides whether the panel
@@ -91,6 +92,26 @@ it, so swapping the store is a change to one flow and nothing else. The answer
also states what it was computed for, so an answer to a different question is
ignored rather than two charts overwriting each other's picture.
## Media tiles
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
— so the tile fetches 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.
Panels see media the same way, and only their own: a screen may fetch the bytes
its own tiles are showing and nothing else.
## Dashboard settings
Most of what a dashboard carries is a widget: a tile bound to a message. A
+8
View File
@@ -128,6 +128,14 @@ warning into a refusal to start.
| `FLOW_CPUS` | `0` | cores nodes that declare `resources` may be given; 0 works it out as every core but two, which are what keeps the engine answering while the machine is busy |
| `FLOW_GPUS` | `0` | GPUs on this machine, each held by one node at a time. Not detected — say how many there are |
| `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 |
An artifact is referred to by a run that recorded it or by a message currently
holding it; anything else is what a camera published four hours ago, and the
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.
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
+23
View File
@@ -166,6 +166,29 @@ def write(self, **ports: Any) -> dict[str, Any] | None:
A write is a command, not a value: set `idempotent = False` on the class so a
redelivery after a crash does not undo a newer command that already landed.
## Devices whose readings are bytes
A camera frame or a recorded clip is far too big to be a message, so a
connector publishes a reference to it instead:
```python
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")
}
```
`save_artifact` stores the bytes and returns what an `image`, `audio` or
`video` port carries — the media type has to match the port's type. It only
works once the node has started, since the store is the engine's and is handed
over then.
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.
## Lifecycle
```python
+34
View File
@@ -99,6 +99,39 @@ stays valid wherever the store is reachable from, including on another machine.
Node code produces one with `fluksio.save_artifact` and opens one with
`fluksio.load_artifact`. See [Writing node code](../code/nodes.md#bytes-artifacts).
### `image`, `audio`, `video`
The same reference, narrowed to a kind of media by its `media_type`.
```json
{"digest": "sha256:…", "size": 61344, "media_type": "image/jpeg", "name": "frame.jpg",
"meta": {"width": 1280, "height": 720, "seq": 41}}
```
An `audio` port takes `audio/*` and refuses anything else, so a speech
recogniser declares what it eats rather than taking any bytes at all and
finding out. An `artifact` port still accepts all three — media narrows
artifact, not the other way round.
`meta` is optional and nothing here reads it: sample rates, dimensions and
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:
| 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, 1530 fps | 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.
### `json`
Anything JSON-serializable. The escape hatch, and the right answer when a
@@ -129,6 +162,7 @@ carry one of them literally is asking for a value this engine reads as a name.
| Notification | `record` |
| Value | anything |
| Icon | weather strings, booleans and numbers alike |
| Media | `image`, `audio`, `video`, `artifact` |
| Clock, Text | nothing — they bind to no message |
Enforced on the server as well as in the editor.