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.
- haybale_stream-0.0.1/.gitignore +28 -0
- haybale_stream-0.0.1/PKG-INFO +87 -0
- haybale_stream-0.0.1/README.md +66 -0
- haybale_stream-0.0.1/haybale_stream/__init__.py +23 -0
- haybale_stream-0.0.1/haybale_stream/backends/__init__.py +41 -0
- haybale_stream-0.0.1/haybale_stream/backends/base.py +215 -0
- haybale_stream-0.0.1/haybale_stream/backends/convert.py +31 -0
- haybale_stream-0.0.1/haybale_stream/backends/ndi.py +160 -0
- haybale_stream-0.0.1/haybale_stream/backends/omt.py +97 -0
- haybale_stream-0.0.1/haybale_stream/backends/sources.py +55 -0
- haybale_stream-0.0.1/haybale_stream/backends/spout.py +117 -0
- haybale_stream-0.0.1/haybale_stream/backends/syphon.py +142 -0
- haybale_stream-0.0.1/haybale_stream/haybale.toml +21 -0
- haybale_stream-0.0.1/haybale_stream/nodes/__init__.py +5 -0
- haybale_stream-0.0.1/haybale_stream/nodes/receive_frame.py +95 -0
- haybale_stream-0.0.1/haybale_stream/nodes/receive_frame_emit.py +176 -0
- haybale_stream-0.0.1/haybale_stream/nodes/send_frame.py +82 -0
- haybale_stream-0.0.1/haybale_stream/nodes/settings.py +66 -0
- haybale_stream-0.0.1/haybale_stream/py.typed +0 -0
- haybale_stream-0.0.1/pyproject.toml +43 -0
|
@@ -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)
|