haybale-stream 0.0.1__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,28 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ *.egg
11
+
12
+ # Virtual environments
13
+ .venv/
14
+ venv/
15
+ env/
16
+
17
+ # Test / type-check caches
18
+ .pytest_cache/
19
+ .ruff_cache/
20
+ .mypy_cache/
21
+ .tox/
22
+ .coverage
23
+ htmlcov/
24
+
25
+ # Editor / OS
26
+ .DS_Store
27
+ .idea/
28
+ .vscode/
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.5
2
+ Name: haybale-stream
3
+ Version: 0.0.1
4
+ Summary: Share video frames between applications over Syphon, Spout, NDI and OMT
5
+ Project-URL: Homepage, https://github.com/going-haywire/haybale-stream
6
+ Project-URL: Documentation, https://github.com/going-haywire/haybale-stream#readme
7
+ Project-URL: Issues, https://github.com/going-haywire/haybale-stream/issues
8
+ Project-URL: Source, https://github.com/going-haywire/haybale-stream
9
+ Author: maybites
10
+ License: MIT
11
+ Keywords: ndi,omt,spout,streaming,syphon,texture sharing,video
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: cyndilib>=0.1.3
14
+ Requires-Dist: cyomtlib>=0.1.2
15
+ Requires-Dist: haybale-image>=0.3.4
16
+ Requires-Dist: haywire-core>=0.3.0
17
+ Requires-Dist: numpy
18
+ Requires-Dist: spoutgl>=0.1.1; sys_platform == 'win32'
19
+ Requires-Dist: syphon-python>=0.2.0; sys_platform == 'darwin'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Haybale Stream
23
+
24
+ A haywire library that sends and receives video frames between applications over
25
+ [Syphon](https://syphon.github.io/) (macOS), [Spout](https://spout.zeal.co/) (Windows),
26
+ [NDI](https://ndi.video) and [OMT](https://www.openmediatransport.org) (Open Media Transport,
27
+ cross-platform).
28
+
29
+ Ideas and backend code come from [TextureSharing](https://github.com/maybites/TextureSharing)
30
+ (Spout for Blender).
31
+
32
+ | Backend | Linux | macOS | Windows |
33
+ |---------|:-----:|:-----:|:-------:|
34
+ | NDI | yes | yes | yes |
35
+ | OMT | yes | yes | yes |
36
+ | Syphon | | yes | |
37
+ | Spout | | | yes |
38
+
39
+ Frames travel as `IMAGE` values from `haybale-image`. See [docs/design.md](https://github.com/going-haywire/haybale-stream/blob/main/docs/design.md).
40
+
41
+ ## Nodes
42
+
43
+ | Node | What it does |
44
+ |------|--------------|
45
+ | Send Frame | Publishes an `IMAGE` under a name on NDI, OMT, Syphon or Spout. |
46
+ | Receive Frame | Fetches the newest frame from a named source as `IMAGE_RGBA`; exits through `Frame` or `No frame`. |
47
+ | Receive Frame Emit | Receives on its own thread and emits a callback per frame to connected Image Event nodes (from haybale-image) (like the visiongraph camera nodes). |
48
+
49
+ Status: NDI and OMT are tested on Linux. Syphon and Spout are implemented but not yet run
50
+ on macOS / Windows.
51
+
52
+ ## Subscribe
53
+
54
+ <!-- marketstall:share-url:start -->
55
+ <!-- marketstall:share-url:end -->
56
+
57
+ ## Getting started
58
+
59
+ ```sh
60
+ uv sync
61
+ uv run haywire
62
+ uv run pytest
63
+ ```
64
+
65
+ Develop from `../haywire-repo` (this package is symlinked into its `barn/`); see [CLAUDE.md](CLAUDE.md).
66
+
67
+ ## Share the library
68
+
69
+ Run from this repo's root (not from `../haywire-repo`):
70
+
71
+ ```shell
72
+ uv run --frozen haywire share --dry-run # first: report only, writes nothing
73
+ uv run --frozen haywire share --bump patch|minor|major # or an explicit X.Y.Z
74
+ ```
75
+
76
+ This bumps every `barn/*` library in lockstep, regenerates docs and `marketstall.toml`,
77
+ then commits, tags `v<version>` and pushes to `origin` itself. It needs a clean working tree
78
+ and the default branch checked out (`--frozen` keeps `uv.lock` from being re-locked; `git checkout uv.lock` first if it is dirty). The project uses `distribute = "pypi"`, so uploading the
79
+ package to PyPI is a separate step (see `publish-to-pypi.md` in the haywire docs).
80
+
81
+ ## Credits
82
+
83
+ Syphon: Tom Butterworth and Anton Marini. Spout: Lynn Jarvis. SpoutGL: Jason Lai.
84
+ syphon-python: Florian Bruggisser. cyndilib: Matthew Reid. OMT: the Open Media Transport project
85
+ (cyomtlib wrapper). NDI® is a registered trademark of Vizrt NDI AB; NDI runtime © Vizrt NDI AB, bundled in the cyndilib wheel under the NDI SDK license (http://ndi.link/ndisdk_license).
86
+
87
+ Martin Froehlich. Implementation for [haywire](https://github.com/going-haywire/haywire)
@@ -0,0 +1,66 @@
1
+ # Haybale Stream
2
+
3
+ A haywire library that sends and receives video frames between applications over
4
+ [Syphon](https://syphon.github.io/) (macOS), [Spout](https://spout.zeal.co/) (Windows),
5
+ [NDI](https://ndi.video) and [OMT](https://www.openmediatransport.org) (Open Media Transport,
6
+ cross-platform).
7
+
8
+ Ideas and backend code come from [TextureSharing](https://github.com/maybites/TextureSharing)
9
+ (Spout for Blender).
10
+
11
+ | Backend | Linux | macOS | Windows |
12
+ |---------|:-----:|:-----:|:-------:|
13
+ | NDI | yes | yes | yes |
14
+ | OMT | yes | yes | yes |
15
+ | Syphon | | yes | |
16
+ | Spout | | | yes |
17
+
18
+ Frames travel as `IMAGE` values from `haybale-image`. See [docs/design.md](https://github.com/going-haywire/haybale-stream/blob/main/docs/design.md).
19
+
20
+ ## Nodes
21
+
22
+ | Node | What it does |
23
+ |------|--------------|
24
+ | Send Frame | Publishes an `IMAGE` under a name on NDI, OMT, Syphon or Spout. |
25
+ | Receive Frame | Fetches the newest frame from a named source as `IMAGE_RGBA`; exits through `Frame` or `No frame`. |
26
+ | Receive Frame Emit | Receives on its own thread and emits a callback per frame to connected Image Event nodes (from haybale-image) (like the visiongraph camera nodes). |
27
+
28
+ Status: NDI and OMT are tested on Linux. Syphon and Spout are implemented but not yet run
29
+ on macOS / Windows.
30
+
31
+ ## Subscribe
32
+
33
+ <!-- marketstall:share-url:start -->
34
+ <!-- marketstall:share-url:end -->
35
+
36
+ ## Getting started
37
+
38
+ ```sh
39
+ uv sync
40
+ uv run haywire
41
+ uv run pytest
42
+ ```
43
+
44
+ Develop from `../haywire-repo` (this package is symlinked into its `barn/`); see [CLAUDE.md](CLAUDE.md).
45
+
46
+ ## Share the library
47
+
48
+ Run from this repo's root (not from `../haywire-repo`):
49
+
50
+ ```shell
51
+ uv run --frozen haywire share --dry-run # first: report only, writes nothing
52
+ uv run --frozen haywire share --bump patch|minor|major # or an explicit X.Y.Z
53
+ ```
54
+
55
+ This bumps every `barn/*` library in lockstep, regenerates docs and `marketstall.toml`,
56
+ then commits, tags `v<version>` and pushes to `origin` itself. It needs a clean working tree
57
+ and the default branch checked out (`--frozen` keeps `uv.lock` from being re-locked; `git checkout uv.lock` first if it is dirty). The project uses `distribute = "pypi"`, so uploading the
58
+ package to PyPI is a separate step (see `publish-to-pypi.md` in the haywire docs).
59
+
60
+ ## Credits
61
+
62
+ Syphon: Tom Butterworth and Anton Marini. Spout: Lynn Jarvis. SpoutGL: Jason Lai.
63
+ syphon-python: Florian Bruggisser. cyndilib: Matthew Reid. OMT: the Open Media Transport project
64
+ (cyomtlib wrapper). NDI® is a registered trademark of Vizrt NDI AB; NDI runtime © Vizrt NDI AB, bundled in the cyndilib wheel under the NDI SDK license (http://ndi.link/ndisdk_license).
65
+
66
+ Martin Froehlich. Implementation for [haywire](https://github.com/going-haywire/haywire)
@@ -0,0 +1,23 @@
1
+ """Haybale Stream: send and receive video frames over NDI, OMT, Syphon and Spout."""
2
+
3
+ from pathlib import Path
4
+
5
+ from haywire.core.library.base import BaseLibrary
6
+ from haywire.core.library.decorator import library
7
+ from haywire.core.node.registry import NodeRegistry
8
+
9
+
10
+ @library(file_watcher=False)
11
+ class Library(BaseLibrary):
12
+ """Stream library: frame-sharing nodes over the backends in ``haybale_stream.backends``."""
13
+
14
+ def register_components(self):
15
+ """Register the node folder."""
16
+ base_path = Path(__file__).parent
17
+ self.add_folder_to_registry(folder_path=str(base_path / "nodes"), registry_cls=NodeRegistry)
18
+
19
+ def validate(self) -> bool:
20
+ return True
21
+
22
+
23
+ __all__ = ["Library"]
@@ -0,0 +1,41 @@
1
+ """Frame-sharing backends behind one sender/receiver contract. See ``base``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import Backend, BackendUnavailable, ClockedSender, Frame, Receiver, Sender
6
+ from .ndi import NDIBackend
7
+ from .omt import OMTBackend
8
+ from .spout import SpoutBackend
9
+ from .syphon import SyphonBackend
10
+
11
+ #: Every backend by key, in menu order. Importing this is safe on any platform: native
12
+ #: dependencies are imported only when a sender, receiver or discovery is actually used.
13
+ BACKENDS: dict[str, type[Backend]] = {
14
+ b.key: b for b in (NDIBackend, OMTBackend, SyphonBackend, SpoutBackend)
15
+ }
16
+
17
+
18
+ def get_backend(key: str) -> type[Backend]:
19
+ """Return the backend registered as ``key`` (case-insensitive)."""
20
+ try:
21
+ return BACKENDS[key.lower()]
22
+ except KeyError:
23
+ raise KeyError(f"unknown backend {key!r}; known: {', '.join(BACKENDS)}") from None
24
+
25
+
26
+ def available_backends() -> list[type[Backend]]:
27
+ """The backends that can run on this machine right now."""
28
+ return [b for b in BACKENDS.values() if b.is_available()]
29
+
30
+
31
+ __all__ = [
32
+ "BACKENDS",
33
+ "Backend",
34
+ "BackendUnavailable",
35
+ "ClockedSender",
36
+ "Frame",
37
+ "Receiver",
38
+ "Sender",
39
+ "available_backends",
40
+ "get_backend",
41
+ ]
@@ -0,0 +1,215 @@
1
+ """The backend-neutral sender/receiver contract.
2
+
3
+ A backend moves **packed 8-bit 4-channel frames** (``H×W×4`` uint8) between processes.
4
+ It knows nothing about haywire or ``ImageValue``: ``convert.py`` bridges the two, so a
5
+ backend module imports only its own native dependency, and only when it is used.
6
+
7
+ Channel order is a property of the backend (``Backend.channel_order``): NDI and Spout
8
+ take RGBA, OMT and Syphon's raw readback are BGRA. The bridge converts once, on the
9
+ way in or out, so a backend never swaps channels itself.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import threading
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass
18
+ from typing import ClassVar, Self
19
+
20
+ import numpy as np
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ class BackendUnavailable(RuntimeError):
26
+ """The backend cannot run here: wrong platform, or its native dependency is missing."""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Frame:
31
+ """One received frame: ``pixels`` is ``H×W×4`` uint8 in the backend's channel order."""
32
+
33
+ pixels: np.ndarray
34
+ timestamp: float | None = None
35
+
36
+ @property
37
+ def width(self) -> int:
38
+ return int(self.pixels.shape[1])
39
+
40
+ @property
41
+ def height(self) -> int:
42
+ return int(self.pixels.shape[0])
43
+
44
+
45
+ class Sender(ABC):
46
+ """Publishes frames under a name that receivers discover."""
47
+
48
+ def __init__(self, name: str) -> None:
49
+ self.name = name
50
+
51
+ @abstractmethod
52
+ def open(self) -> None:
53
+ """Start publishing. Raises ``BackendUnavailable`` if the backend cannot run."""
54
+
55
+ @abstractmethod
56
+ def send(self, pixels: np.ndarray, frame_rate: float = 30.0) -> None:
57
+ """Publish one ``H×W×4`` uint8 frame. May return before the frame is on the wire."""
58
+
59
+ @abstractmethod
60
+ def close(self) -> None:
61
+ """Stop publishing and release native resources. Safe to call twice."""
62
+
63
+ def __enter__(self) -> Self:
64
+ self.open()
65
+ return self
66
+
67
+ def __exit__(self, *exc: object) -> None:
68
+ self.close()
69
+
70
+
71
+ class ClockedSender(Sender):
72
+ """A sender whose native send call blocks for one frame period (NDI, OMT).
73
+
74
+ The caller's thread must not block on the network clock, so ``send`` only stores the
75
+ newest frame; a worker thread writes it. Frames sent faster than the clock are
76
+ dropped (latest wins). With ``steady_stream`` the last frame is resent while the
77
+ caller is idle, which keeps receivers from showing "no signal" on a still image.
78
+ """
79
+
80
+ def __init__(self, name: str, steady_stream: bool = False) -> None:
81
+ super().__init__(name)
82
+ self.steady_stream = steady_stream
83
+ self._latest: tuple[np.ndarray, float] | None = None
84
+ self._lock = threading.Lock()
85
+ self._new = threading.Event()
86
+ self._stop = threading.Event()
87
+ self._thread: threading.Thread | None = None
88
+ self.frames_written = 0
89
+
90
+ @abstractmethod
91
+ def _open_native(self) -> None: ...
92
+
93
+ @abstractmethod
94
+ def _write(self, pixels: np.ndarray, frame_rate: float) -> None:
95
+ """Blocking native write; runs on the worker thread only."""
96
+
97
+ @abstractmethod
98
+ def _close_native(self) -> None: ...
99
+
100
+ def open(self) -> None:
101
+ self._open_native()
102
+ self._stop.clear()
103
+ self._thread = threading.Thread(
104
+ target=self._run, name=f"{type(self).__name__}-{self.name}", daemon=True
105
+ )
106
+ self._thread.start()
107
+
108
+ def send(self, pixels: np.ndarray, frame_rate: float = 30.0) -> None:
109
+ # Own the bytes: the caller's array may be a read-only view of an image other nodes
110
+ # still read, and native writers want a writable buffer. np.array always copies.
111
+ frame = np.array(pixels, dtype=np.uint8, order="C")
112
+ with self._lock:
113
+ self._latest = (frame, frame_rate)
114
+ self._new.set()
115
+
116
+ def _run(self) -> None:
117
+ while not self._stop.is_set():
118
+ if (not self.steady_stream or self._latest is None) and not self._new.wait(0.1):
119
+ continue
120
+ self._new.clear()
121
+ with self._lock:
122
+ latest = self._latest
123
+ if latest is None or self._stop.is_set():
124
+ continue
125
+ try:
126
+ self._write(*latest)
127
+ self.frames_written += 1
128
+ except Exception:
129
+ log.exception("%s send failed", type(self).__name__)
130
+ self._stop.wait(0.1)
131
+
132
+ def close(self) -> None:
133
+ self._stop.set()
134
+ self._new.set()
135
+ thread, self._thread = self._thread, None
136
+ if thread is not None:
137
+ thread.join(timeout=5.0)
138
+ self._close_native()
139
+
140
+
141
+ class Receiver(ABC):
142
+ """Receives frames from one named source."""
143
+
144
+ def __init__(self, source: str) -> None:
145
+ self.source = source
146
+
147
+ @abstractmethod
148
+ def open(self, timeout: float = 5.0) -> None:
149
+ """Connect, waiting up to ``timeout`` s for the source to appear.
150
+
151
+ Raises ``BackendUnavailable`` or ``LookupError`` (source not found).
152
+ """
153
+
154
+ @abstractmethod
155
+ def poll(self, timeout: float = 0.0) -> Frame | None:
156
+ """Return the newest frame not yet returned, or ``None`` if there is none (waits up to ``timeout`` s)."""
157
+
158
+ @abstractmethod
159
+ def close(self) -> None:
160
+ """Disconnect and release native resources. Safe to call twice."""
161
+
162
+ def __enter__(self) -> Self:
163
+ self.open()
164
+ return self
165
+
166
+ def __exit__(self, *exc: object) -> None:
167
+ self.close()
168
+
169
+
170
+ class Backend(ABC):
171
+ """One transport: knows whether it can run here, lists sources, builds senders and receivers."""
172
+
173
+ key: ClassVar[str]
174
+ label: ClassVar[str]
175
+ #: ``sys.platform`` prefixes the backend runs on.
176
+ platforms: ClassVar[tuple[str, ...]] = ("linux", "darwin", "win32")
177
+ #: Channel order of the frames this backend sends and receives: ``"rgba"`` or ``"bgra"``.
178
+ channel_order: ClassVar[str] = "rgba"
179
+ #: Whether a sender can be told to resend its last frame (see ``ClockedSender``).
180
+ supports_steady_stream: ClassVar[bool] = False
181
+
182
+ @classmethod
183
+ def supported_here(cls) -> bool:
184
+ import sys
185
+
186
+ return sys.platform.startswith(cls.platforms)
187
+
188
+ @classmethod
189
+ def unavailable_reason(cls) -> str | None:
190
+ """Why this backend cannot run here, or ``None`` when it can."""
191
+ if not cls.supported_here():
192
+ return f"{cls.label} is not available on this platform (needs {'/'.join(cls.platforms)})"
193
+ return cls._check_dependency()
194
+
195
+ @classmethod
196
+ def is_available(cls) -> bool:
197
+ return cls.unavailable_reason() is None
198
+
199
+ @classmethod
200
+ @abstractmethod
201
+ def _check_dependency(cls) -> str | None:
202
+ """Import the native dependency; return a message if it is missing, else ``None``."""
203
+
204
+ @classmethod
205
+ @abstractmethod
206
+ def discover(cls, wait: float = 0.0) -> list[str]:
207
+ """Names of the sources currently visible. ``wait`` seconds to let discovery settle."""
208
+
209
+ @classmethod
210
+ @abstractmethod
211
+ def create_sender(cls, name: str, *, steady_stream: bool = False) -> Sender: ...
212
+
213
+ @classmethod
214
+ @abstractmethod
215
+ def create_receiver(cls, source: str) -> Receiver: ...
@@ -0,0 +1,31 @@
1
+ """Bridge between ``ImageValue`` and the packed 4-channel frames backends move."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from haybale_image.value import ChannelOrder, ImageValue, Layout, convert_layout
7
+
8
+ from .base import Backend, Frame
9
+
10
+
11
+ def image_to_pixels(image: ImageValue, order: str) -> np.ndarray:
12
+ """Return ``image`` as a C-contiguous ``H×W×4`` uint8 array in ``order`` (``"rgba"`` / ``"bgra"``).
13
+
14
+ Gray and RGB images get an opaque alpha. The array may be shared with ``image``'s
15
+ cache and is read-only: copy before writing.
16
+ """
17
+ rgba = convert_layout(image, Layout.RGBA)
18
+ channel = ChannelOrder.BGR if order == "bgra" else ChannelOrder.RGB
19
+ return np.ascontiguousarray(rgba.array(np.uint8, channel))
20
+
21
+
22
+ def pixels_to_image(pixels: np.ndarray, order: str) -> ImageValue:
23
+ """Wrap an ``H×W×4`` uint8 frame in ``order`` as an RGBA ``ImageValue`` (no copy)."""
24
+ if pixels.ndim != 3 or pixels.shape[2] != 4 or pixels.dtype != np.uint8:
25
+ raise ValueError(f"expected an H×W×4 uint8 frame, got {pixels.dtype} {pixels.shape}")
26
+ channel = ChannelOrder.BGR if order == "bgra" else ChannelOrder.RGB
27
+ return ImageValue.from_array(pixels, order=channel)
28
+
29
+
30
+ def frame_to_image(frame: Frame, backend: type[Backend]) -> ImageValue:
31
+ return pixels_to_image(frame.pixels, backend.channel_order)
@@ -0,0 +1,160 @@
1
+ """NDI backend, on cyndilib (wheels bundle the NDI runtime on Linux, macOS and Windows)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from fractions import Fraction
7
+
8
+ import numpy as np
9
+
10
+ from .base import Backend, BackendUnavailable, ClockedSender, Frame, Receiver, Sender
11
+
12
+
13
+ class NDISender(ClockedSender):
14
+ def __init__(self, name: str, steady_stream: bool = False) -> None:
15
+ super().__init__(name, steady_stream)
16
+ self._sender = None
17
+ self._size: tuple[int, int] | None = None
18
+ self._rate: Fraction | None = None
19
+
20
+ def _open_native(self) -> None:
21
+ from cyndilib.sender import Sender as NativeSender
22
+
23
+ self._sender = NativeSender(self.name)
24
+
25
+ def _configure(self, width: int, height: int, frame_rate: float) -> None:
26
+ """(Re)build the video frame; NDI can only swap it while the sender is closed."""
27
+ from cyndilib.video_frame import VideoSendFrame
28
+ from cyndilib.wrapper.ndi_structs import FourCC
29
+
30
+ sender = self._sender
31
+ if self._size is not None:
32
+ sender.close()
33
+ frame = VideoSendFrame()
34
+ frame.set_fourcc(FourCC.RGBA)
35
+ frame.set_resolution(width, height)
36
+ rate = Fraction(frame_rate).limit_denominator(1001)
37
+ frame.set_frame_rate(rate)
38
+ sender.set_video_frame(frame)
39
+ sender.open()
40
+ self._size, self._rate = (width, height), rate
41
+
42
+ def _write(self, pixels: np.ndarray, frame_rate: float) -> None:
43
+ height, width = pixels.shape[:2]
44
+ rate = Fraction(frame_rate).limit_denominator(1001)
45
+ if self._size != (width, height) or self._rate != rate:
46
+ self._configure(width, height, frame_rate)
47
+ self._sender.write_video(pixels.reshape(-1))
48
+
49
+ def _close_native(self) -> None:
50
+ sender, self._sender = self._sender, None
51
+ if sender is not None and self._size is not None:
52
+ sender.close()
53
+ self._size = None
54
+
55
+
56
+ class NDIReceiver(Receiver):
57
+ def __init__(self, source: str) -> None:
58
+ super().__init__(source)
59
+ self._finder = None
60
+ self._receiver = None
61
+ self._video = None
62
+ self._last_ts: float | None = None
63
+
64
+ def open(self, timeout: float = 5.0) -> None:
65
+ from cyndilib.finder import Finder
66
+ from cyndilib.receiver import Receiver as NativeReceiver
67
+ from cyndilib.video_frame import VideoFrameSync
68
+ from cyndilib.wrapper.ndi_recv import RecvBandwidth, RecvColorFormat
69
+
70
+ finder = Finder()
71
+ finder.open()
72
+ try:
73
+ deadline = time.monotonic() + timeout
74
+ found = None
75
+ while found is None:
76
+ finder.wait_for_sources(0.5)
77
+ found = next((s for s in finder if s.name == self.source), None)
78
+ if time.monotonic() >= deadline:
79
+ break
80
+ if found is None:
81
+ raise LookupError(f"NDI source {self.source!r} not found")
82
+ receiver = NativeReceiver(
83
+ color_format=RecvColorFormat.RGBX_RGBA, bandwidth=RecvBandwidth.highest
84
+ )
85
+ video = VideoFrameSync()
86
+ receiver.frame_sync.set_video_frame(video)
87
+ receiver.set_source(found)
88
+ except BaseException:
89
+ finder.close()
90
+ raise
91
+ self._finder, self._receiver, self._video = finder, receiver, video
92
+
93
+ def poll(self, timeout: float = 0.0) -> Frame | None:
94
+ if self._receiver is None:
95
+ return None
96
+ deadline = time.monotonic() + timeout
97
+ while True:
98
+ # frame_sync repeats the last frame; the timestamp tells a new one from a repeat.
99
+ self._receiver.frame_sync.capture_video()
100
+ video = self._video
101
+ if video.get_data_size() > 0:
102
+ ts = video.get_timestamp_posix()
103
+ if ts != self._last_ts:
104
+ self._last_ts = ts
105
+ width, height = video.get_resolution()
106
+ pixels = np.array(video.get_array(), dtype=np.uint8).reshape(height, width, 4)
107
+ return Frame(pixels, ts)
108
+ if time.monotonic() >= deadline:
109
+ return None
110
+ time.sleep(0.005)
111
+
112
+ def close(self) -> None:
113
+ receiver, self._receiver = self._receiver, None
114
+ finder, self._finder = self._finder, None
115
+ if receiver is not None:
116
+ receiver.disconnect()
117
+ if finder is not None:
118
+ finder.close()
119
+
120
+
121
+ class NDIBackend(Backend):
122
+ key = "ndi"
123
+ label = "NDI"
124
+ channel_order = "rgba"
125
+ supports_steady_stream = True
126
+
127
+ @classmethod
128
+ def _check_dependency(cls) -> str | None:
129
+ try:
130
+ import cyndilib # noqa: F401
131
+ except ImportError as exc:
132
+ return f"NDI needs the 'cyndilib' package ({exc})"
133
+ return None
134
+
135
+ @classmethod
136
+ def discover(cls, wait: float = 0.0) -> list[str]:
137
+ if (reason := cls.unavailable_reason()) is not None:
138
+ raise BackendUnavailable(reason)
139
+ from cyndilib.finder import Finder
140
+
141
+ finder = Finder()
142
+ finder.open()
143
+ try:
144
+ if wait > 0:
145
+ finder.wait_for_sources(wait)
146
+ return sorted(s.name for s in finder)
147
+ finally:
148
+ finder.close()
149
+
150
+ @classmethod
151
+ def create_sender(cls, name: str, *, steady_stream: bool = False) -> Sender:
152
+ if (reason := cls.unavailable_reason()) is not None:
153
+ raise BackendUnavailable(reason)
154
+ return NDISender(name, steady_stream)
155
+
156
+ @classmethod
157
+ def create_receiver(cls, source: str) -> Receiver:
158
+ if (reason := cls.unavailable_reason()) is not None:
159
+ raise BackendUnavailable(reason)
160
+ return NDIReceiver(source)