pith-mp4 0.1.1__py3-none-win_amd64.whl

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.
pith_mp4/__init__.py ADDED
@@ -0,0 +1,405 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 pith-hash
3
+ """pith-mp4 SDK: ISO-BMFF demuxing through ctypes.
4
+
5
+ Demuxes a native ISO-BMFF file (ftyp/moov/trak/stbl: sample tables,
6
+ `stts`/`stsc`/`stsz`/`stco`/`co64`/`stss`, `stsd` sample entries with
7
+ `avcC`/`esds`) through the Rust cdylib, handing back the canonical
8
+ record walk the ``reference.json`` vectors are checked against: the
9
+ demuxed shape serialized deterministically, field by field
10
+ (big-endian), exactly as the crate's ``ffi`` module documents it.
11
+
12
+ The cdylib is located through the suite's discovery chain:
13
+
14
+ 1. ``PITH_CDYLIB`` — an explicit cdylib *file* path;
15
+ 2. ``PITH_CDYLIB_DIR`` — a *directory* scanned for the cdylib names
16
+ (the CD pipeline points this at ``target/release``);
17
+ 3. the package directory itself (the built wheel ships the cdylib as
18
+ package data);
19
+ 4. ``<repo root>/target/release`` — the repository working-tree layout,
20
+ so a source checkout runs against a local cargo build with no
21
+ configuration.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import ctypes
27
+ import os
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+ __all__ = [
32
+ "Demuxed",
33
+ "Track",
34
+ "Sample",
35
+ "Entry",
36
+ "FfiError",
37
+ "LibraryNotFoundError",
38
+ "find_cdylib",
39
+ "demux_canonical",
40
+ "parse_canonical",
41
+ "STATUS_OK",
42
+ "STATUS_INVALID",
43
+ "STATUS_REJECTED",
44
+ "ERR_BAD_VALUE",
45
+ "ERR_INVALID_MAGIC",
46
+ "ERR_TOO_LARGE",
47
+ "ERR_TRUNCATED",
48
+ "ERR_UNSUPPORTED",
49
+ "ENTRY_VISUAL",
50
+ "ENTRY_AUDIO",
51
+ "ENTRY_OTHER",
52
+ ]
53
+
54
+ #: Status: success.
55
+ STATUS_OK = 0
56
+ #: Status: a caller argument is invalid (a null pointer).
57
+ STATUS_INVALID = -1
58
+ #: Status: the core demuxer refused the input (no ``ftyp``,
59
+ #: unrecognised brands, missing ``moov``, fragmented file, truncated).
60
+ STATUS_REJECTED = -2
61
+
62
+ #: Refusal kind ``"BadValue"`` (``Error::BadValue``).
63
+ ERR_BAD_VALUE = 1
64
+ #: Refusal kind ``"InvalidMagic"`` (``Error::InvalidMagic``).
65
+ ERR_INVALID_MAGIC = 2
66
+ #: Refusal kind ``"TooLarge"`` (``Error::TooLarge``).
67
+ ERR_TOO_LARGE = 3
68
+ #: Refusal kind ``"Truncated"`` (``Error::Truncated``).
69
+ ERR_TRUNCATED = 4
70
+ #: Refusal kind ``"Unsupported"`` (``Error::Unsupported``).
71
+ ERR_UNSUPPORTED = 5
72
+
73
+ #: ``stsd`` entry kind: a visual sample entry.
74
+ ENTRY_VISUAL = 0
75
+ #: ``stsd`` entry kind: an audio sample entry.
76
+ ENTRY_AUDIO = 1
77
+ #: ``stsd`` entry kind: neither visual nor audio.
78
+ ENTRY_OTHER = 2
79
+
80
+ #: Every cdylib file name cargo may drop into the build directory, per
81
+ #: platform (windows / linux / macOS).
82
+ CDYLIB_NAMES = ("pith_mp4.dll", "libpith_mp4.so", "libpith_mp4.dylib")
83
+
84
+ #: Refusal-kind names by code — the same stable names the ``errors``
85
+ #: of ``reference.json`` record as strings.
86
+ ERR_KIND_NAMES = {
87
+ ERR_BAD_VALUE: "BadValue",
88
+ ERR_INVALID_MAGIC: "InvalidMagic",
89
+ ERR_TOO_LARGE: "TooLarge",
90
+ ERR_TRUNCATED: "Truncated",
91
+ ERR_UNSUPPORTED: "Unsupported",
92
+ }
93
+
94
+ #: ``stsd`` entry-kind names by code — the variant names reference.json
95
+ #: records under ``stsd_entries[].kind``.
96
+ ENTRY_KIND_NAMES = {ENTRY_VISUAL: "Visual", ENTRY_AUDIO: "Audio", ENTRY_OTHER: "Other"}
97
+
98
+
99
+ class LibraryNotFoundError(OSError):
100
+ """No cdylib was found through the discovery chain."""
101
+
102
+
103
+ class FfiError(Exception):
104
+ """A non-zero status code came back from the cdylib."""
105
+
106
+ def __init__(self, op: str, status: int, kind: int = 0) -> None:
107
+ detail = {
108
+ STATUS_INVALID: "invalid argument",
109
+ STATUS_REJECTED: "input rejected",
110
+ }.get(status, "unknown failure")
111
+ if kind:
112
+ detail = f"{detail} ({ERR_KIND_NAMES.get(kind, f'kind {kind}')})"
113
+ super().__init__(f"{op} failed: {detail} (status {status})")
114
+ #: The raw status code the FFI returned.
115
+ self.status = status
116
+ #: The refusal kind code (0 unless the status is
117
+ #: :data:`STATUS_REJECTED`; one of the ``ERR_*`` constants).
118
+ self.kind = kind
119
+
120
+
121
+ def find_cdylib() -> Path:
122
+ """Locates the cdylib through the suite's discovery chain."""
123
+ explicit = os.environ.get("PITH_CDYLIB")
124
+ if explicit:
125
+ p = Path(explicit)
126
+ if p.is_file():
127
+ return p
128
+ env_dir = os.environ.get("PITH_CDYLIB_DIR")
129
+ candidates: list[Path] = []
130
+ if env_dir:
131
+ env_dir_path = Path(env_dir)
132
+ candidates.append(env_dir_path)
133
+ if not env_dir_path.is_absolute():
134
+ # CD and local runs invoke tools from the repository root or
135
+ # from sdk/<lang>; resolve the env value against both.
136
+ candidates.append(Path.cwd() / env_dir_path)
137
+ candidates.append(Path(__file__).resolve().parents[3] / env_dir_path)
138
+ candidates.append(Path(__file__).resolve().parent) # packaged wheel
139
+ candidates.append(Path(__file__).resolve().parents[3] / "target" / "release")
140
+ for directory in candidates:
141
+ for name in CDYLIB_NAMES:
142
+ p = directory / name
143
+ if p.is_file():
144
+ return p
145
+ raise LibraryNotFoundError(
146
+ "no pith-mp4 cdylib found (searched PITH_CDYLIB, PITH_CDYLIB_DIR, "
147
+ "the package directory and <repo>/target/release); "
148
+ "run `cargo build --release` first"
149
+ )
150
+
151
+
152
+ _lib: ctypes.CDLL | None = None
153
+
154
+
155
+ def _load() -> ctypes.CDLL:
156
+ global _lib
157
+ if _lib is None:
158
+ lib = ctypes.CDLL(str(find_cdylib()))
159
+ lib.pith_mp4_demux.argtypes = [
160
+ ctypes.c_void_p, # data
161
+ ctypes.c_size_t, # len
162
+ ctypes.POINTER(ctypes.c_void_p), # out buffer
163
+ ctypes.POINTER(ctypes.c_size_t), # out length
164
+ ctypes.POINTER(ctypes.c_int32), # out refusal kind
165
+ ]
166
+ lib.pith_mp4_demux.restype = ctypes.c_int32
167
+ lib.pith_mp4_free.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
168
+ lib.pith_mp4_free.restype = None
169
+ _lib = lib
170
+ return _lib
171
+
172
+
173
+ def demux_canonical(data: bytes) -> bytes:
174
+ """Demuxes a complete ISO-BMFF file into the canonical record walk
175
+ the SDK vectors are checked against.
176
+
177
+ Raises :class:`FfiError` with ``status == STATUS_REJECTED`` for any
178
+ refused input; ``err.kind`` then carries one of the ``ERR_*`` kind
179
+ codes the reference.json error vectors pin. The demuxer never
180
+ panics through this boundary.
181
+ """
182
+ out = ctypes.c_void_p()
183
+ out_len = ctypes.c_size_t()
184
+ err = ctypes.c_int32()
185
+ status = _load().pith_mp4_demux(
186
+ data, len(data), ctypes.byref(out), ctypes.byref(out_len), ctypes.byref(err)
187
+ )
188
+ if status != STATUS_OK:
189
+ raise FfiError("pith_mp4_demux", status, err.value)
190
+ try:
191
+ return ctypes.string_at(out, out_len.value)
192
+ finally:
193
+ _load().pith_mp4_free(out, out_len.value)
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class Sample:
198
+ """One sample record of the canonical walk."""
199
+
200
+ #: Absolute byte offset of the sample in the file.
201
+ offset: int
202
+ #: Sample size in bytes.
203
+ size: int
204
+ #: Decoding time in the track timescale.
205
+ decoding: int
206
+ #: Presentation time in the track timescale (may precede decoding
207
+ #: with edit lists / B-frames).
208
+ presentation: int
209
+ #: Sample duration in the track timescale.
210
+ duration: int
211
+ #: Whether the sample is a sync (keyframe) sample.
212
+ keyframe: bool
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class Entry:
217
+ """One ``stsd`` sample entry of the canonical walk."""
218
+
219
+ #: ``"Visual"``, ``"Audio"`` or ``"Other"``.
220
+ kind: str
221
+ #: The four-character coding (``avc1``, ``mp4a``, ...).
222
+ coding: str
223
+ #: Visual only: coded width.
224
+ width: int | None
225
+ #: Visual only: coded height.
226
+ height: int | None
227
+ #: Visual only: ``avcC`` payload, hex or ``None``.
228
+ avcc_hex: str | None
229
+ #: Audio only: channel count.
230
+ channels: int | None
231
+ #: Audio only: sample rate in Hz.
232
+ rate: int | None
233
+ #: ``esds`` payload, hex or ``None``.
234
+ esds_hex: str | None
235
+
236
+
237
+ @dataclass(frozen=True)
238
+ class Track:
239
+ """One demuxed track of the canonical walk."""
240
+
241
+ #: Track id from ``tkhd``.
242
+ id: int
243
+ #: Media timescale from ``mdhd``.
244
+ timescale: int
245
+ #: Track duration in the media timescale.
246
+ duration: int
247
+ #: ISO-639-2/T language code, or ``None``.
248
+ language: str | None
249
+ #: Handler type (``vide``, ``soun``, ...).
250
+ handler: str
251
+ #: Visual track width from ``tkhd`` (0 for non-visual).
252
+ width: int
253
+ #: Visual track height from ``tkhd`` (0 for non-visual).
254
+ height: int
255
+ #: All sample records, in sample order.
256
+ samples: list[Sample]
257
+ #: All ``stsd`` sample entries.
258
+ entries: list[Entry]
259
+
260
+
261
+ @dataclass(frozen=True)
262
+ class Demuxed:
263
+ """A demuxed ISO-BMFF file, re-expressed from the canonical walk."""
264
+
265
+ #: Major brand of the ``ftyp``.
266
+ major_brand: str
267
+ #: Compatible brands of the ``ftyp``.
268
+ compatible_brands: list[str]
269
+ #: Movie timescale from ``mvhd``.
270
+ timescale: int
271
+ #: Movie duration in the movie timescale.
272
+ duration: int
273
+ #: Demuxed tracks, in file order.
274
+ tracks: list[Track]
275
+ #: The canonical byte stream the walk is parsed from.
276
+ raw: bytes
277
+
278
+
279
+ class _Reader:
280
+ """Sequential big-endian reader over the canonical walk."""
281
+
282
+ def __init__(self, raw: bytes) -> None:
283
+ self.raw = raw
284
+ self.pos = 0
285
+
286
+ def take(self, n: int) -> bytes:
287
+ if self.pos + n > len(self.raw):
288
+ raise ValueError("canonical walk is truncated")
289
+ chunk = self.raw[self.pos : self.pos + n]
290
+ self.pos += n
291
+ return chunk
292
+
293
+ def u8(self) -> int:
294
+ return self.take(1)[0]
295
+
296
+ def u32(self) -> int:
297
+ return int.from_bytes(self.take(4), "big")
298
+
299
+ def u64(self) -> int:
300
+ return int.from_bytes(self.take(8), "big")
301
+
302
+ def i64(self) -> int:
303
+ return int.from_bytes(self.take(8), "big", signed=True)
304
+
305
+ def four(self) -> str:
306
+ return self.take(4).decode("latin-1")
307
+
308
+
309
+ def _parse_entry(r: _Reader) -> Entry:
310
+ kind_code = r.u8()
311
+ coding = r.four()
312
+ if kind_code == ENTRY_VISUAL:
313
+ width = r.u32()
314
+ height = r.u32()
315
+ avcc = r.take(r.u32())
316
+ esds = r.take(r.u32())
317
+ return Entry(
318
+ kind=ENTRY_KIND_NAMES[kind_code],
319
+ coding=coding,
320
+ width=width,
321
+ height=height,
322
+ avcc_hex=avcc.hex() if avcc else None,
323
+ channels=None,
324
+ rate=None,
325
+ esds_hex=esds.hex() if esds else None,
326
+ )
327
+ if kind_code == ENTRY_AUDIO:
328
+ channels = r.u32()
329
+ rate = r.u32()
330
+ esds = r.take(r.u32())
331
+ return Entry(
332
+ kind=ENTRY_KIND_NAMES[kind_code],
333
+ coding=coding,
334
+ width=None,
335
+ height=None,
336
+ avcc_hex=None,
337
+ channels=channels,
338
+ rate=rate,
339
+ esds_hex=esds.hex() if esds else None,
340
+ )
341
+ return Entry(
342
+ kind=ENTRY_KIND_NAMES[kind_code],
343
+ coding=coding,
344
+ width=None,
345
+ height=None,
346
+ avcc_hex=None,
347
+ channels=None,
348
+ rate=None,
349
+ esds_hex=None,
350
+ )
351
+
352
+
353
+ def _parse_track(r: _Reader) -> Track:
354
+ track_id = r.u32()
355
+ timescale = r.u32()
356
+ duration = r.u64()
357
+ language = None
358
+ if r.u8() == 1:
359
+ language = r.take(3).decode("latin-1")
360
+ else:
361
+ r.take(3)
362
+ handler = r.four()
363
+ width = r.u32()
364
+ height = r.u32()
365
+ samples = [
366
+ Sample(
367
+ offset=r.u64(),
368
+ size=r.u32(),
369
+ decoding=r.u64(),
370
+ presentation=r.i64(),
371
+ duration=r.u32(),
372
+ keyframe=r.u8() == 1,
373
+ )
374
+ for _ in range(r.u32())
375
+ ]
376
+ entries = [_parse_entry(r) for _ in range(r.u32())]
377
+ return Track(
378
+ id=track_id,
379
+ timescale=timescale,
380
+ duration=duration,
381
+ language=language,
382
+ handler=handler,
383
+ width=width,
384
+ height=height,
385
+ samples=samples,
386
+ entries=entries,
387
+ )
388
+
389
+
390
+ def parse_canonical(raw: bytes) -> Demuxed:
391
+ """Re-expresses the canonical record walk as a :class:`Demuxed`."""
392
+ r = _Reader(raw)
393
+ major_brand = r.four()
394
+ compatible_brands = [r.four() for _ in range(r.u32())]
395
+ timescale = r.u32()
396
+ duration = r.u64()
397
+ tracks = [_parse_track(r) for _ in range(r.u32())]
398
+ return Demuxed(
399
+ major_brand=major_brand,
400
+ compatible_brands=compatible_brands,
401
+ timescale=timescale,
402
+ duration=duration,
403
+ tracks=tracks,
404
+ raw=raw,
405
+ )
pith_mp4/pith_mp4.dll ADDED
Binary file
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: pith-mp4
3
+ Version: 0.1.1
4
+ Summary: ISO-BMFF demuxing SDK: ftyp/moov sample tables, stts/stsc/stsz/stco/co64/stss and stsd sample entries via the pith-mp4 Rust cdylib (ctypes, zero-dep)
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/pith-hash/pith-mp4
7
+ Project-URL: Homepage, https://github.com/pith-hash/pith-mp4
8
+ Keywords: flac,audio,decoder,pcm,hashing
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Rust
11
+ Classifier: Topic :: Multimedia :: Sound/Audio
12
+ Requires-Python: >=3.9
@@ -0,0 +1,6 @@
1
+ pith_mp4/__init__.py,sha256=aWtXQssnFcF5nqM8sIcmGXdPNWSI2MI_lKzz1EffNBg,12259
2
+ pith_mp4/pith_mp4.dll,sha256=IBzPEfmmszBwZuPd-Nck4t1PKdj21M-NTadgQjlAy8k,160256
3
+ pith_mp4-0.1.1.dist-info/METADATA,sha256=M5ngM3tQxNyUmn5ISaxkERYHJZ9NlKuFX93Vz7rEYJQ,571
4
+ pith_mp4-0.1.1.dist-info/WHEEL,sha256=Zx98gwb_dQKckJK3HEOVKnxxD52EfU_PXDG_usMJ2ng,98
5
+ pith_mp4-0.1.1.dist-info/top_level.txt,sha256=Oxau1mlbt0pDRmdeLXRGiLgcpLG8BsNK3PG5gw0tmJg,9
6
+ pith_mp4-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1 @@
1
+ pith_mp4