braidpipe 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: braidpipe
3
+ Version: 0.3.0
4
+ Summary: Worker SDK for braidpipe, the never-dark AI video middleware: write a function that mutates a frame, the SDK runs the IPC loop.
5
+ Author: braidpipe contributors
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/emdiple/braidpipe
8
+ Keywords: video,streaming,gstreamer,computer-vision,shared-memory
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Multimedia :: Video
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: numpy>=1.24
18
+ Provides-Extra: examples
19
+ Requires-Dist: opencv-python; extra == "examples"
20
+
21
+ # braidpipe (worker SDK)
22
+
23
+ The Python worker SDK for [braidpipe](https://github.com/emdiple/braidpipe), the
24
+ never-dark AI video middleware. The Rust daemon owns the media path; this
25
+ package is how a Python process receives its frames — as zero-copy NumPy views
26
+ over shared memory locally, or over the tcp-raw transport from another machine —
27
+ and hands them back.
28
+
29
+ A complete worker:
30
+
31
+ ```python
32
+ import braidpipe
33
+
34
+ def process(frame): # (H, W, 3) uint8, RGB — mutate it in place
35
+ frame[:, :, 0] //= 2 # your inference here
36
+
37
+ if __name__ == "__main__":
38
+ braidpipe.run(process)
39
+ ```
40
+
41
+ `run()` owns everything else: the handshake, the per-frame notification loop,
42
+ freeing the shared-memory slot, acking (including `"success": false` when your
43
+ code raises, so the stream falls back to passthrough instead of going dark),
44
+ and the transport switch — set `BRAIDPIPE_DAEMON=host:port` and the same worker
45
+ attaches over the network instead.
46
+
47
+ This package does not contain the daemon. Build it from the
48
+ [repository](https://github.com/emdiple/braidpipe), which also has the full
49
+ worker contract, examples, and deadline rules in `docs/workers.md`.
@@ -0,0 +1,29 @@
1
+ # braidpipe (worker SDK)
2
+
3
+ The Python worker SDK for [braidpipe](https://github.com/emdiple/braidpipe), the
4
+ never-dark AI video middleware. The Rust daemon owns the media path; this
5
+ package is how a Python process receives its frames — as zero-copy NumPy views
6
+ over shared memory locally, or over the tcp-raw transport from another machine —
7
+ and hands them back.
8
+
9
+ A complete worker:
10
+
11
+ ```python
12
+ import braidpipe
13
+
14
+ def process(frame): # (H, W, 3) uint8, RGB — mutate it in place
15
+ frame[:, :, 0] //= 2 # your inference here
16
+
17
+ if __name__ == "__main__":
18
+ braidpipe.run(process)
19
+ ```
20
+
21
+ `run()` owns everything else: the handshake, the per-frame notification loop,
22
+ freeing the shared-memory slot, acking (including `"success": false` when your
23
+ code raises, so the stream falls back to passthrough instead of going dark),
24
+ and the transport switch — set `BRAIDPIPE_DAEMON=host:port` and the same worker
25
+ attaches over the network instead.
26
+
27
+ This package does not contain the daemon. Build it from the
28
+ [repository](https://github.com/emdiple/braidpipe), which also has the full
29
+ worker contract, examples, and deadline rules in `docs/workers.md`.
@@ -0,0 +1,44 @@
1
+ """The braidpipe worker SDK: your function, our loop.
2
+
3
+ A complete worker is a processing function handed to `run()`:
4
+
5
+ import braidpipe
6
+
7
+ def process(frame): # (H, W, 3) uint8, RGB — mutate it in place
8
+ frame[:, :, 0] //= 2 # your inference here
9
+
10
+ if __name__ == "__main__":
11
+ braidpipe.run(process)
12
+
13
+ `run()` owns the handshake, the per-frame loop, slot release, acking (including
14
+ `"success": false` when the handler raises, so the stream falls back to
15
+ passthrough instead of going dark) and the transport: shared memory next to a
16
+ local daemon, tcp-raw when `BRAIDPIPE_DAEMON=host:port` is set. Take a second
17
+ `ctx` parameter for frame ids and timestamps, and use `BackgroundModel` for
18
+ models too slow for the 1.5-frame-period deadline.
19
+
20
+ The transports underneath (`attach`/`SharedMemoryManager` for shared memory,
21
+ `connect`/`RemoteWorkerLink` for tcp-raw) stay importable for workers that need
22
+ the loop itself.
23
+ """
24
+
25
+ from .background import BackgroundModel
26
+ from .contract import CONTRACT_VERSION
27
+ from .remote import RemoteWorkerLink, connect
28
+ from .runner import FrameContext, run, worker
29
+ from .shm import SharedMemoryManager, attach
30
+
31
+ __version__ = "0.3.0"
32
+
33
+ __all__ = [
34
+ "BackgroundModel",
35
+ "CONTRACT_VERSION",
36
+ "FrameContext",
37
+ "RemoteWorkerLink",
38
+ "SharedMemoryManager",
39
+ "attach",
40
+ "connect",
41
+ "run",
42
+ "worker",
43
+ "__version__",
44
+ ]
@@ -0,0 +1,16 @@
1
+ """`python3 -m braidpipe`: the raw worker with no processing at all.
2
+
3
+ Attaches, acks every frame untouched, and proves the AI loop is closed — the
4
+ same thing python/braidpipe/worker.py does, without needing a path to a script.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+ from .runner import run
10
+
11
+
12
+ def process(frame: np.ndarray) -> None:
13
+ """Deliberately empty: every frame passes through the loop unmodified."""
14
+
15
+
16
+ run(process, name="braidpipe")
@@ -0,0 +1,78 @@
1
+ """Off-hot-path inference: run a slow model on a thread, read cached results.
2
+
3
+ The relay gives a worker 1.5 frame periods, and most real models do not fit in
4
+ that. The trade that works is to decouple "what is in the frame" (slow, cached)
5
+ from "draw it" (fast, every frame): submit an occasional frame to the model,
6
+ annotate every frame with the most recent answer. Results lag the picture by a
7
+ frame or two; no frame ever misses its deadline.
8
+
9
+ `BackgroundModel` is that pattern with the threading taken out of your hands:
10
+
11
+ model = braidpipe.BackgroundModel(infer, initial=[])
12
+
13
+ def process(frame, ctx):
14
+ if ctx.frame_id % 3 == 0:
15
+ model.submit(frame)
16
+ draw(frame, model.latest())
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import threading
22
+ from typing import Callable, Generic, TypeVar
23
+
24
+ import numpy as np
25
+
26
+ T = TypeVar("T")
27
+
28
+
29
+ class BackgroundModel(Generic[T]):
30
+ """Runs `infer(frame) -> result` on a daemon thread, one frame at a time.
31
+
32
+ `submit()` copies the frame — the shared-memory slot is recycled the moment
33
+ the handler returns — and is dropped, not queued, while a previous frame is
34
+ still being inferred, so the model always works on the newest picture it
35
+ can get. `latest()` returns the most recent result, or `initial` until the
36
+ first inference completes. An exception in `infer` is logged and the result
37
+ left unchanged; the thread never dies.
38
+ """
39
+
40
+ def __init__(self, infer: Callable[[np.ndarray], T], initial: T = None):
41
+ self._infer = infer
42
+ self._pending: np.ndarray | None = None
43
+ self._latest: T = initial
44
+ self._lock = threading.Lock()
45
+ self._wake = threading.Event()
46
+ self._thread = threading.Thread(target=self._loop, daemon=True)
47
+ self._thread.start()
48
+
49
+ def submit(self, frame: np.ndarray) -> None:
50
+ """Offer a frame for inference; skipped if one is already waiting."""
51
+ with self._lock:
52
+ if self._pending is not None:
53
+ return
54
+ self._pending = frame.copy()
55
+ self._wake.set()
56
+
57
+ def latest(self) -> T:
58
+ with self._lock:
59
+ return self._latest
60
+
61
+ def _loop(self) -> None:
62
+ while True:
63
+ self._wake.wait()
64
+ self._wake.clear()
65
+
66
+ with self._lock:
67
+ frame, self._pending = self._pending, None
68
+ if frame is None:
69
+ continue
70
+
71
+ try:
72
+ result = self._infer(frame)
73
+ except Exception as exc: # a bad frame must not kill the thread
74
+ print(f"[background] inference error: {exc}", flush=True)
75
+ continue
76
+
77
+ with self._lock:
78
+ self._latest = result
@@ -0,0 +1,32 @@
1
+ """The IPC contract version this SDK speaks, and the check against the daemon.
2
+
3
+ The contract is everything both sides must agree on: the shm/slot layouts, the
4
+ control-packet fields, and the tcp-raw wire format. The daemon stamps its
5
+ version into every config packet it answers a hello with; a mismatch here must
6
+ fail at attach time, loudly, because the alternative is silently misreading
7
+ shared memory.
8
+
9
+ A config with no version at all comes from a daemon older than versioning
10
+ (braidpipe <= 0.2.0). Those daemons speak contract 1, so attaching proceeds
11
+ with a warning rather than refusing streams that would work.
12
+ """
13
+
14
+ CONTRACT_VERSION = 1
15
+
16
+
17
+ def check_contract(config: dict, transport: str) -> None:
18
+ """Raises RuntimeError if the daemon's config names a different contract."""
19
+ theirs = config.get("contract")
20
+ if theirs is None:
21
+ print(
22
+ f"[braidpipe] warning: daemon sent no contract version over {transport} "
23
+ f"(daemon <= 0.2.0); assuming contract {CONTRACT_VERSION}",
24
+ flush=True,
25
+ )
26
+ return
27
+ if theirs != CONTRACT_VERSION:
28
+ raise RuntimeError(
29
+ f"daemon speaks IPC contract {theirs}, this SDK speaks {CONTRACT_VERSION} "
30
+ f"(over {transport}); upgrade whichever side is older instead of attaching "
31
+ "and misreading frames"
32
+ )
File without changes
@@ -0,0 +1,137 @@
1
+ """The tcp-raw transport: attach to a braidpipe daemon on another machine.
2
+
3
+ Mirrors the shm handshake in shape: say hello, get a config packet back.
4
+ Here the hello goes over UDP to the daemon's --worker-listen address, the
5
+ config carries a TCP port instead of a file descriptor, and frames flow both
6
+ ways on one TCP connection as a fixed 24-byte header plus raw pixel bytes.
7
+
8
+ The frames are uncompressed, so this needs a fast link: 720p RGB at 30 fps is
9
+ ~660 Mbit/s each way. Use it on a 10 GbE LAN (or 720p on a quiet gigabit
10
+ link), not across the internet.
11
+ """
12
+
13
+ import json
14
+ import socket
15
+ import struct
16
+ import time
17
+
18
+ import numpy as np
19
+
20
+ from .contract import check_contract
21
+
22
+ # Wire header matching Rust net.rs, 24 bytes little-endian:
23
+ # frame_id u64, time_us u64, payload_len u32, slot u8, flags u8, 2 pad.
24
+ # time_us carries the capture timestamp daemon -> worker and this worker's
25
+ # processing time on the way back. Bit 0 of flags is "success" on results.
26
+ WIRE_HEADER_FMT = "<QQIBB2x"
27
+ WIRE_HEADER_SIZE = struct.calcsize(WIRE_HEADER_FMT)
28
+ assert WIRE_HEADER_SIZE == 24
29
+
30
+ HELLO = b'{"type":"hello","transports":["tcp-raw"]}'
31
+
32
+
33
+ def connect(daemon: str, retry_interval: float = 1.0) -> "RemoteWorkerLink":
34
+ """Negotiates with the daemon at "host:port" until a data connection is up.
35
+
36
+ Retries forever, so a worker may start before the daemon and simply wait
37
+ for it -- the same contract the shm attach gives a local worker.
38
+ """
39
+ host, _, port_text = daemon.rpartition(":")
40
+ address = (host, int(port_text))
41
+
42
+ udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
43
+ udp.settimeout(retry_interval)
44
+ try:
45
+ while True:
46
+ try:
47
+ udp.sendto(HELLO, address)
48
+ reply, _ = udp.recvfrom(512)
49
+ except (TimeoutError, OSError):
50
+ time.sleep(retry_interval)
51
+ continue
52
+ config = json.loads(reply)
53
+ if config.get("type") == "config" and config.get("transport") == "tcp-raw":
54
+ check_contract(config, "tcp-raw")
55
+ break
56
+ raise RuntimeError(f"daemon refused tcp-raw: {config}")
57
+ finally:
58
+ udp.close()
59
+
60
+ tcp = socket.create_connection((host, config["data_port"]))
61
+ tcp.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
62
+ return RemoteWorkerLink(tcp, config)
63
+
64
+
65
+ class RemoteWorkerLink:
66
+ def __init__(self, sock: socket.socket, config: dict):
67
+ self.sock = sock
68
+ self.width = config["width"]
69
+ self.height = config["height"]
70
+ self.channels = config["channels"]
71
+ self._payload_len = self.width * self.height * self.channels
72
+ # One reusable buffer: frames are processed in place and sent back
73
+ # from the same bytes, so no per-frame allocation happens.
74
+ self._buf = bytearray(self._payload_len)
75
+ self._frame_view = np.frombuffer(self._buf, dtype=np.uint8).reshape(
76
+ self.height, self.width, self.channels
77
+ )
78
+
79
+ def frames(self):
80
+ """Yields (frame_id, slot, timestamp_us, frame) until the daemon hangs up.
81
+
82
+ `frame` is a NumPy view over an internal buffer that is reused for the
83
+ next frame -- process it (in place is fine) and call `send_processed`
84
+ before advancing the loop; copy it if you need to keep it longer.
85
+ """
86
+ header_buf = bytearray(WIRE_HEADER_SIZE)
87
+ frame = self._frame_view
88
+ while True:
89
+ if not self._recv_exact(header_buf):
90
+ return
91
+ frame_id, timestamp_us, payload_len, slot, _flags = struct.unpack(
92
+ WIRE_HEADER_FMT, header_buf
93
+ )
94
+ if payload_len != self._payload_len:
95
+ raise RuntimeError(
96
+ f"daemon sent {payload_len} payload bytes, expected {self._payload_len}"
97
+ )
98
+ if not self._recv_exact(self._buf):
99
+ return
100
+ yield frame_id, slot, timestamp_us, frame
101
+
102
+ def send_processed(
103
+ self,
104
+ frame_id: int,
105
+ slot: int,
106
+ frame: np.ndarray,
107
+ processing_time_us: int,
108
+ success: bool = True,
109
+ ) -> None:
110
+ """Returns a result to the daemon; this doubles as the frame's ack."""
111
+ header = struct.pack(
112
+ WIRE_HEADER_FMT,
113
+ frame_id,
114
+ processing_time_us,
115
+ self._payload_len,
116
+ slot,
117
+ 1 if success else 0,
118
+ )
119
+ self.sock.sendall(header)
120
+ if np.shares_memory(frame, self._frame_view):
121
+ # The frame is (a view of) the internal buffer; send it as-is.
122
+ self.sock.sendall(self._buf)
123
+ else:
124
+ self.sock.sendall(np.ascontiguousarray(frame).tobytes())
125
+
126
+ def _recv_exact(self, buf) -> bool:
127
+ """Fills `buf` completely, or returns False on a closed connection."""
128
+ view = memoryview(buf)
129
+ while view:
130
+ received = self.sock.recv_into(view, len(view))
131
+ if received == 0:
132
+ return False
133
+ view = view[received:]
134
+ return True
135
+
136
+ def close(self):
137
+ self.sock.close()
@@ -0,0 +1,245 @@
1
+ """The worker loop as a library: write `process(frame)`, call `run(process)`.
2
+
3
+ Every worker has the same four obligations — attach, receive a notification per
4
+ frame, free the slot, ack — and the same failure contract: report an exception
5
+ with `"success": false` instead of dying, so the daemon passes the original
6
+ frame through and the stream never goes dark. This module owns all of that,
7
+ for both transports:
8
+
9
+ - shared memory + Unix datagrams when launched by (or next to) a local daemon,
10
+ - tcp-raw when `BRAIDPIPE_DAEMON=host:port` points at a remote one.
11
+
12
+ The processing callback is the only thing a worker author writes. It takes the
13
+ frame — an (H, W, 3) uint8 RGB array to mutate in place — and optionally a
14
+ `FrameContext`; raising is safe and costs one passthrough frame, but the
15
+ callback must finish inside 1.5 frame periods (50 ms at 30 fps) or the daemon
16
+ falls back to passthrough. Anything slower belongs off the hot path — see
17
+ `braidpipe.BackgroundModel`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import inspect
23
+ import json
24
+ import os
25
+ import socket
26
+ import time
27
+ from dataclasses import dataclass
28
+ from typing import Callable
29
+
30
+ import numpy as np
31
+
32
+ from .remote import connect
33
+ from .shm import attach
34
+
35
+ DEFAULT_RUST_SOCK = "/tmp/braidpipe_rust.sock"
36
+ DEFAULT_PYTHON_SOCK = "/tmp/braidpipe_python.sock"
37
+
38
+ ProcessFn = Callable[..., None]
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class FrameContext:
43
+ """Everything known about the frame besides its pixels.
44
+
45
+ `timestamp_us` is the wall clock the daemon recorded as it handed the frame
46
+ over (written into the slot header locally, carried on the wire remotely),
47
+ so `time.time_ns() // 1000 - ctx.timestamp_us` is the one-way IPC delay.
48
+ """
49
+
50
+ frame_id: int
51
+ slot: int
52
+ timestamp_us: int
53
+ width: int
54
+ height: int
55
+ channels: int
56
+ transport: str # "shm" or "tcp-raw"
57
+
58
+
59
+ _registered: ProcessFn | None = None
60
+
61
+
62
+ def worker(process: ProcessFn) -> ProcessFn:
63
+ """Marks `process` as this script's frame handler, so `run()` finds it
64
+ without being passed anything. Sugar only: `run(process)` is the same."""
65
+ global _registered
66
+ _registered = process
67
+ return process
68
+
69
+
70
+ def _wants_context(process: ProcessFn) -> bool:
71
+ """Whether to call `process(frame, ctx)` or just `process(frame)`.
72
+
73
+ Decided once from the signature, not per frame; anything uninspectable is
74
+ given both arguments.
75
+ """
76
+ try:
77
+ parameters = inspect.signature(process).parameters.values()
78
+ except (TypeError, ValueError):
79
+ return True
80
+ positional = [
81
+ p
82
+ for p in parameters
83
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
84
+ ]
85
+ return len(positional) >= 2 or any(p.kind == p.VAR_POSITIONAL for p in parameters)
86
+
87
+
88
+ def run(
89
+ process: ProcessFn | None = None,
90
+ *,
91
+ daemon: str | None = None,
92
+ rust_sock: str | None = None,
93
+ python_sock: str | None = None,
94
+ name: str = "worker",
95
+ ) -> None:
96
+ """Runs the worker loop until the daemon hangs up or Ctrl-C.
97
+
98
+ Arguments not given fall back to the environment: `BRAIDPIPE_DAEMON`
99
+ selects the tcp-raw transport, otherwise `BRAIDPIPE_RUST_SOCK` and
100
+ `BRAIDPIPE_PYTHON_SOCK` name the local sockets. `name` is only the log
101
+ prefix. Returns normally on shutdown, so final reporting can follow it.
102
+ """
103
+ if process is None:
104
+ process = _registered
105
+ if process is None:
106
+ raise TypeError(
107
+ "no frame handler: pass one to run() or decorate it with @braidpipe.worker"
108
+ )
109
+ wants_ctx = _wants_context(process)
110
+
111
+ if daemon is None:
112
+ daemon = os.environ.get("BRAIDPIPE_DAEMON")
113
+ if daemon:
114
+ _run_remote(process, wants_ctx, daemon, name)
115
+ else:
116
+ _run_shm(
117
+ process,
118
+ wants_ctx,
119
+ rust_sock or os.environ.get("BRAIDPIPE_RUST_SOCK", DEFAULT_RUST_SOCK),
120
+ python_sock or os.environ.get("BRAIDPIPE_PYTHON_SOCK", DEFAULT_PYTHON_SOCK),
121
+ name,
122
+ )
123
+
124
+
125
+ def _invoke(
126
+ process: ProcessFn,
127
+ wants_ctx: bool,
128
+ frame: np.ndarray,
129
+ ctx: FrameContext,
130
+ name: str,
131
+ ) -> bool:
132
+ """Calls the handler; an exception becomes a failed (passthrough) frame."""
133
+ try:
134
+ if wants_ctx:
135
+ process(frame, ctx)
136
+ else:
137
+ process(frame)
138
+ return True
139
+ except Exception as exc:
140
+ print(f"[{name}] frame {ctx.frame_id} failed: {exc}", flush=True)
141
+ return False
142
+
143
+
144
+ def _run_shm(
145
+ process: ProcessFn,
146
+ wants_ctx: bool,
147
+ rust_sock_path: str,
148
+ python_sock_path: str,
149
+ name: str,
150
+ ) -> None:
151
+ if os.path.exists(python_sock_path):
152
+ os.remove(python_sock_path)
153
+
154
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
155
+ sock.bind(python_sock_path)
156
+
157
+ # Handshake: the daemon answers our hello with the shared-memory fd.
158
+ shm = attach(sock, rust_sock_path)
159
+ print(
160
+ f"[{name}] attached to SHM ({shm.width}x{shm.height} @ {shm.channels}ch)",
161
+ flush=True,
162
+ )
163
+
164
+ try:
165
+ while True:
166
+ packet = json.loads(sock.recvfrom(512)[0])
167
+ if "frame_id" not in packet:
168
+ continue # a control packet, e.g. a duplicate handshake reply
169
+ frame_id = packet["frame_id"]
170
+ slot_idx = packet["slot_index"]
171
+ started = time.perf_counter_ns()
172
+
173
+ # The daemon's write time must be read before the slot is freed,
174
+ # or the next frame may already have overwritten the header.
175
+ _, _, written_us = shm.read_slot_header(slot_idx)
176
+ ctx = FrameContext(
177
+ frame_id=frame_id,
178
+ slot=slot_idx,
179
+ timestamp_us=written_us,
180
+ width=shm.width,
181
+ height=shm.height,
182
+ channels=shm.channels,
183
+ transport="shm",
184
+ )
185
+
186
+ frame = shm.get_slot_numpy_array(slot_idx)
187
+ success = _invoke(process, wants_ctx, frame, ctx, name)
188
+
189
+ # The slot is recycled the moment it is freed: the handler must
190
+ # have copied any pixels it wants to keep.
191
+ shm.mark_slot_free(slot_idx)
192
+
193
+ ack = {
194
+ "frame_id": frame_id,
195
+ "slot_index": slot_idx,
196
+ "processing_time_us": (time.perf_counter_ns() - started) // 1000,
197
+ "success": success,
198
+ }
199
+ try:
200
+ sock.sendto(json.dumps(ack).encode("utf-8"), rust_sock_path)
201
+ except OSError as exc:
202
+ # A full datagram buffer is backpressure, not a fatal error.
203
+ print(f"[{name}] dropped ack for frame {frame_id}: {exc}", flush=True)
204
+
205
+ except KeyboardInterrupt:
206
+ print(f"[{name}] shutting down cleanly...", flush=True)
207
+ finally:
208
+ sock.close()
209
+ shm.close()
210
+ if os.path.exists(python_sock_path):
211
+ os.remove(python_sock_path)
212
+
213
+
214
+ def _run_remote(process: ProcessFn, wants_ctx: bool, daemon: str, name: str) -> None:
215
+ link = connect(daemon)
216
+ print(
217
+ f"[{name}] connected to daemon at {daemon} "
218
+ f"({link.width}x{link.height} @ {link.channels}ch)",
219
+ flush=True,
220
+ )
221
+ try:
222
+ for frame_id, slot_idx, timestamp_us, frame in link.frames():
223
+ started = time.perf_counter_ns()
224
+ ctx = FrameContext(
225
+ frame_id=frame_id,
226
+ slot=slot_idx,
227
+ timestamp_us=timestamp_us,
228
+ width=link.width,
229
+ height=link.height,
230
+ channels=link.channels,
231
+ transport="tcp-raw",
232
+ )
233
+ success = _invoke(process, wants_ctx, frame, ctx, name)
234
+ link.send_processed(
235
+ frame_id,
236
+ slot_idx,
237
+ frame,
238
+ (time.perf_counter_ns() - started) // 1000,
239
+ success,
240
+ )
241
+ print(f"[{name}] daemon hung up", flush=True)
242
+ except KeyboardInterrupt:
243
+ print(f"[{name}] shutting down cleanly...", flush=True)
244
+ finally:
245
+ link.close()
@@ -0,0 +1,126 @@
1
+ import json
2
+ import mmap
3
+ import os
4
+ import socket as socket_module
5
+ import struct
6
+ import time
7
+ import numpy as np
8
+
9
+ from .contract import check_contract
10
+
11
+ # Slot State Constants matching Rust shm.rs
12
+ SLOT_FREE = 0
13
+ SLOT_READY_FOR_AI = 1
14
+ SLOT_PROCESSING = 2
15
+
16
+ # Struct layouts matching the explicit #[repr(C)] layouts in Rust shm.rs:
17
+ # ShmHeader: width u32, height u32, channels u8, slot_count u8, 2 pad bytes,
18
+ # slot_size u32, 16 reserved bytes -> 32 bytes total
19
+ HEADER_FMT = "<IIBB2xI16s"
20
+ HEADER_SIZE = struct.calcsize(HEADER_FMT)
21
+ assert HEADER_SIZE == 32
22
+
23
+ # SlotHeader: state u8, 7 pad bytes, frame_id u64, timestamp_us u64 -> 24 bytes
24
+ SLOT_HEADER_FMT = "<B7xQQ"
25
+ SLOT_HEADER_SIZE = struct.calcsize(SLOT_HEADER_FMT)
26
+ assert SLOT_HEADER_SIZE == 24
27
+
28
+ # The handshake: a worker announces itself with HELLO on the daemon's socket
29
+ # and receives a datagram carrying the shared segment's fd as SCM_RIGHTS
30
+ # ancillary data. The segment is anonymous -- the fd is the only way in.
31
+ HELLO = b'{"type":"hello"}'
32
+
33
+
34
+ def attach(sock: socket_module.socket, rust_sock_path: str, retry_interval: float = 1.0):
35
+ """Says hello to the daemon until it answers with the shared-memory fd.
36
+
37
+ `sock` must already be bound to this worker's own socket path, because the
38
+ daemon addresses its reply there. Retries forever, so a worker may start
39
+ before the daemon and simply wait for it.
40
+ """
41
+ previous_timeout = sock.gettimeout()
42
+ sock.settimeout(retry_interval)
43
+ try:
44
+ while True:
45
+ try:
46
+ sock.sendto(HELLO, rust_sock_path)
47
+ except OSError:
48
+ # The daemon is not up (no socket file yet); try again.
49
+ time.sleep(retry_interval)
50
+ continue
51
+ try:
52
+ msg, fds, _flags, _addr = socket_module.recv_fds(sock, 512, 1)
53
+ except TimeoutError:
54
+ continue
55
+ except OSError:
56
+ time.sleep(retry_interval)
57
+ continue
58
+ if fds:
59
+ # The config body riding beside the fd names the daemon's
60
+ # contract version; refuse the fd rather than misread it.
61
+ try:
62
+ config = json.loads(msg)
63
+ except ValueError:
64
+ config = {}
65
+ try:
66
+ check_contract(config, "shm")
67
+ except Exception:
68
+ os.close(fds[0])
69
+ raise
70
+ return SharedMemoryManager(fds[0])
71
+ # A frame notification that raced the handshake; ignore it. The
72
+ # daemon passes those frames through unchanged.
73
+ finally:
74
+ sock.settimeout(previous_timeout)
75
+
76
+
77
+ class SharedMemoryManager:
78
+ def __init__(self, fd: int):
79
+ # The fd received from the daemon describes the whole segment; its
80
+ # size comes from the fd itself, and everything else from the header.
81
+ size = os.fstat(fd).st_size
82
+ self.shm = mmap.mmap(fd, size)
83
+ os.close(fd) # the mapping keeps the segment alive on its own
84
+
85
+ # Unpack header metadata
86
+ header_bytes = bytes(self.shm[:HEADER_SIZE])
87
+ (
88
+ self.width,
89
+ self.height,
90
+ self.channels,
91
+ self.slot_count,
92
+ self.slot_size,
93
+ _,
94
+ ) = struct.unpack(HEADER_FMT, header_bytes)
95
+
96
+ def get_slot_numpy_array(self, slot_idx: int) -> np.ndarray:
97
+ """Returns a zero-copy NumPy view over a specific SHM slot's pixel buffer."""
98
+ slot_offset = HEADER_SIZE + (slot_idx * self.slot_size)
99
+ payload_offset = slot_offset + SLOT_HEADER_SIZE
100
+ payload_bytes = self.width * self.height * self.channels
101
+
102
+ # Point NumPy directly to the shared memory buffer slice
103
+ return np.ndarray(
104
+ shape=(self.height, self.width, self.channels),
105
+ dtype=np.uint8,
106
+ buffer=self.shm,
107
+ offset=payload_offset,
108
+ )
109
+
110
+ def read_slot_header(self, slot_idx: int) -> tuple[int, int, int]:
111
+ """Returns (state, frame_id, timestamp_us) for a slot.
112
+
113
+ `timestamp_us` is the wall clock the daemon recorded as it wrote the
114
+ frame, so subtracting `time.time()` here gives the one-way IPC delay.
115
+ """
116
+ slot_offset = HEADER_SIZE + (slot_idx * self.slot_size)
117
+ raw = bytes(self.shm[slot_offset : slot_offset + SLOT_HEADER_SIZE])
118
+ return struct.unpack(SLOT_HEADER_FMT, raw)
119
+
120
+ def mark_slot_free(self, slot_idx: int):
121
+ """Resets the slot state back to FREE so Rust can write the next frame."""
122
+ slot_offset = HEADER_SIZE + (slot_idx * self.slot_size)
123
+ self.shm[slot_offset] = SLOT_FREE
124
+
125
+ def close(self):
126
+ self.shm.close()
@@ -0,0 +1,47 @@
1
+ """The raw worker template: your code in `process()`, the SDK does the rest.
2
+
3
+ This is the file to copy when writing your own worker. The braidpipe package
4
+ owns the whole IPC contract — attach, one datagram per frame, a zero-copy NumPy
5
+ view of the slot, slot release, the ack — and an exception in `process()` costs
6
+ one passthrough frame, never the stream. The one thing this template does not
7
+ do is touch the pixels.
8
+
9
+ Demonstration workers live in examples/ — an edge transform, threaded YOLO
10
+ detection, and latency stamping.
11
+
12
+ Usage:
13
+ cargo run -p braidpipe --release -- --python-script python/braidpipe/worker.py
14
+
15
+ Environment:
16
+ BRAIDPIPE_DAEMON a daemon's --worker-listen address; switches the
17
+ worker to the tcp-raw transport (default: unset)
18
+ BRAIDPIPE_RUST_SOCK daemon's ack socket (default: /tmp/braidpipe_rust.sock)
19
+ BRAIDPIPE_PYTHON_SOCK this worker's socket (default: /tmp/braidpipe_python.sock)
20
+ """
21
+
22
+ import numpy as np
23
+
24
+ try:
25
+ import braidpipe
26
+ except ModuleNotFoundError: # run from a source checkout, package not installed
27
+ import os
28
+ import sys
29
+
30
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
31
+ import braidpipe
32
+
33
+
34
+ def process(frame: np.ndarray) -> None:
35
+ """Your code goes here. Modify `frame` in place; writing to the view *is*
36
+ writing the output.
37
+
38
+ Frames are RGB, not BGR. Finish inside 1.5 frame periods (50 ms at 30 fps)
39
+ or the daemon falls back to passthrough; anything slower than that belongs
40
+ on a thread with cached results — see braidpipe.BackgroundModel and
41
+ examples/worker_detect.py. Take a second `ctx` parameter if you need frame
42
+ ids or timestamps.
43
+ """
44
+
45
+
46
+ if __name__ == "__main__":
47
+ braidpipe.run(process)
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: braidpipe
3
+ Version: 0.3.0
4
+ Summary: Worker SDK for braidpipe, the never-dark AI video middleware: write a function that mutates a frame, the SDK runs the IPC loop.
5
+ Author: braidpipe contributors
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/emdiple/braidpipe
8
+ Keywords: video,streaming,gstreamer,computer-vision,shared-memory
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Operating System :: MacOS
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Multimedia :: Video
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: numpy>=1.24
18
+ Provides-Extra: examples
19
+ Requires-Dist: opencv-python; extra == "examples"
20
+
21
+ # braidpipe (worker SDK)
22
+
23
+ The Python worker SDK for [braidpipe](https://github.com/emdiple/braidpipe), the
24
+ never-dark AI video middleware. The Rust daemon owns the media path; this
25
+ package is how a Python process receives its frames — as zero-copy NumPy views
26
+ over shared memory locally, or over the tcp-raw transport from another machine —
27
+ and hands them back.
28
+
29
+ A complete worker:
30
+
31
+ ```python
32
+ import braidpipe
33
+
34
+ def process(frame): # (H, W, 3) uint8, RGB — mutate it in place
35
+ frame[:, :, 0] //= 2 # your inference here
36
+
37
+ if __name__ == "__main__":
38
+ braidpipe.run(process)
39
+ ```
40
+
41
+ `run()` owns everything else: the handshake, the per-frame notification loop,
42
+ freeing the shared-memory slot, acking (including `"success": false` when your
43
+ code raises, so the stream falls back to passthrough instead of going dark),
44
+ and the transport switch — set `BRAIDPIPE_DAEMON=host:port` and the same worker
45
+ attaches over the network instead.
46
+
47
+ This package does not contain the daemon. Build it from the
48
+ [repository](https://github.com/emdiple/braidpipe), which also has the full
49
+ worker contract, examples, and deadline rules in `docs/workers.md`.
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ braidpipe/__init__.py
4
+ braidpipe/__main__.py
5
+ braidpipe/background.py
6
+ braidpipe/contract.py
7
+ braidpipe/py.typed
8
+ braidpipe/remote.py
9
+ braidpipe/runner.py
10
+ braidpipe/shm.py
11
+ braidpipe/worker.py
12
+ braidpipe.egg-info/PKG-INFO
13
+ braidpipe.egg-info/SOURCES.txt
14
+ braidpipe.egg-info/dependency_links.txt
15
+ braidpipe.egg-info/requires.txt
16
+ braidpipe.egg-info/top_level.txt
17
+ tests/test_runner.py
@@ -0,0 +1,4 @@
1
+ numpy>=1.24
2
+
3
+ [examples]
4
+ opencv-python
@@ -0,0 +1 @@
1
+ braidpipe
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "braidpipe"
7
+ version = "0.3.0"
8
+ description = "Worker SDK for braidpipe, the never-dark AI video middleware: write a function that mutates a frame, the SDK runs the IPC loop."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "braidpipe contributors" }]
13
+ keywords = ["video", "streaming", "gstreamer", "computer-vision", "shared-memory"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Operating System :: MacOS",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Multimedia :: Video",
21
+ ]
22
+ dependencies = ["numpy>=1.24"]
23
+
24
+ [project.optional-dependencies]
25
+ examples = ["opencv-python"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/emdiple/braidpipe"
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["braidpipe*"]
32
+
33
+ [tool.setuptools.package-data]
34
+ braidpipe = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,249 @@
1
+ """SDK tests against a fake daemon: pure Python, no Rust or GStreamer needed.
2
+
3
+ Each test stands up the daemon side of one transport — the UDS handshake with a
4
+ real fd for shared memory, a UDP-hello/TCP-data pair for tcp-raw — and runs
5
+ `braidpipe.run()` against it, asserting the contract the Rust daemon relies on:
6
+ pixels mutated in place, slots freed, every frame acked, and an exception in
7
+ the handler reported as `"success": false` rather than a dead worker.
8
+
9
+ Run with: python3 -m unittest discover python/tests
10
+ """
11
+
12
+ import array
13
+ import json
14
+ import os
15
+ import socket
16
+ import struct
17
+ import sys
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ import unittest
22
+
23
+ import numpy as np
24
+
25
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
+
27
+ import braidpipe
28
+ from braidpipe.runner import _wants_context
29
+ from braidpipe.shm import HEADER_FMT, HEADER_SIZE, SLOT_HEADER_FMT, SLOT_HEADER_SIZE
30
+
31
+ WIDTH, HEIGHT, CHANNELS, SLOT_COUNT = 8, 4, 3, 2
32
+ PAYLOAD = WIDTH * HEIGHT * CHANNELS
33
+ SLOT_SIZE = SLOT_HEADER_SIZE + PAYLOAD
34
+
35
+ TIMEOUT = 5.0
36
+
37
+
38
+ def make_segment():
39
+ """A real mmap-able fd laid out exactly like the Rust daemon's segment."""
40
+ f = tempfile.TemporaryFile()
41
+ f.truncate(HEADER_SIZE + SLOT_COUNT * SLOT_SIZE)
42
+ f.seek(0)
43
+ f.write(struct.pack(HEADER_FMT, WIDTH, HEIGHT, CHANNELS, SLOT_COUNT, SLOT_SIZE, b""))
44
+ f.flush()
45
+ return f
46
+
47
+
48
+ class ShmTransportTest(unittest.TestCase):
49
+ def test_frames_processed_freed_and_acked(self):
50
+ tmp = tempfile.mkdtemp()
51
+ rust_sock_path = os.path.join(tmp, "rust.sock")
52
+ python_sock_path = os.path.join(tmp, "python.sock")
53
+
54
+ daemon = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
55
+ daemon.bind(rust_sock_path)
56
+ daemon.settimeout(TIMEOUT)
57
+ self.addCleanup(daemon.close)
58
+
59
+ segment = make_segment()
60
+ self.addCleanup(segment.close)
61
+ view = np.memmap(segment, dtype=np.uint8, mode="r+")
62
+
63
+ seen = []
64
+
65
+ def process(frame, ctx):
66
+ seen.append((ctx.frame_id, ctx.timestamp_us, ctx.transport))
67
+ if ctx.frame_id == 2:
68
+ raise RuntimeError("deliberate failure")
69
+ frame += 1
70
+
71
+ worker = threading.Thread(
72
+ target=braidpipe.run,
73
+ kwargs=dict(
74
+ process=process,
75
+ rust_sock=rust_sock_path,
76
+ python_sock=python_sock_path,
77
+ name="test",
78
+ ),
79
+ daemon=True, # left blocked in recvfrom once the test is over
80
+ )
81
+ worker.start()
82
+
83
+ # Handshake: the worker's hello is answered with the segment's fd.
84
+ # (sendmsg directly: socket.send_fds does not forward `address`.)
85
+ _, worker_addr = daemon.recvfrom(512)
86
+ daemon.sendmsg(
87
+ [b'{"type":"config","transport":"shm","contract":1}'],
88
+ [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array("i", [segment.fileno()]))],
89
+ 0,
90
+ worker_addr,
91
+ )
92
+
93
+ def send_frame(frame_id, slot, fill):
94
+ offset = HEADER_SIZE + slot * SLOT_SIZE
95
+ view[offset : offset + SLOT_HEADER_SIZE] = np.frombuffer(
96
+ struct.pack(SLOT_HEADER_FMT, 1, frame_id, 1_000_000 + frame_id),
97
+ dtype=np.uint8,
98
+ )
99
+ view[offset + SLOT_HEADER_SIZE : offset + SLOT_SIZE] = fill
100
+ daemon.sendto(
101
+ json.dumps({"frame_id": frame_id, "slot_index": slot}).encode(),
102
+ python_sock_path,
103
+ )
104
+ ack = json.loads(daemon.recvfrom(512)[0])
105
+ return offset, ack
106
+
107
+ # A control packet with no frame_id must be skipped, not crash the loop.
108
+ daemon.sendto(b'{"type":"noise"}', python_sock_path)
109
+
110
+ offset, ack = send_frame(1, 0, fill=10)
111
+ self.assertEqual(ack["frame_id"], 1)
112
+ self.assertTrue(ack["success"])
113
+ self.assertEqual(view[offset], 0) # slot freed
114
+ self.assertTrue((view[offset + SLOT_HEADER_SIZE : offset + SLOT_SIZE] == 11).all())
115
+
116
+ # A raising handler acks success=false and leaves the loop alive.
117
+ offset, ack = send_frame(2, 1, fill=20)
118
+ self.assertFalse(ack["success"])
119
+ self.assertEqual(view[offset], 0)
120
+ self.assertTrue((view[offset + SLOT_HEADER_SIZE : offset + SLOT_SIZE] == 20).all())
121
+
122
+ _, ack = send_frame(3, 0, fill=30)
123
+ self.assertTrue(ack["success"])
124
+
125
+ self.assertEqual(
126
+ seen,
127
+ [(1, 1_000_001, "shm"), (2, 1_000_002, "shm"), (3, 1_000_003, "shm")],
128
+ )
129
+
130
+
131
+ class TcpRawTransportTest(unittest.TestCase):
132
+ def test_frames_round_trip_and_clean_shutdown(self):
133
+ from braidpipe.remote import WIRE_HEADER_FMT, WIRE_HEADER_SIZE
134
+
135
+ udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
136
+ udp.bind(("127.0.0.1", 0))
137
+ udp.settimeout(TIMEOUT)
138
+ self.addCleanup(udp.close)
139
+
140
+ tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
141
+ tcp.bind(("127.0.0.1", 0))
142
+ tcp.listen(1)
143
+ tcp.settimeout(TIMEOUT)
144
+ self.addCleanup(tcp.close)
145
+
146
+ def process(frame):
147
+ frame += 1
148
+
149
+ worker = threading.Thread(
150
+ target=braidpipe.run,
151
+ kwargs=dict(
152
+ process=process,
153
+ daemon=f"127.0.0.1:{udp.getsockname()[1]}",
154
+ name="test",
155
+ ),
156
+ daemon=True, # a failed test must not hang the runner at exit
157
+ )
158
+ worker.start()
159
+
160
+ hello, worker_addr = udp.recvfrom(512)
161
+ self.assertIn("tcp-raw", json.loads(hello)["transports"])
162
+ udp.sendto(
163
+ json.dumps(
164
+ {
165
+ "type": "config",
166
+ "transport": "tcp-raw",
167
+ "contract": 1,
168
+ "data_port": tcp.getsockname()[1],
169
+ "width": WIDTH,
170
+ "height": HEIGHT,
171
+ "channels": CHANNELS,
172
+ }
173
+ ).encode(),
174
+ worker_addr,
175
+ )
176
+
177
+ conn, _ = tcp.accept()
178
+ conn.settimeout(TIMEOUT)
179
+ for frame_id in (1, 2):
180
+ conn.sendall(
181
+ struct.pack(WIRE_HEADER_FMT, frame_id, 5_000, PAYLOAD, 0, 0)
182
+ + bytes([frame_id * 10]) * PAYLOAD
183
+ )
184
+ reply = b""
185
+ while len(reply) < WIRE_HEADER_SIZE + PAYLOAD:
186
+ reply += conn.recv(4096)
187
+ reply_id, _, _, _, flags = struct.unpack(
188
+ WIRE_HEADER_FMT, reply[:WIRE_HEADER_SIZE]
189
+ )
190
+ self.assertEqual(reply_id, frame_id)
191
+ self.assertEqual(flags & 1, 1)
192
+ self.assertEqual(set(reply[WIRE_HEADER_SIZE:]), {frame_id * 10 + 1})
193
+
194
+ # Hanging up must end run() rather than strand the worker.
195
+ conn.close()
196
+ worker.join(TIMEOUT)
197
+ self.assertFalse(worker.is_alive())
198
+
199
+
200
+ class ContractTest(unittest.TestCase):
201
+ def test_matching_and_missing_versions_attach(self):
202
+ from braidpipe.contract import CONTRACT_VERSION, check_contract
203
+
204
+ check_contract({"contract": CONTRACT_VERSION}, "shm") # must not raise
205
+ check_contract({}, "shm") # pre-versioning daemon: warn, not refuse
206
+
207
+ def test_mismatch_refuses_loudly(self):
208
+ from braidpipe.contract import check_contract
209
+
210
+ with self.assertRaises(RuntimeError):
211
+ check_contract({"contract": 999}, "shm")
212
+
213
+ def test_remote_connect_refuses_mismatched_daemon(self):
214
+ udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
215
+ udp.bind(("127.0.0.1", 0))
216
+ udp.settimeout(TIMEOUT)
217
+ self.addCleanup(udp.close)
218
+
219
+ def answer_hello():
220
+ _, addr = udp.recvfrom(512)
221
+ udp.sendto(
222
+ b'{"type":"config","transport":"tcp-raw","data_port":1,'
223
+ b'"width":8,"height":4,"channels":3,"contract":999}',
224
+ addr,
225
+ )
226
+
227
+ threading.Thread(target=answer_hello, daemon=True).start()
228
+ with self.assertRaises(RuntimeError):
229
+ braidpipe.connect(f"127.0.0.1:{udp.getsockname()[1]}")
230
+
231
+
232
+ class SignatureTest(unittest.TestCase):
233
+ def test_handler_arity_detection(self):
234
+ self.assertFalse(_wants_context(lambda frame: None))
235
+ self.assertTrue(_wants_context(lambda frame, ctx: None))
236
+ self.assertTrue(_wants_context(lambda *args: None))
237
+
238
+ def test_worker_decorator_registers_handler(self):
239
+ @braidpipe.worker
240
+ def handler(frame):
241
+ pass
242
+
243
+ from braidpipe import runner
244
+
245
+ self.assertIs(runner._registered, handler)
246
+
247
+
248
+ if __name__ == "__main__":
249
+ unittest.main()