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.
@@ -0,0 +1,15 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+
9
+ # Tooling caches
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+
14
+ # Agent worktrees
15
+ .claude/worktrees/
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.
@@ -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.
@@ -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