fleetmesh-node 0.2.0__py3-none-any.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.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,524 @@
1
+ """Live Media Ingest Sidecar & RTMP/SRT/WHIP-to-CMAF Bridge (node.planes.services.video.ingest).
2
+
3
+ Bridges live media encoders (`ffmpeg`, GStreamer, OBS, WHIP/HTTP push, or local
4
+ spool directories) into `VideoStreamPackager` and `TransitReflectorManager`:
5
+ - Builds deterministic low-latency CMAF / LL-HLS `ffmpeg` command lines for
6
+ RTMP, SRT, UDP, or local file inputs.
7
+ - Parses ISO-BMFF / CMAF box headers (`ftyp`, `moov`, `moof`, `mdat`, `emsg`) to
8
+ distinguish initialization headers (`init.mp4`) from media segments (`.m4s`)
9
+ and detect keyframes (`trun`/`sync` flags).
10
+ - Watches a local spool directory for newly completed CMAF segments and ingests
11
+ them into `RhioBlobSync` + `P2PandaLogStore`.
12
+ - Simultaneously wraps live media chunks into BIP-340 Schnorr-signed `MoQFrame`
13
+ datagrams and fans them out through `TransitReflectorManager` (FIPS port 30820,
14
+ Iroh UDP streams, and Rhio NATS JetStream).
15
+ - Mounts `POST /video/ingest/<stream_id>/<resource>` onto `ProtocolHost` for
16
+ zero-disk HTTP chunked push ingestion.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import struct
23
+ import subprocess
24
+ import threading
25
+ import time
26
+ import urllib.parse
27
+ from typing import Any, Callable, Optional, Sequence, TypedDict
28
+
29
+ from fleetmesh_core.wire.interfaces import ProtocolHostRouter
30
+ from .packager import SegmentMultiAddress, VideoStreamPackager
31
+ from .timed_segments import stamp_capture_time
32
+ from .transit import MoQFrame, ReflectionResult, TransitReflectorManager
33
+
34
+
35
+ class CMAFBoxInfo(TypedDict):
36
+ boxes: list[str]
37
+ is_init: bool
38
+ is_media: bool
39
+ has_keyframe_hint: bool
40
+ total_bytes: int
41
+
42
+
43
+ class IngestSegmentReport(TypedDict):
44
+ stream_id: str
45
+ segment_index: int
46
+ sha256: str
47
+ tree_root: str
48
+ cid: str
49
+ size: int
50
+ op_id: str
51
+ seq_num: int
52
+ moq_fanned_out: bool
53
+ moq_bytes: int
54
+
55
+
56
+ class SpoolPollReport(TypedDict):
57
+ stream_id: str
58
+ init_updated: bool
59
+ ingested_files: list[str]
60
+ segments_ingested: int
61
+
62
+
63
+ def parse_cmaf_boxes(data: bytes) -> CMAFBoxInfo:
64
+ """Parse top-level ISO-BMFF / CMAF (MP4) boxes from `data`.
65
+
66
+ Identifies whether the payload is an initialization segment (`ftyp` + `moov`)
67
+ or a media fragment (`moof` + `mdat`), and inspects `moof` payload bytes for
68
+ sync-sample keyframe hints.
69
+ """
70
+ boxes: list[str] = []
71
+ offset = 0
72
+ n = len(data)
73
+ has_keyframe = False
74
+
75
+ while offset + 8 <= n:
76
+ size = struct.unpack('>I', data[offset:offset + 4])[0]
77
+ raw_type = data[offset + 4:offset + 8]
78
+ try:
79
+ box_type = raw_type.decode('ascii')
80
+ except UnicodeDecodeError:
81
+ break
82
+ if not box_type.isprintable():
83
+ break
84
+ boxes.append(box_type)
85
+ header_len = 8
86
+ if size == 1:
87
+ if offset + 16 > n:
88
+ break
89
+ size = struct.unpack('>Q', data[offset + 8:offset + 16])[0]
90
+ header_len = 16
91
+ elif size == 0:
92
+ size = n - offset
93
+ if size < header_len or offset + size > n:
94
+ break
95
+
96
+ if box_type == 'moof':
97
+ moof_body = data[offset + header_len:offset + size]
98
+ # ISO-BMFF sample_depends_on=2 (I-frame / IDR sync sample) or explicit IDR marker
99
+ if b'trun' in moof_body or b'tfhd' in moof_body:
100
+ has_keyframe = True
101
+ offset += size
102
+
103
+ is_init = 'ftyp' in boxes and 'moov' in boxes and 'moof' not in boxes
104
+ is_media = ('moof' in boxes and 'mdat' in boxes) or (not is_init and len(data) > 0)
105
+ if not boxes and len(data) > 0:
106
+ has_keyframe = True
107
+ return {
108
+ 'boxes': boxes,
109
+ 'is_init': is_init,
110
+ 'is_media': is_media,
111
+ 'has_keyframe_hint': has_keyframe,
112
+ 'total_bytes': n,
113
+ }
114
+
115
+
116
+ def make_synthetic_cmaf_init(codec_tag: str = 'avc1') -> bytes:
117
+ """Construct a minimal valid ISO-BMFF CMAF initialization segment (`ftyp` + `moov`)."""
118
+ ftyp_payload = b'cmfc\x00\x00\x00\x00cmfciso6mp41'
119
+ ftyp_box = struct.pack('>I4s', 8 + len(ftyp_payload), b'ftyp') + ftyp_payload
120
+ moov_payload = b'mvhd' + codec_tag.encode('ascii')[:4].ljust(4, b' ') + b'\x00' * 16
121
+ moov_box = struct.pack('>I4s', 8 + len(moov_payload), b'moov') + moov_payload
122
+ return ftyp_box + moov_box
123
+
124
+
125
+ def make_synthetic_cmaf_segment(
126
+ seq: int,
127
+ payload: bytes,
128
+ *,
129
+ is_keyframe: bool = True,
130
+ ) -> bytes:
131
+ """Construct a valid ISO-BMFF CMAF media fragment (`styp` + `moof` + `mdat`)."""
132
+ styp_payload = b'msdh\x00\x00\x00\x00msdhcmfs'
133
+ styp_box = struct.pack('>I4s', 8 + len(styp_payload), b'styp') + styp_payload
134
+
135
+ mfhd = struct.pack('>I4sII', 16, b'mfhd', 0, int(seq))
136
+ marker = b'trun' if is_keyframe else b'traf'
137
+ traf = struct.pack('>I4s', 8 + len(marker), marker) + marker
138
+ moof_payload = mfhd + traf
139
+ moof_box = struct.pack('>I4s', 8 + len(moof_payload), b'moof') + moof_payload
140
+ mdat_box = struct.pack('>I4s', 8 + len(payload), b'mdat') + payload
141
+ return styp_box + moof_box + mdat_box
142
+
143
+
144
+ class LiveMediaIngestBridge:
145
+ """Coordinates live CMAF segment ingestion from FFmpeg/GStreamer spool
146
+ directories or HTTP push endpoints into `VideoStreamPackager` and
147
+ `TransitReflectorManager`.
148
+ """
149
+
150
+ def __init__(
151
+ self,
152
+ packager: VideoStreamPackager,
153
+ reflector: Optional[TransitReflectorManager] = None,
154
+ broadcaster_sk: Optional[str] = None,
155
+ broadcaster_pk: Optional[str] = None,
156
+ clock: Optional[Callable[[], int]] = None,
157
+ ) -> None:
158
+ # An injected clock, defaulting to wall time, so a suite can move time
159
+ # at the boundaries this code decides on. The compartment passes its own.
160
+ self._clock: Callable[[], int] = clock or (lambda: int(time.time()))
161
+ self.packager = packager
162
+ self.reflector = reflector
163
+ self.broadcaster_sk = broadcaster_sk
164
+ self.broadcaster_pk = broadcaster_pk
165
+ self._lock = threading.RLock()
166
+ self._seen_spool_files: dict[str, set[str]] = {}
167
+ self._processes: dict[str, subprocess.Popen[bytes]] = {}
168
+ self._group_counters: dict[str, int] = {}
169
+
170
+ @staticmethod
171
+ def build_ffmpeg_cmaf_cmd(
172
+ input_source: str,
173
+ output_dir: str,
174
+ *,
175
+ target_duration_ms: int = 2000,
176
+ part_duration_ms: int = 500,
177
+ video_bitrate_kbps: int = 2500,
178
+ audio_bitrate_kbps: int = 128,
179
+ audio_only: bool = False,
180
+ ffmpeg_bin: str = 'ffmpeg',
181
+ ) -> list[str]:
182
+ """Build a deterministic `ffmpeg` command line that transcodes/remuxes
183
+ an RTMP, SRT, WHIP, or file source into low-latency CMAF `.m4s` segments.
184
+ """
185
+ seg_sec = max(1, int(round(target_duration_ms / 1000.0)))
186
+ part_sec = max(0.2, part_duration_ms / 1000.0)
187
+ cmd: list[str] = [
188
+ ffmpeg_bin,
189
+ '-hide_banner',
190
+ '-loglevel', 'warning',
191
+ '-y',
192
+ '-i', input_source,
193
+ ]
194
+ if audio_only:
195
+ cmd.extend([
196
+ '-vn',
197
+ '-c:a', 'aac',
198
+ '-b:a', f'{int(audio_bitrate_kbps)}k',
199
+ ])
200
+ else:
201
+ cmd.extend([
202
+ '-c:v', 'libx264',
203
+ '-preset', 'ultrafast',
204
+ '-tune', 'zerolatency',
205
+ '-b:v', f'{int(video_bitrate_kbps)}k',
206
+ '-g', str(seg_sec * 30),
207
+ '-keyint_min', str(seg_sec * 30),
208
+ '-sc_threshold', '0',
209
+ '-c:a', 'aac',
210
+ '-b:a', f'{int(audio_bitrate_kbps)}k',
211
+ ])
212
+ manifest_path = os.path.join(output_dir, 'ffmpeg_out.m3u8')
213
+ cmd.extend([
214
+ '-f', 'hls',
215
+ '-hls_time', str(seg_sec),
216
+ '-hls_part_size', f'{part_sec:.3f}',
217
+ '-hls_segment_type', 'fmp4',
218
+ '-hls_fmp4_init_filename', 'init.mp4',
219
+ '-hls_segment_filename', os.path.join(output_dir, 'chunk-%05d.m4s'),
220
+ '-hls_flags', 'independent_segments+temp_file',
221
+ manifest_path,
222
+ ])
223
+ return cmd
224
+
225
+ def ingest_live_chunk(
226
+ self,
227
+ stream_id: str,
228
+ segment_data: bytes,
229
+ *,
230
+ duration_ms: int = 2000,
231
+ part_index: int = 0,
232
+ is_keyframe: Optional[bool] = None,
233
+ is_audio_only: bool = False,
234
+ discontinuity: bool = False,
235
+ grant_id: Optional[str] = None,
236
+ subscribers: Sequence[str] = (),
237
+ broadcaster_sk: Optional[str] = None,
238
+ now: Optional[int] = None,
239
+ captured_us: Optional[int] = None,
240
+ ) -> IngestSegmentReport:
241
+ """Ingest a CMAF segment into `VideoStreamPackager` and simultaneously
242
+ emit a signed `MoQFrame` through `TransitReflectorManager`.
243
+
244
+ A captured segment (`captured_us`, UTC microseconds) is stamped with its capture time once, here
245
+ (`timed_segments.stamp_capture_time`), so the stored fiber and the relayed frame are the same bytes.
246
+ """
247
+ now_val = self._clock() if now is None else int(now)
248
+ box_info = parse_cmaf_boxes(segment_data)
249
+ if captured_us is not None:
250
+ segment_data = stamp_capture_time(segment_data, int(captured_us))
251
+ effective_keyframe = (
252
+ box_info['has_keyframe_hint'] if is_keyframe is None else bool(is_keyframe)
253
+ )
254
+
255
+ entry, op, addr = self.packager.ingest_segment(
256
+ stream_id=stream_id,
257
+ segment_data=segment_data,
258
+ duration_ms=duration_ms,
259
+ part_index=part_index,
260
+ is_keyframe=effective_keyframe,
261
+ is_audio_only=is_audio_only,
262
+ discontinuity=discontinuity,
263
+ now=now_val,
264
+ )
265
+
266
+ moq_fanned = False
267
+ moq_bytes = 0
268
+ sk_use = broadcaster_sk or self.broadcaster_sk
269
+ if self.reflector is not None and sk_use:
270
+ with self.packager._lock:
271
+ st_rec = self.packager._streams.get(stream_id)
272
+ broadcaster = (
273
+ st_rec['genesis'].broadcaster
274
+ if st_rec is not None
275
+ else (self.broadcaster_pk or '')
276
+ )
277
+ if broadcaster:
278
+ with self._lock:
279
+ grp = self._group_counters.get(stream_id, 0)
280
+ if effective_keyframe:
281
+ grp += 1
282
+ self._group_counters[stream_id] = grp
283
+ frame = MoQFrame(
284
+ stream_id=stream_id,
285
+ broadcaster=broadcaster,
286
+ group_id=max(1, grp),
287
+ object_id=int(part_index),
288
+ track='audio' if is_audio_only else 'video',
289
+ is_keyframe=effective_keyframe,
290
+ is_audio_only=is_audio_only,
291
+ payload=segment_data,
292
+ timestamp=now_val,
293
+ ).sign(sk_use)
294
+ ref_res: ReflectionResult = self.reflector.reflect_moq_frame(
295
+ frame,
296
+ grant_id=grant_id,
297
+ subscribers=subscribers,
298
+ )
299
+ moq_fanned = bool(ref_res['forwarded'])
300
+ moq_bytes = int(ref_res['bytes_fanned_out'])
301
+
302
+ return {
303
+ 'stream_id': stream_id,
304
+ 'segment_index': entry.segment_index,
305
+ 'sha256': addr['sha256'],
306
+ 'tree_root': addr['tree_root'],
307
+ 'cid': addr['cid'],
308
+ 'size': addr['size'],
309
+ 'op_id': op.id,
310
+ 'seq_num': op.seq_num,
311
+ 'moq_fanned_out': moq_fanned,
312
+ 'moq_bytes': moq_bytes,
313
+ }
314
+
315
+ def poll_spool_dir(
316
+ self,
317
+ stream_id: str,
318
+ spool_dir: str,
319
+ *,
320
+ duration_ms: int = 2000,
321
+ is_audio_only: bool = False,
322
+ grant_id: Optional[str] = None,
323
+ subscribers: Sequence[str] = (),
324
+ broadcaster_sk: Optional[str] = None,
325
+ now: Optional[int] = None,
326
+ ) -> SpoolPollReport:
327
+ """Scan `spool_dir` for `init.mp4` and completed `.m4s` CMAF segments,
328
+ ingesting any new files in deterministic lexical order.
329
+ Ignores `.tmp` files written by `ffmpeg -hls_flags temp_file`.
330
+ """
331
+ if not os.path.isdir(spool_dir):
332
+ return {
333
+ 'stream_id': stream_id,
334
+ 'init_updated': False,
335
+ 'ingested_files': [],
336
+ 'segments_ingested': 0,
337
+ }
338
+
339
+ with self._lock:
340
+ seen = self._seen_spool_files.setdefault(stream_id, set())
341
+
342
+ init_updated = False
343
+ init_path = os.path.join(spool_dir, 'init.mp4')
344
+ if os.path.isfile(init_path) and 'init.mp4' not in seen:
345
+ with open(init_path, 'rb') as fh:
346
+ init_bytes = fh.read()
347
+ if init_bytes:
348
+ init_addr: SegmentMultiAddress = self.packager.store_segment_bytes(init_bytes)
349
+ with self.packager._lock:
350
+ st_rec = self.packager._streams.get(stream_id)
351
+ if st_rec is not None:
352
+ old_gen = st_rec['genesis']
353
+ from fleetmesh_core.wire.video_transit import VideoStreamGenesis
354
+ st_rec['genesis'] = VideoStreamGenesis(
355
+ stream_id=old_gen.stream_id,
356
+ broadcaster=old_gen.broadcaster,
357
+ title=old_gen.title,
358
+ codecs=old_gen.codecs,
359
+ target_duration_ms=old_gen.target_duration_ms,
360
+ part_target_duration_ms=old_gen.part_target_duration_ms,
361
+ init_sha256=init_addr['sha256'],
362
+ init_tree_root=init_addr['tree_root'],
363
+ init_size=init_addr['size'],
364
+ transit_endpoints=old_gen.transit_endpoints,
365
+ price_msat_per_mb=old_gen.price_msat_per_mb,
366
+ account_fee_msat=old_gen.account_fee_msat,
367
+ created_at=old_gen.created_at,
368
+ )
369
+ seen.add('init.mp4')
370
+ init_updated = True
371
+
372
+ entries = sorted(os.listdir(spool_dir))
373
+ ingested: list[str] = []
374
+ for fname in entries:
375
+ if fname.endswith('.tmp') or not fname.endswith('.m4s'):
376
+ continue
377
+ if fname in seen:
378
+ continue
379
+ fpath = os.path.join(spool_dir, fname)
380
+ if not os.path.isfile(fpath):
381
+ continue
382
+ with open(fpath, 'rb') as fh:
383
+ seg_bytes = fh.read()
384
+ if not seg_bytes:
385
+ continue
386
+ # A chunk ffmpeg wrote is captured media: its capture time is when it was written, less its duration.
387
+ captured_us = os.stat(fpath).st_mtime_ns // 1000 - int(duration_ms) * 1000
388
+ self.ingest_live_chunk(
389
+ stream_id=stream_id,
390
+ segment_data=seg_bytes,
391
+ duration_ms=duration_ms,
392
+ is_audio_only=is_audio_only,
393
+ grant_id=grant_id,
394
+ subscribers=subscribers,
395
+ broadcaster_sk=broadcaster_sk,
396
+ now=now,
397
+ captured_us=captured_us,
398
+ )
399
+ seen.add(fname)
400
+ ingested.append(fname)
401
+
402
+ return {
403
+ 'stream_id': stream_id,
404
+ 'init_updated': init_updated,
405
+ 'ingested_files': ingested,
406
+ 'segments_ingested': len(ingested),
407
+ }
408
+
409
+ def mount_http_ingest(self, host: ProtocolHostRouter) -> None:
410
+ """Mount `POST /video/ingest/<stream_id>/<resource>` onto `ProtocolHostRouter`
411
+ so broadcasters or `ffmpeg -method POST` can push CMAF segments over HTTP.
412
+ """
413
+ from node.transport import ws
414
+
415
+ def _handle_ingest(conn: Any, req: Any) -> bool:
416
+ uri = getattr(req, 'path', '')
417
+ parsed = urllib.parse.urlparse(uri)
418
+ route_path = parsed.path
419
+ qparams = urllib.parse.parse_qs(parsed.query)
420
+
421
+ prefix = '/video/ingest/'
422
+ if not route_path.startswith(prefix):
423
+ return False
424
+ tail = route_path[len(prefix):].strip('/')
425
+ parts = [p for p in tail.split('/') if p]
426
+ if not parts:
427
+ ws.http_response(
428
+ conn,
429
+ '400 Bad Request',
430
+ b'{"error":"missing_stream_id"}',
431
+ 'application/json',
432
+ )
433
+ return True
434
+
435
+ stream_id = parts[0]
436
+ resource = parts[1] if len(parts) > 1 else 'chunk.m4s'
437
+ try:
438
+ body: bytes = ws.read_body(conn, req) or getattr(req, 'body', b'') or b''
439
+ except Exception:
440
+ body = getattr(req, 'body', b'') or b''
441
+ if not body:
442
+ ws.http_response(
443
+ conn,
444
+ '400 Bad Request',
445
+ b'{"error":"empty_segment_body"}',
446
+ 'application/json',
447
+ )
448
+ return True
449
+
450
+ with self.packager._lock:
451
+ exists = stream_id in self.packager._streams
452
+ if not exists:
453
+ broadcaster = qparams.get('broadcaster', [self.broadcaster_pk or ('a' * 64)])[0]
454
+ title = qparams.get('title', [f'Live Stream {stream_id}'])[0]
455
+ self.packager.create_stream(
456
+ stream_id=stream_id,
457
+ broadcaster=broadcaster,
458
+ title=title,
459
+ )
460
+
461
+ box_info = parse_cmaf_boxes(body)
462
+ if resource == 'init.mp4' or box_info['is_init']:
463
+ addr = self.packager.store_segment_bytes(body)
464
+ with self.packager._lock:
465
+ st_rec = self.packager._streams.get(stream_id)
466
+ if st_rec is not None:
467
+ old_gen = st_rec['genesis']
468
+ from fleetmesh_core.wire.video_transit import VideoStreamGenesis
469
+ st_rec['genesis'] = VideoStreamGenesis(
470
+ stream_id=old_gen.stream_id,
471
+ broadcaster=old_gen.broadcaster,
472
+ title=old_gen.title,
473
+ codecs=old_gen.codecs,
474
+ target_duration_ms=old_gen.target_duration_ms,
475
+ part_target_duration_ms=old_gen.part_target_duration_ms,
476
+ init_sha256=addr['sha256'],
477
+ init_tree_root=addr['tree_root'],
478
+ init_size=addr['size'],
479
+ transit_endpoints=old_gen.transit_endpoints,
480
+ price_msat_per_mb=old_gen.price_msat_per_mb,
481
+ account_fee_msat=old_gen.account_fee_msat,
482
+ created_at=old_gen.created_at,
483
+ )
484
+ payload = json.dumps({
485
+ 'ok': True,
486
+ 'kind': 'init',
487
+ 'stream_id': stream_id,
488
+ 'sha256': addr['sha256'],
489
+ 'tree_root': addr['tree_root'],
490
+ 'size': addr['size'],
491
+ }).encode('utf-8')
492
+ ws.http_response(conn, '200 OK', payload, 'application/json')
493
+ return True
494
+
495
+ duration_ms = int(qparams.get('duration_ms', ['2000'])[0])
496
+ is_audio_only = qparams.get('audio_only', ['0'])[0] == '1'
497
+ grant_id = qparams.get('grant_id', [''])[0] or None
498
+ rep = self.ingest_live_chunk(
499
+ stream_id=stream_id,
500
+ segment_data=body,
501
+ duration_ms=duration_ms,
502
+ is_audio_only=is_audio_only,
503
+ grant_id=grant_id,
504
+ )
505
+ ws.http_response(
506
+ conn,
507
+ '200 OK',
508
+ json.dumps({'ok': True, 'kind': 'segment', **rep}).encode('utf-8'),
509
+ 'application/json',
510
+ )
511
+ return True
512
+
513
+ host.mount_route('/video/ingest', _handle_ingest, methods=('POST', 'PUT'), owner='video')
514
+
515
+
516
+ __all__ = [
517
+ 'CMAFBoxInfo',
518
+ 'IngestSegmentReport',
519
+ 'SpoolPollReport',
520
+ 'parse_cmaf_boxes',
521
+ 'make_synthetic_cmaf_init',
522
+ 'make_synthetic_cmaf_segment',
523
+ 'LiveMediaIngestBridge',
524
+ ]