pyst2110 0.9.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.
- pyst2110-0.9.1/.gitignore +15 -0
- pyst2110-0.9.1/LICENSE +21 -0
- pyst2110-0.9.1/PKG-INFO +149 -0
- pyst2110-0.9.1/README.md +133 -0
- pyst2110-0.9.1/pyproject.toml +94 -0
- pyst2110-0.9.1/src/pyst2110/__init__.py +113 -0
- pyst2110-0.9.1/src/pyst2110/_chunk.py +146 -0
- pyst2110-0.9.1/src/pyst2110/_layout.py +85 -0
- pyst2110-0.9.1/src/pyst2110/framing.py +352 -0
- pyst2110-0.9.1/src/pyst2110/geometry.py +240 -0
- pyst2110-0.9.1/src/pyst2110/payload.py +556 -0
- pyst2110-0.9.1/src/pyst2110/py.typed +0 -0
- pyst2110-0.9.1/src/pyst2110/redundancy.py +139 -0
- pyst2110-0.9.1/src/pyst2110/rtp.py +245 -0
- pyst2110-0.9.1/src/pyst2110/sdp.py +1052 -0
- pyst2110-0.9.1/src/pyst2110/timing.py +844 -0
- pyst2110-0.9.1/src/pyst2110/transmit.py +329 -0
- pyst2110-0.9.1/tests/captures/convertip-2022-7-outage.npz +0 -0
- pyst2110-0.9.1/tests/conftest.py +44 -0
- pyst2110-0.9.1/tests/test_framing.py +445 -0
- pyst2110-0.9.1/tests/test_geometry.py +322 -0
- pyst2110-0.9.1/tests/test_path_equality.py +495 -0
- pyst2110-0.9.1/tests/test_payload_header.py +887 -0
- pyst2110-0.9.1/tests/test_receive_slice.py +354 -0
- pyst2110-0.9.1/tests/test_redundancy.py +186 -0
- pyst2110-0.9.1/tests/test_rtp.py +392 -0
- pyst2110-0.9.1/tests/test_sdp.py +1182 -0
- pyst2110-0.9.1/tests/test_timing.py +786 -0
- pyst2110-0.9.1/tests/test_transmit.py +431 -0
- pyst2110-0.9.1/tests/test_transmit_slice.py +263 -0
pyst2110-0.9.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fuse Technical Group
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pyst2110-0.9.1/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyst2110
|
|
3
|
+
Version: 0.9.1
|
|
4
|
+
Summary: SMPTE ST 2110 protocol: RTP and RFC 4175 headers, geometry, SDP
|
|
5
|
+
Project-URL: Repository, https://github.com/Fuse-Technical-Group/pyst2110
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Topic :: Multimedia :: Video
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: numpy>=2
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# pyst2110
|
|
18
|
+
|
|
19
|
+
SMPTE ST 2110 protocol for Python: RTP and RFC 4175 headers read and
|
|
20
|
+
written, frame boundaries, sequence-loss accounting, pgroup geometry, and
|
|
21
|
+
SDP.
|
|
22
|
+
|
|
23
|
+
> [REQUIREMENTS.md](REQUIREMENTS.md) — the problem.
|
|
24
|
+
> [SPEC.md](SPEC.md) — the design and its rationale.
|
|
25
|
+
> [ROADMAP.md](ROADMAP.md) — what is not built yet.
|
|
26
|
+
|
|
27
|
+
| Layer | Owns |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| A transport binding (a NIC binding, a socket, a capture file) | Moving bytes |
|
|
30
|
+
| **pyst2110** | RTP + RFC 4175 headers, geometry, SDP, ST 2110-21 timing |
|
|
31
|
+
| The consuming runtime | Pixels — packing, unpacking, colour |
|
|
32
|
+
|
|
33
|
+
The layer between a transport and a raster: it says what packet headers
|
|
34
|
+
mean and where their payloads belong, and never moves a pixel itself.
|
|
35
|
+
numpy is the only runtime dependency — no vendor SDK, no transport
|
|
36
|
+
binding, no NIC and no licence, so it runs anywhere CI does.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
A git dependency pinned by tag. Publication to PyPI waits for a consumer
|
|
41
|
+
depending on a released version (§road:pypi):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv add "pyst2110 @ git+https://github.com/Fuse-Technical-Group/pyst2110@v0.2.0"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pyst2110 import parse_sdp, parse_video_format
|
|
51
|
+
|
|
52
|
+
offer = open("flow.sdp").read()
|
|
53
|
+
flow = parse_sdp(offer) # destination address, port, source filter
|
|
54
|
+
video = parse_video_format(offer) # width, height, rate, sampling, depth
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Parsing is vectorized over whole chunks: every function takes a
|
|
58
|
+
two-dimensional `uint8` array, one row per packet, and returns one array
|
|
59
|
+
per field — the shape a header-data-split receiver already hands out.
|
|
60
|
+
|
|
61
|
+
Header fields are reported as the wire declared them, this being a parse
|
|
62
|
+
and not a filter. A packet is free to name a row outside the image, so
|
|
63
|
+
`fits_raster` masks the descriptors that name a place inside the flow's
|
|
64
|
+
raster and a consumer places only those:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
fits = fits_raster(video, payload.line, payload.offset_samples)
|
|
68
|
+
starts = raster_offset(video, payload.line[fits], payload.offset_samples[fits])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Sending is the same shape in reverse. A frame's headers are built once
|
|
72
|
+
for a format and payload size, then stamped per frame with the only two
|
|
73
|
+
fields that move — the sequence numbers and the media timestamp:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from pyst2110 import FrameHeaders, choose_payload_size, format_sdp, max_payload_size
|
|
77
|
+
|
|
78
|
+
payload_size = choose_payload_size(video, max_payload_size(video))
|
|
79
|
+
frame = FrameHeaders(video, payload_size, ssrc=0x1234ABCD)
|
|
80
|
+
for index in range(frames):
|
|
81
|
+
headers = frame.stamp(index) # (packets, 20) uint8, one row per packet
|
|
82
|
+
... # send each header with frame.frame_offset_octets
|
|
83
|
+
offer = format_sdp(flow, video, session_name="my sender") # what was sent
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The offer carries the media type parameters ST 2110-20 section 7.2 and
|
|
87
|
+
ST 2110-21 section 8.1 require of a sender. `TP` describes the pacing,
|
|
88
|
+
which is the transport's and not this library's, so `sender_type=` is
|
|
89
|
+
the caller's to set — it defaults to `2110TPN`. A multicast `flow` names
|
|
90
|
+
its sender or passes `any_source=True`; see §spec:sdp for why that is a
|
|
91
|
+
choice rather than a default.
|
|
92
|
+
|
|
93
|
+
An ST 2022-7 redundant pair is one offer and not two. `format_dup_sdp`
|
|
94
|
+
writes RFC 7104's grouping — a session-level `a=group:DUP` over two
|
|
95
|
+
`m=video` blocks, each with its own address and sender — and
|
|
96
|
+
`parse_dup_sdp` reads both legs back, in the order the group names them:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from pyst2110 import format_dup_sdp, parse_dup_sdp
|
|
100
|
+
|
|
101
|
+
offer = format_dup_sdp(red, blue, video, session_name="my sender")
|
|
102
|
+
first, second = parse_dup_sdp(offer)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A document whose `DUP` tags and media blocks disagree is refused rather
|
|
106
|
+
than read as a single-leg offer, and `parse_sdp` refuses a grouped offer
|
|
107
|
+
for the same reason: a sender that emitted one leg where two were meant
|
|
108
|
+
sends unprotected essence and reports success.
|
|
109
|
+
|
|
110
|
+
`session_name` defaults to the single space RFC 4566 section 5.3 prescribes
|
|
111
|
+
for a session with no meaningful name — and at least one transmit SDK refuses
|
|
112
|
+
it, so name the session where a sender will read the offer back. NVIDIA
|
|
113
|
+
Rivermax reports `'x=<token>' format not found` and fails stream creation,
|
|
114
|
+
accepting the same document once it is named.
|
|
115
|
+
|
|
116
|
+
`frame_offset_octets` says which octets of the frame buffer each packet
|
|
117
|
+
carries. Moving them is the consumer's, as on the receive side.
|
|
118
|
+
|
|
119
|
+
`stamp` hands back the same array every time, restamped in place — that
|
|
120
|
+
is what keeps the loop above from allocating a frame of headers per
|
|
121
|
+
frame. So `headers` is only valid until the next `stamp`: a caller
|
|
122
|
+
queueing two frames at once copies the first.
|
|
123
|
+
|
|
124
|
+
## API
|
|
125
|
+
|
|
126
|
+
Everything is re-exported from the top-level package, and the
|
|
127
|
+
docstrings there are the authority; `help(pyst2110)` is the index.
|
|
128
|
+
|
|
129
|
+
Both paths are built: SDP parsing and emit — the single-leg offer and the
|
|
130
|
+
ST 2022-7 pair — the RFC 3550 header parse, format geometry, sequence and
|
|
131
|
+
frame tracking, RFC 4175 payload descriptors, the transmit header block,
|
|
132
|
+
the ST 2022-7 reconstruction of one flow from two legs, and the ST 2110-21
|
|
133
|
+
timing model — read schedules, sender limits, and the two leaky buckets
|
|
134
|
+
that judge a capture's emission instants. What is not built is listed in
|
|
135
|
+
[ROADMAP.md](ROADMAP.md).
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
uv sync
|
|
141
|
+
bash tools/ci.sh
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`tools/ci.sh` is the gate CI runs — ruff, mypy, pytest.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT — see [LICENSE](LICENSE). The standards this implements are public and
|
|
149
|
+
an implementation of them should be too.
|
pyst2110-0.9.1/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# pyst2110
|
|
2
|
+
|
|
3
|
+
SMPTE ST 2110 protocol for Python: RTP and RFC 4175 headers read and
|
|
4
|
+
written, frame boundaries, sequence-loss accounting, pgroup geometry, and
|
|
5
|
+
SDP.
|
|
6
|
+
|
|
7
|
+
> [REQUIREMENTS.md](REQUIREMENTS.md) — the problem.
|
|
8
|
+
> [SPEC.md](SPEC.md) — the design and its rationale.
|
|
9
|
+
> [ROADMAP.md](ROADMAP.md) — what is not built yet.
|
|
10
|
+
|
|
11
|
+
| Layer | Owns |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| A transport binding (a NIC binding, a socket, a capture file) | Moving bytes |
|
|
14
|
+
| **pyst2110** | RTP + RFC 4175 headers, geometry, SDP, ST 2110-21 timing |
|
|
15
|
+
| The consuming runtime | Pixels — packing, unpacking, colour |
|
|
16
|
+
|
|
17
|
+
The layer between a transport and a raster: it says what packet headers
|
|
18
|
+
mean and where their payloads belong, and never moves a pixel itself.
|
|
19
|
+
numpy is the only runtime dependency — no vendor SDK, no transport
|
|
20
|
+
binding, no NIC and no licence, so it runs anywhere CI does.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
A git dependency pinned by tag. Publication to PyPI waits for a consumer
|
|
25
|
+
depending on a released version (§road:pypi):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv add "pyst2110 @ git+https://github.com/Fuse-Technical-Group/pyst2110@v0.2.0"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from pyst2110 import parse_sdp, parse_video_format
|
|
35
|
+
|
|
36
|
+
offer = open("flow.sdp").read()
|
|
37
|
+
flow = parse_sdp(offer) # destination address, port, source filter
|
|
38
|
+
video = parse_video_format(offer) # width, height, rate, sampling, depth
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Parsing is vectorized over whole chunks: every function takes a
|
|
42
|
+
two-dimensional `uint8` array, one row per packet, and returns one array
|
|
43
|
+
per field — the shape a header-data-split receiver already hands out.
|
|
44
|
+
|
|
45
|
+
Header fields are reported as the wire declared them, this being a parse
|
|
46
|
+
and not a filter. A packet is free to name a row outside the image, so
|
|
47
|
+
`fits_raster` masks the descriptors that name a place inside the flow's
|
|
48
|
+
raster and a consumer places only those:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
fits = fits_raster(video, payload.line, payload.offset_samples)
|
|
52
|
+
starts = raster_offset(video, payload.line[fits], payload.offset_samples[fits])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Sending is the same shape in reverse. A frame's headers are built once
|
|
56
|
+
for a format and payload size, then stamped per frame with the only two
|
|
57
|
+
fields that move — the sequence numbers and the media timestamp:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from pyst2110 import FrameHeaders, choose_payload_size, format_sdp, max_payload_size
|
|
61
|
+
|
|
62
|
+
payload_size = choose_payload_size(video, max_payload_size(video))
|
|
63
|
+
frame = FrameHeaders(video, payload_size, ssrc=0x1234ABCD)
|
|
64
|
+
for index in range(frames):
|
|
65
|
+
headers = frame.stamp(index) # (packets, 20) uint8, one row per packet
|
|
66
|
+
... # send each header with frame.frame_offset_octets
|
|
67
|
+
offer = format_sdp(flow, video, session_name="my sender") # what was sent
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The offer carries the media type parameters ST 2110-20 section 7.2 and
|
|
71
|
+
ST 2110-21 section 8.1 require of a sender. `TP` describes the pacing,
|
|
72
|
+
which is the transport's and not this library's, so `sender_type=` is
|
|
73
|
+
the caller's to set — it defaults to `2110TPN`. A multicast `flow` names
|
|
74
|
+
its sender or passes `any_source=True`; see §spec:sdp for why that is a
|
|
75
|
+
choice rather than a default.
|
|
76
|
+
|
|
77
|
+
An ST 2022-7 redundant pair is one offer and not two. `format_dup_sdp`
|
|
78
|
+
writes RFC 7104's grouping — a session-level `a=group:DUP` over two
|
|
79
|
+
`m=video` blocks, each with its own address and sender — and
|
|
80
|
+
`parse_dup_sdp` reads both legs back, in the order the group names them:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from pyst2110 import format_dup_sdp, parse_dup_sdp
|
|
84
|
+
|
|
85
|
+
offer = format_dup_sdp(red, blue, video, session_name="my sender")
|
|
86
|
+
first, second = parse_dup_sdp(offer)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A document whose `DUP` tags and media blocks disagree is refused rather
|
|
90
|
+
than read as a single-leg offer, and `parse_sdp` refuses a grouped offer
|
|
91
|
+
for the same reason: a sender that emitted one leg where two were meant
|
|
92
|
+
sends unprotected essence and reports success.
|
|
93
|
+
|
|
94
|
+
`session_name` defaults to the single space RFC 4566 section 5.3 prescribes
|
|
95
|
+
for a session with no meaningful name — and at least one transmit SDK refuses
|
|
96
|
+
it, so name the session where a sender will read the offer back. NVIDIA
|
|
97
|
+
Rivermax reports `'x=<token>' format not found` and fails stream creation,
|
|
98
|
+
accepting the same document once it is named.
|
|
99
|
+
|
|
100
|
+
`frame_offset_octets` says which octets of the frame buffer each packet
|
|
101
|
+
carries. Moving them is the consumer's, as on the receive side.
|
|
102
|
+
|
|
103
|
+
`stamp` hands back the same array every time, restamped in place — that
|
|
104
|
+
is what keeps the loop above from allocating a frame of headers per
|
|
105
|
+
frame. So `headers` is only valid until the next `stamp`: a caller
|
|
106
|
+
queueing two frames at once copies the first.
|
|
107
|
+
|
|
108
|
+
## API
|
|
109
|
+
|
|
110
|
+
Everything is re-exported from the top-level package, and the
|
|
111
|
+
docstrings there are the authority; `help(pyst2110)` is the index.
|
|
112
|
+
|
|
113
|
+
Both paths are built: SDP parsing and emit — the single-leg offer and the
|
|
114
|
+
ST 2022-7 pair — the RFC 3550 header parse, format geometry, sequence and
|
|
115
|
+
frame tracking, RFC 4175 payload descriptors, the transmit header block,
|
|
116
|
+
the ST 2022-7 reconstruction of one flow from two legs, and the ST 2110-21
|
|
117
|
+
timing model — read schedules, sender limits, and the two leaky buckets
|
|
118
|
+
that judge a capture's emission instants. What is not built is listed in
|
|
119
|
+
[ROADMAP.md](ROADMAP.md).
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv sync
|
|
125
|
+
bash tools/ci.sh
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`tools/ci.sh` is the gate CI runs — ruff, mypy, pytest.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT — see [LICENSE](LICENSE). The standards this implements are public and
|
|
133
|
+
an implementation of them should be too.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyst2110"
|
|
7
|
+
# Derived from the git tag, never authored here. A static version is one a
|
|
8
|
+
# release has to remember to rewrite, and this one's releases did not: every
|
|
9
|
+
# tag from v0.4.0 to v0.7.1 shipped `0.0.0`, so `importlib.metadata.version`
|
|
10
|
+
# told a consumer nothing about which parse it had loaded, and a consumer had
|
|
11
|
+
# to probe for a capability instead. See [tool.hatch.version].
|
|
12
|
+
dynamic = ["version"]
|
|
13
|
+
description = "SMPTE ST 2110 protocol: RTP and RFC 4175 headers, geometry, SDP"
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
requires-python = ">=3.12"
|
|
16
|
+
license = "MIT"
|
|
17
|
+
license-files = ["LICENSE"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Multimedia :: Video",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
# The only runtime dependency, and the interface itself: every parse takes
|
|
26
|
+
# and returns numpy arrays (SPEC §spec:interface-shape). No transport
|
|
27
|
+
# binding and no SDK appears here, by design (§spec:scope-boundary).
|
|
28
|
+
dependencies = ["numpy>=2"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Repository = "https://github.com/Fuse-Technical-Group/pyst2110"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8",
|
|
36
|
+
# Pinned, not floored: a newer ruff than CI runs is precisely the
|
|
37
|
+
# disagreement a pin exists to prevent.
|
|
38
|
+
"ruff==0.15.9",
|
|
39
|
+
# py.typed promises downstream consumers type information; this checks it.
|
|
40
|
+
"mypy>=1.13",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/pyst2110"]
|
|
45
|
+
|
|
46
|
+
# An allowlist, because hatchling's default sdist is every tracked file. That
|
|
47
|
+
# would publish the two vendored Vale binaries under .cache/ — 40 MB each —
|
|
48
|
+
# and the governance documents, none of which a PyPI consumer builds from.
|
|
49
|
+
# The tests ride along so the sdist can check itself.
|
|
50
|
+
[tool.hatch.build.targets.sdist]
|
|
51
|
+
include = ["/src/pyst2110", "/tests", "/README.md", "/LICENSE"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
# No markers and no deselection: nothing here needs a NIC, a licence or an
|
|
55
|
+
# SDK, so the default selection is the whole suite (SPEC §spec:testing).
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
# A fixture is a conforming offer unless its test says otherwise: a tolerated
|
|
58
|
+
# gap in one is an error here, and a test that means it catches the warning
|
|
59
|
+
# (SPEC §spec:sdp).
|
|
60
|
+
filterwarnings = ["error::pyst2110.sdp.SdpConformanceWarning"]
|
|
61
|
+
|
|
62
|
+
[tool.hatch.version]
|
|
63
|
+
source = "vcs"
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
target-version = "py312"
|
|
67
|
+
line-length = 88
|
|
68
|
+
src = ["src", "tests"]
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint]
|
|
71
|
+
select = [
|
|
72
|
+
"F", # pyflakes
|
|
73
|
+
"E", # pycodestyle errors
|
|
74
|
+
"W", # pycodestyle warnings
|
|
75
|
+
"I", # isort
|
|
76
|
+
"N", # pep8-naming
|
|
77
|
+
"UP", # pyupgrade
|
|
78
|
+
"A", # flake8-builtins
|
|
79
|
+
"B", # flake8-bugbear
|
|
80
|
+
"C4", # flake8-comprehensions
|
|
81
|
+
"PT", # flake8-pytest-style
|
|
82
|
+
"RET", # flake8-return
|
|
83
|
+
"SIM", # flake8-simplify
|
|
84
|
+
"S", # bandit
|
|
85
|
+
"RUF", # ruff-specific
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[tool.ruff.lint.per-file-ignores]
|
|
89
|
+
"tests/*" = ["S101"] # assert is what pytest is made of
|
|
90
|
+
|
|
91
|
+
[tool.mypy]
|
|
92
|
+
python_version = "3.12"
|
|
93
|
+
strict = true
|
|
94
|
+
files = ["src/pyst2110"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""SMPTE ST 2110 protocol: RTP and RFC 4175 headers, geometry, SDP.
|
|
2
|
+
|
|
3
|
+
The layer between a transport and a raster. A NIC binding, a socket or a
|
|
4
|
+
capture file supplies packets; this says what their headers mean and where
|
|
5
|
+
their payloads belong; the consumer moves the pixels (SPEC
|
|
6
|
+
§spec:scope-boundary).
|
|
7
|
+
|
|
8
|
+
Nothing here needs a NIC, a licence or a vendor SDK.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pyst2110.framing import FrameTracker as FrameTracker
|
|
12
|
+
from pyst2110.framing import SequenceTracker as SequenceTracker
|
|
13
|
+
from pyst2110.framing import frame_boundaries as frame_boundaries
|
|
14
|
+
from pyst2110.framing import frame_starts as frame_starts
|
|
15
|
+
from pyst2110.geometry import byte_offset as byte_offset
|
|
16
|
+
from pyst2110.geometry import choose_payload_size as choose_payload_size
|
|
17
|
+
from pyst2110.geometry import fits_raster as fits_raster
|
|
18
|
+
from pyst2110.geometry import line_bytes as line_bytes
|
|
19
|
+
from pyst2110.geometry import packets_per_frame as packets_per_frame
|
|
20
|
+
from pyst2110.geometry import packets_per_line as packets_per_line
|
|
21
|
+
from pyst2110.geometry import pgroup as pgroup
|
|
22
|
+
from pyst2110.geometry import raster_offset as raster_offset
|
|
23
|
+
from pyst2110.geometry import rows_per_field as rows_per_field
|
|
24
|
+
from pyst2110.geometry import sample_offset as sample_offset
|
|
25
|
+
from pyst2110.payload import PayloadHeaders as PayloadHeaders
|
|
26
|
+
from pyst2110.payload import parse_payload_headers as parse_payload_headers
|
|
27
|
+
from pyst2110.redundancy import LegPair as LegPair
|
|
28
|
+
from pyst2110.redundancy import reconstruct as reconstruct
|
|
29
|
+
from pyst2110.rtp import FIXED_HEADER_SIZE as FIXED_HEADER_SIZE
|
|
30
|
+
from pyst2110.rtp import RtpHeaders as RtpHeaders
|
|
31
|
+
from pyst2110.rtp import parse_rtp as parse_rtp
|
|
32
|
+
from pyst2110.sdp import RTP_CLOCK_RATE as RTP_CLOCK_RATE
|
|
33
|
+
from pyst2110.sdp import SENDER_TYPE_NARROW as SENDER_TYPE_NARROW
|
|
34
|
+
from pyst2110.sdp import SENDER_TYPE_NARROW_LINEAR as SENDER_TYPE_NARROW_LINEAR
|
|
35
|
+
from pyst2110.sdp import SENDER_TYPE_WIDE as SENDER_TYPE_WIDE
|
|
36
|
+
from pyst2110.sdp import SENDER_TYPES as SENDER_TYPES
|
|
37
|
+
from pyst2110.sdp import STANDARD_UDP_SIZE_LIMIT as STANDARD_UDP_SIZE_LIMIT
|
|
38
|
+
from pyst2110.sdp import SdpConformanceWarning as SdpConformanceWarning
|
|
39
|
+
from pyst2110.sdp import SdpFlow as SdpFlow
|
|
40
|
+
from pyst2110.sdp import SdpVideo as SdpVideo
|
|
41
|
+
from pyst2110.sdp import format_dup_sdp as format_dup_sdp
|
|
42
|
+
from pyst2110.sdp import format_sdp as format_sdp
|
|
43
|
+
from pyst2110.sdp import parse_dup_sdp as parse_dup_sdp
|
|
44
|
+
from pyst2110.sdp import parse_sdp as parse_sdp
|
|
45
|
+
from pyst2110.sdp import parse_video_format as parse_video_format
|
|
46
|
+
from pyst2110.timing import Limits as Limits
|
|
47
|
+
from pyst2110.timing import Measurement as Measurement
|
|
48
|
+
from pyst2110.timing import Schedule as Schedule
|
|
49
|
+
from pyst2110.timing import Vrx as Vrx
|
|
50
|
+
from pyst2110.timing import c_inst as c_inst
|
|
51
|
+
from pyst2110.timing import measure as measure
|
|
52
|
+
from pyst2110.timing import read_schedule as read_schedule
|
|
53
|
+
from pyst2110.timing import read_times as read_times
|
|
54
|
+
from pyst2110.timing import sender_limits as sender_limits
|
|
55
|
+
from pyst2110.timing import video_datum as video_datum
|
|
56
|
+
from pyst2110.timing import vrx as vrx
|
|
57
|
+
from pyst2110.transmit import PACKET_HEADER_SIZE as PACKET_HEADER_SIZE
|
|
58
|
+
from pyst2110.transmit import UDP_HEADER_SIZE as UDP_HEADER_SIZE
|
|
59
|
+
from pyst2110.transmit import FrameHeaders as FrameHeaders
|
|
60
|
+
from pyst2110.transmit import max_payload_size as max_payload_size
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"FIXED_HEADER_SIZE",
|
|
64
|
+
"PACKET_HEADER_SIZE",
|
|
65
|
+
"RTP_CLOCK_RATE",
|
|
66
|
+
"SENDER_TYPES",
|
|
67
|
+
"SENDER_TYPE_NARROW",
|
|
68
|
+
"SENDER_TYPE_NARROW_LINEAR",
|
|
69
|
+
"SENDER_TYPE_WIDE",
|
|
70
|
+
"STANDARD_UDP_SIZE_LIMIT",
|
|
71
|
+
"UDP_HEADER_SIZE",
|
|
72
|
+
"FrameHeaders",
|
|
73
|
+
"FrameTracker",
|
|
74
|
+
"LegPair",
|
|
75
|
+
"Limits",
|
|
76
|
+
"Measurement",
|
|
77
|
+
"PayloadHeaders",
|
|
78
|
+
"RtpHeaders",
|
|
79
|
+
"Schedule",
|
|
80
|
+
"SdpConformanceWarning",
|
|
81
|
+
"SdpFlow",
|
|
82
|
+
"SdpVideo",
|
|
83
|
+
"SequenceTracker",
|
|
84
|
+
"Vrx",
|
|
85
|
+
"byte_offset",
|
|
86
|
+
"c_inst",
|
|
87
|
+
"choose_payload_size",
|
|
88
|
+
"fits_raster",
|
|
89
|
+
"format_dup_sdp",
|
|
90
|
+
"format_sdp",
|
|
91
|
+
"frame_boundaries",
|
|
92
|
+
"frame_starts",
|
|
93
|
+
"line_bytes",
|
|
94
|
+
"max_payload_size",
|
|
95
|
+
"measure",
|
|
96
|
+
"packets_per_frame",
|
|
97
|
+
"packets_per_line",
|
|
98
|
+
"parse_dup_sdp",
|
|
99
|
+
"parse_payload_headers",
|
|
100
|
+
"parse_rtp",
|
|
101
|
+
"parse_sdp",
|
|
102
|
+
"parse_video_format",
|
|
103
|
+
"pgroup",
|
|
104
|
+
"raster_offset",
|
|
105
|
+
"read_schedule",
|
|
106
|
+
"read_times",
|
|
107
|
+
"reconstruct",
|
|
108
|
+
"rows_per_field",
|
|
109
|
+
"sample_offset",
|
|
110
|
+
"sender_limits",
|
|
111
|
+
"video_datum",
|
|
112
|
+
"vrx",
|
|
113
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Shape checks and per-packet reads shared by the header parses.
|
|
2
|
+
|
|
3
|
+
Internal. Both parses take the same thing — a ``(packets, stride)`` uint8 view
|
|
4
|
+
(SPEC §spec:interface-shape) — and both need to read a big-endian field at an
|
|
5
|
+
offset that differs per packet, because CSRCs, a header extension and the
|
|
6
|
+
RFC 4175 continuation bit all move what follows them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from numpy.typing import ArrayLike, DTypeLike, NDArray
|
|
15
|
+
|
|
16
|
+
_U16_SIZE = 2
|
|
17
|
+
_U32_SIZE = 4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def per_packet(
|
|
21
|
+
values: ArrayLike,
|
|
22
|
+
noun: str,
|
|
23
|
+
count: int | None = None,
|
|
24
|
+
plural: str = "",
|
|
25
|
+
dtype: DTypeLike = np.int64,
|
|
26
|
+
) -> NDArray[Any]:
|
|
27
|
+
"""One ``noun`` per packet, coerced — or a ValueError naming the field.
|
|
28
|
+
|
|
29
|
+
Every array crossing this interface is packet-aligned
|
|
30
|
+
(§spec:interface-shape), so every one of them is checked the same way.
|
|
31
|
+
Pass ``count`` where the packet count is already known, which reports both
|
|
32
|
+
numbers; without it the check is that the input is one-dimensional at all,
|
|
33
|
+
which is what catches a ``(packets, stride)`` view passed by mistake.
|
|
34
|
+
|
|
35
|
+
``plural`` names ``noun`` in the plural where adding an *s* does not.
|
|
36
|
+
"""
|
|
37
|
+
array = np.asarray(values, dtype=dtype)
|
|
38
|
+
if count is None:
|
|
39
|
+
if array.ndim != 1:
|
|
40
|
+
raise ValueError(f"expected one {noun} per packet, got shape {array.shape}")
|
|
41
|
+
elif array.shape != (count,):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"expected one {noun} per packet: {count} packets, "
|
|
44
|
+
f"{array.size} {plural or noun + 's'}"
|
|
45
|
+
)
|
|
46
|
+
return array
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate(packets: NDArray[np.uint8], minimum: int) -> None:
|
|
50
|
+
"""Reject anything that is not a byte chunk of at least ``minimum`` columns."""
|
|
51
|
+
if packets.ndim != 2:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"expected a (packets, stride) byte view, got shape {packets.shape}"
|
|
54
|
+
)
|
|
55
|
+
if packets.dtype != np.uint8:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"expected a uint8 byte view, got dtype {packets.dtype}; a wider "
|
|
58
|
+
"dtype would misalign every field"
|
|
59
|
+
)
|
|
60
|
+
if packets.shape[1] < minimum:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"expected at least {minimum} columns, got shape {packets.shape}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def limits(
|
|
67
|
+
packets: NDArray[np.uint8], sizes: NDArray[np.integer[Any]] | None
|
|
68
|
+
) -> NDArray[np.int64]:
|
|
69
|
+
"""Each packet's usable length: its reported size, or the stride if unstated.
|
|
70
|
+
|
|
71
|
+
A receiver writes at most the stride and reports the true size separately,
|
|
72
|
+
so the columns past a packet still hold whatever the buffer held before
|
|
73
|
+
(§spec:interface-shape). Deriving a field's position from those would read
|
|
74
|
+
the previous frame's packet.
|
|
75
|
+
"""
|
|
76
|
+
count, stride = packets.shape
|
|
77
|
+
if sizes is None:
|
|
78
|
+
return np.full(count, stride, dtype=np.int64)
|
|
79
|
+
bounds: NDArray[np.int64] = per_packet(sizes, "size", count=count)
|
|
80
|
+
return np.clip(bounds, 0, stride)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def read_u16(
|
|
84
|
+
packets: NDArray[np.uint8],
|
|
85
|
+
offsets: NDArray[np.int64],
|
|
86
|
+
bounds: NDArray[np.int64],
|
|
87
|
+
rows: NDArray[np.int64] | None = None,
|
|
88
|
+
) -> tuple[NDArray[np.int64], NDArray[np.bool_]]:
|
|
89
|
+
"""A big-endian 16-bit field read at a per-packet offset.
|
|
90
|
+
|
|
91
|
+
Returns the values and a mask of which reads landed inside their packet.
|
|
92
|
+
A read that does not fit is clamped to a valid index and its value zeroed
|
|
93
|
+
rather than raising: one malformed packet in a chunk of thousands is a
|
|
94
|
+
packet to account for, not a reason to drop the chunk.
|
|
95
|
+
|
|
96
|
+
``rows`` is the packet index the gather reads down, ``arange(count)``
|
|
97
|
+
where a caller does not pass one. A caller reading several fields of the
|
|
98
|
+
same chunk passes it once rather than paying an allocation per field:
|
|
99
|
+
:func:`pyst2110.payload.parse_payload_headers` reads nine, and at a
|
|
100
|
+
thousand chunks a second that is nine arange allocations per chunk on the
|
|
101
|
+
frame path.
|
|
102
|
+
"""
|
|
103
|
+
inside = (offsets >= 0) & (offsets + _U16_SIZE <= bounds)
|
|
104
|
+
index = np.where(inside, offsets, 0).clip(0, packets.shape[1] - _U16_SIZE)
|
|
105
|
+
if rows is None:
|
|
106
|
+
rows = np.arange(packets.shape[0])
|
|
107
|
+
high = packets[rows, index].astype(np.int64)
|
|
108
|
+
low = packets[rows, index + 1].astype(np.int64)
|
|
109
|
+
return np.where(inside, (high << 8) | low, 0).astype(np.int64), inside
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def u16_view(packets: NDArray[np.uint8]) -> NDArray[np.uint16] | None:
|
|
113
|
+
"""The whole chunk as big-endian 16-bit words, or ``None`` if it cannot be.
|
|
114
|
+
|
|
115
|
+
Reinterpreting the buffer is what turns a header field into a column
|
|
116
|
+
slice, and a column slice is what the conforming fast path is made of
|
|
117
|
+
(§spec:conforming-fast-path). It needs the rows contiguous and an even
|
|
118
|
+
number of octets in each; a caller handing over an odd stride, or a
|
|
119
|
+
sub-block sliced out of a wider buffer, gets ``None`` and the general path
|
|
120
|
+
rather than an exception. Both are legitimate chunks — they are simply not
|
|
121
|
+
ones a column slice can read.
|
|
122
|
+
"""
|
|
123
|
+
if packets.shape[1] % _U16_SIZE != 0 or not packets.flags["C_CONTIGUOUS"]:
|
|
124
|
+
return None
|
|
125
|
+
view: NDArray[np.uint16] = packets.view(">u2")
|
|
126
|
+
return view
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def u32_view(packets: NDArray[np.uint8]) -> NDArray[np.uint32] | None:
|
|
130
|
+
"""The whole chunk as big-endian 32-bit words, or ``None`` if it cannot be.
|
|
131
|
+
|
|
132
|
+
The same reinterpretation :func:`u16_view` makes, one width up, for the
|
|
133
|
+
fields that are thirty-two bits on the wire — the RTP timestamp and the
|
|
134
|
+
SSRC. Reading one as two sixteen-bit columns joined in place costs a
|
|
135
|
+
widen, a shift and an or over the chunk; reading it as one column costs a
|
|
136
|
+
strided copy and nothing else.
|
|
137
|
+
|
|
138
|
+
A stricter guard than the sixteen-bit one, and it has to be: a stride that
|
|
139
|
+
is a multiple of two but not of four reinterprets to a shape numpy
|
|
140
|
+
refuses, so it takes the sixteen-bit path instead. The chunk's own base is
|
|
141
|
+
numpy's to align.
|
|
142
|
+
"""
|
|
143
|
+
if packets.shape[1] % _U32_SIZE != 0 or not packets.flags["C_CONTIGUOUS"]:
|
|
144
|
+
return None
|
|
145
|
+
view: NDArray[np.uint32] = packets.view(">u4")
|
|
146
|
+
return view
|