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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- 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
|
+
]
|