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,456 @@
|
|
|
1
|
+
"""Browser / Web Player UI Face for Fleetmesh Video Streaming (node.planes.services.video.player).
|
|
2
|
+
|
|
3
|
+
Mounts interactive HTML5 player and directory routes onto `ProtocolHost`:
|
|
4
|
+
- `GET /video/watch`: Live Broadcast Directory & Transit Status Dashboard.
|
|
5
|
+
- `GET /video/watch/<stream_id>`: Self-contained HTML5 + `hls.js` + WebCrypto
|
|
6
|
+
player that:
|
|
7
|
+
1. Verifies 1024-byte chunked `RhioBaoTree` Merkle roots (`X-Bao-Tree-Root`
|
|
8
|
+
and `#EXT-X-FLEETMESH-BAO-ROOT`) in-browser via `crypto.subtle.digest`.
|
|
9
|
+
2. Automatically follows `307 Temporary Redirect` Transit Reflector handovers
|
|
10
|
+
and surfaces active reflector endpoints in the telemetry HUD.
|
|
11
|
+
3. Intercepts HTTP `402 Payment Required` dual-item Cashu quotes (operator
|
|
12
|
+
`NUT-03` bandwidth + creator `NUT-11` P2PK) and attaches `X-Cashu` tokens.
|
|
13
|
+
4. Supports one-click fallback to `?audio_only=1` when soft debt degradation
|
|
14
|
+
is active.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import html
|
|
19
|
+
import json
|
|
20
|
+
import urllib.parse
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
from fleetmesh_core.wire.interfaces import ProtocolHostRouter
|
|
24
|
+
from .ledger import TransitLedgerStore
|
|
25
|
+
from .packager import VideoStreamPackager
|
|
26
|
+
from .transit import TransitReflectorManager
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def render_directory_html(
|
|
30
|
+
packager: VideoStreamPackager,
|
|
31
|
+
reflector: Optional[TransitReflectorManager] = None,
|
|
32
|
+
ledger: Optional[TransitLedgerStore] = None,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Render the `GET /video/watch` live stream directory and transit dashboard."""
|
|
35
|
+
with packager._lock:
|
|
36
|
+
streams = list(packager._streams.values())
|
|
37
|
+
|
|
38
|
+
cards_html: list[str] = []
|
|
39
|
+
for st in streams:
|
|
40
|
+
gen = st['genesis']
|
|
41
|
+
sid = html.escape(gen.stream_id)
|
|
42
|
+
title = html.escape(gen.title)
|
|
43
|
+
broadcaster = html.escape(gen.broadcaster[:20] + '...')
|
|
44
|
+
seg_count = len(st['segments'])
|
|
45
|
+
endpoints = st['transit_endpoints']
|
|
46
|
+
handover = bool(
|
|
47
|
+
endpoints or (reflector is not None and reflector.is_handover_active(gen.stream_id))
|
|
48
|
+
)
|
|
49
|
+
mode_badge = (
|
|
50
|
+
'<span class="badge badge-transit">Transit Reflector Handover</span>'
|
|
51
|
+
if handover
|
|
52
|
+
else '<span class="badge badge-origin">Origin Direct</span>'
|
|
53
|
+
)
|
|
54
|
+
econ_badge = (
|
|
55
|
+
f'<span class="badge badge-paid">Commercial ({gen.price_msat_per_mb} msat/MB + {gen.account_fee_msat} msat P2PK)</span>'
|
|
56
|
+
if (gen.price_msat_per_mb > 0 or gen.account_fee_msat > 0)
|
|
57
|
+
else '<span class="badge badge-barter">Pro-Bono / Barter IOU</span>'
|
|
58
|
+
)
|
|
59
|
+
cards_html.append(
|
|
60
|
+
f"""
|
|
61
|
+
<div class="stream-card" data-stream-id="{sid}">
|
|
62
|
+
<div class="card-header">
|
|
63
|
+
<h3><a href="/video/watch/{sid}">{title}</a></h3>
|
|
64
|
+
<div class="badges">{mode_badge} {econ_badge}</div>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="card-meta">
|
|
67
|
+
<span>Stream ID: <code>{sid}</code></span>
|
|
68
|
+
<span>Broadcaster: <code>{broadcaster}</code></span>
|
|
69
|
+
<span>Segments: <strong>{seg_count}</strong> (seq #{st['latest_seq']})</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="card-actions">
|
|
72
|
+
<a class="btn" href="/video/watch/{sid}">Open Web Player</a>
|
|
73
|
+
<a class="btn btn-secondary" href="/video/live/{sid}/index.m3u8">HLS Manifest (.m3u8)</a>
|
|
74
|
+
<a class="btn btn-secondary" href="/video/live/{sid}/index.m3u8?audio_only=1">Audio-Only (.m3u8)</a>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
"""
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
empty_state = (
|
|
81
|
+
'<p class="empty">No active live streams on this node yet. '
|
|
82
|
+
'Use <code>meshnode stream start</code> or <code>POST /video/ingest/<stream_id>/chunk.m4s</code>.</p>'
|
|
83
|
+
if not cards_html
|
|
84
|
+
else '\n'.join(cards_html)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
debts_rows: list[str] = []
|
|
88
|
+
if ledger is not None:
|
|
89
|
+
summary = ledger.summary()
|
|
90
|
+
for d in summary.get('debts', []):
|
|
91
|
+
debtor_short = html.escape(str(d.get('debtor', ''))[:20])
|
|
92
|
+
gross = int(d.get('gross_transit_bytes', 0))
|
|
93
|
+
bartered = int(d.get('bartered_bytes', 0))
|
|
94
|
+
unsettled = int(d.get('unsettled_bytes', 0))
|
|
95
|
+
degraded = bool(d.get('degraded_audio_only', False))
|
|
96
|
+
q_badge = 'Degraded (Audio-Only)' if degraded else 'Full Quality'
|
|
97
|
+
debts_rows.append(
|
|
98
|
+
f'<tr><td><code>{debtor_short}</code></td>'
|
|
99
|
+
f'<td>{gross} B</td><td>{bartered} B</td>'
|
|
100
|
+
f'<td>{unsettled} B</td><td>{q_badge}</td></tr>'
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
ledger_table = (
|
|
104
|
+
'<table class="ledger-table"><thead><tr>'
|
|
105
|
+
'<th>Debtor Peer</th><th>Gross Transit</th><th>Barter Credited</th>'
|
|
106
|
+
'<th>Unsettled Debt</th><th>Stream Quality</th></tr></thead><tbody>'
|
|
107
|
+
+ (''.join(debts_rows) if debts_rows else '<tr><td colspan="5">No active transit debt records</td></tr>')
|
|
108
|
+
+ '</tbody></table>'
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return f"""<!DOCTYPE html>
|
|
112
|
+
<html lang="en">
|
|
113
|
+
<head>
|
|
114
|
+
<meta charset="utf-8" />
|
|
115
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
116
|
+
<title>Fleetmesh Video Streaming & Transit Reflector</title>
|
|
117
|
+
<style>
|
|
118
|
+
:root {{
|
|
119
|
+
--bg: #0b0f17;
|
|
120
|
+
--panel: #131b2e;
|
|
121
|
+
--border: #243252;
|
|
122
|
+
--text: #e2e8f0;
|
|
123
|
+
--muted: #94a3b8;
|
|
124
|
+
--accent: #38bdf8;
|
|
125
|
+
--green: #22c55e;
|
|
126
|
+
--amber: #f59e0b;
|
|
127
|
+
}}
|
|
128
|
+
body {{
|
|
129
|
+
margin: 0; padding: 2rem; background: var(--bg); color: var(--text);
|
|
130
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
|
|
131
|
+
}}
|
|
132
|
+
header {{
|
|
133
|
+
border-bottom: 1px solid var(--border); padding-bottom: 1rem; margin-bottom: 1.5rem;
|
|
134
|
+
}}
|
|
135
|
+
.stream-card {{
|
|
136
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
|
137
|
+
padding: 1.25rem; margin-bottom: 1rem;
|
|
138
|
+
}}
|
|
139
|
+
.card-header {{ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; }}
|
|
140
|
+
.card-header h3 {{ margin: 0; }}
|
|
141
|
+
.card-header a {{ color: var(--accent); text-decoration: none; }}
|
|
142
|
+
.card-meta {{ display: flex; gap: 1.5rem; color: var(--muted); margin: 0.75rem 0; font-size: 0.9rem; flex-wrap: wrap; }}
|
|
143
|
+
.badge {{ padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }}
|
|
144
|
+
.badge-origin {{ background: rgba(34, 197, 94, 0.15); color: var(--green); }}
|
|
145
|
+
.badge-transit {{ background: rgba(245, 158, 11, 0.15); color: var(--amber); }}
|
|
146
|
+
.badge-barter {{ background: rgba(56, 189, 248, 0.15); color: var(--accent); }}
|
|
147
|
+
.badge-paid {{ background: rgba(168, 85, 247, 0.2); color: #c084fc; }}
|
|
148
|
+
.btn {{
|
|
149
|
+
display: inline-block; padding: 0.45rem 0.9rem; background: var(--accent);
|
|
150
|
+
color: #0b0f17; border-radius: 5px; text-decoration: none; font-weight: 600;
|
|
151
|
+
font-size: 0.85rem; margin-right: 0.5rem;
|
|
152
|
+
}}
|
|
153
|
+
.btn-secondary {{ background: transparent; color: var(--text); border: 1px solid var(--border); }}
|
|
154
|
+
.ledger-table {{ width: 100%; border-collapse: collapse; margin-top: 1rem; background: var(--panel); }}
|
|
155
|
+
.ledger-table th, .ledger-table td {{ border: 1px solid var(--border); padding: 0.6rem 0.8rem; text-align: left; font-size: 0.85rem; }}
|
|
156
|
+
</style>
|
|
157
|
+
</head>
|
|
158
|
+
<body>
|
|
159
|
+
<header>
|
|
160
|
+
<h1>Fleetmesh Decentralized Video & Transit Reflector</h1>
|
|
161
|
+
<p>Content-Addressed CMAF / LL-HLS • 1024-Byte RhioBaoTree Merkle Verification • Bilateral Transit Barter & Cashu 402</p>
|
|
162
|
+
</header>
|
|
163
|
+
<section>
|
|
164
|
+
<h2>Active Broadcast Streams ({len(streams)})</h2>
|
|
165
|
+
{empty_state}
|
|
166
|
+
</section>
|
|
167
|
+
<section style="margin-top: 2rem;">
|
|
168
|
+
<h2>Bilateral Transit IOU & Bartered Utility Ledger</h2>
|
|
169
|
+
{ledger_table}
|
|
170
|
+
</section>
|
|
171
|
+
</body>
|
|
172
|
+
</html>
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render_player_html(
|
|
177
|
+
stream_id: str,
|
|
178
|
+
packager: VideoStreamPackager,
|
|
179
|
+
reflector: Optional[TransitReflectorManager] = None,
|
|
180
|
+
) -> Optional[str]:
|
|
181
|
+
"""Render the `GET /video/watch/<stream_id>` cryptographic web player page."""
|
|
182
|
+
with packager._lock:
|
|
183
|
+
st = packager._streams.get(stream_id)
|
|
184
|
+
if st is None:
|
|
185
|
+
return None
|
|
186
|
+
gen = st['genesis']
|
|
187
|
+
endpoints = list(st['transit_endpoints'])
|
|
188
|
+
seg_count = len(st['segments'])
|
|
189
|
+
latest_seq = st['latest_seq']
|
|
190
|
+
|
|
191
|
+
sid_esc = html.escape(gen.stream_id)
|
|
192
|
+
stream_id_js = json.dumps(gen.stream_id).replace('</', '<\\/')
|
|
193
|
+
title_esc = html.escape(gen.title)
|
|
194
|
+
broadcaster_esc = html.escape(gen.broadcaster)
|
|
195
|
+
handover_active = bool(
|
|
196
|
+
endpoints or (reflector is not None and reflector.is_handover_active(stream_id))
|
|
197
|
+
)
|
|
198
|
+
active_reflector = html.escape(endpoints[0]) if endpoints else 'None (Serving direct from Origin)'
|
|
199
|
+
is_paid = gen.price_msat_per_mb > 0 or gen.account_fee_msat > 0
|
|
200
|
+
|
|
201
|
+
return f"""<!DOCTYPE html>
|
|
202
|
+
<html lang="en">
|
|
203
|
+
<head>
|
|
204
|
+
<meta charset="utf-8" />
|
|
205
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
206
|
+
<title>{title_esc} — Fleetmesh Web Player</title>
|
|
207
|
+
<style>
|
|
208
|
+
:root {{
|
|
209
|
+
--bg: #0b0f17;
|
|
210
|
+
--panel: #131b2e;
|
|
211
|
+
--border: #243252;
|
|
212
|
+
--text: #e2e8f0;
|
|
213
|
+
--muted: #94a3b8;
|
|
214
|
+
--accent: #38bdf8;
|
|
215
|
+
--green: #22c55e;
|
|
216
|
+
--amber: #f59e0b;
|
|
217
|
+
--red: #ef4444;
|
|
218
|
+
}}
|
|
219
|
+
body {{
|
|
220
|
+
margin: 0; padding: 1.5rem; background: var(--bg); color: var(--text);
|
|
221
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
|
|
222
|
+
}}
|
|
223
|
+
.layout {{ display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; max-width: 1280px; margin: 0 auto; }}
|
|
224
|
+
@media (max-width: 900px) {{ .layout {{ grid-template-columns: 1fr; }} }}
|
|
225
|
+
.player-box, .hud-box {{
|
|
226
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem;
|
|
227
|
+
}}
|
|
228
|
+
video {{ width: 100%; background: #000; border-radius: 6px; aspect-ratio: 16 / 9; }}
|
|
229
|
+
.hud-row {{ display: flex; justify-content: space-between; padding: 0.45rem 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; }}
|
|
230
|
+
.status-ok {{ color: var(--green); font-weight: 600; }}
|
|
231
|
+
.status-warn {{ color: var(--amber); font-weight: 600; }}
|
|
232
|
+
.cashu-drawer {{
|
|
233
|
+
margin-top: 1rem; padding: 0.9rem; border: 1px solid var(--border); border-radius: 6px; background: rgba(15, 23, 42, 0.7);
|
|
234
|
+
}}
|
|
235
|
+
input[type="text"] {{
|
|
236
|
+
width: 100%; box-sizing: border-box; padding: 0.5rem; margin-top: 0.4rem;
|
|
237
|
+
background: #0b0f17; border: 1px solid var(--border); color: var(--text); border-radius: 4px;
|
|
238
|
+
}}
|
|
239
|
+
button {{
|
|
240
|
+
margin-top: 0.5rem; padding: 0.45rem 0.9rem; background: var(--accent); color: #0b0f17;
|
|
241
|
+
border: none; border-radius: 4px; font-weight: 600; cursor: pointer;
|
|
242
|
+
}}
|
|
243
|
+
</style>
|
|
244
|
+
</head>
|
|
245
|
+
<body>
|
|
246
|
+
<div style="max-width: 1280px; margin: 0 auto 1rem auto;">
|
|
247
|
+
<a href="/video/watch" style="color: var(--accent); text-decoration: none;">← All Live Streams</a>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="layout">
|
|
250
|
+
<div class="player-box">
|
|
251
|
+
<h2 style="margin-top: 0;">{title_esc}</h2>
|
|
252
|
+
<video id="video-player" controls autoplay muted playsinline
|
|
253
|
+
data-manifest-url="/video/live/{sid_esc}/index.m3u8"></video>
|
|
254
|
+
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
|
255
|
+
<button id="btn-verify-next" type="button" onclick="FleetmeshPlayer.verifyLatestSegment()">Verify Latest Segment (Bao Merkle)</button>
|
|
256
|
+
<button id="btn-audio-only" type="button" onclick="FleetmeshPlayer.toggleAudioOnly()">Toggle Audio-Only Fallback</button>
|
|
257
|
+
</div>
|
|
258
|
+
<div class="cashu-drawer" id="cashu-drawer">
|
|
259
|
+
<strong>HTTP 402 Cashu Settlement Token (X-Cashu)</strong>
|
|
260
|
+
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 0.25rem;">
|
|
261
|
+
Pricing: {gen.price_msat_per_mb} msat/MB (NUT-03 operator bandwidth) + {gen.account_fee_msat} msat (NUT-11 creator P2PK)
|
|
262
|
+
</div>
|
|
263
|
+
<input type="text" id="cashu-token-input" placeholder="cashuAey..." />
|
|
264
|
+
<div id="cashu-quote-status" style="font-size: 0.8rem; margin-top: 0.4rem; color: var(--amber);"></div>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div class="hud-box">
|
|
269
|
+
<h3 style="margin-top: 0;">Cryptographic & Transit Telemetry</h3>
|
|
270
|
+
<div class="hud-row"><span>Stream ID</span><code>{sid_esc}</code></div>
|
|
271
|
+
<div class="hud-row"><span>Broadcaster</span><code title="{broadcaster_esc}">{broadcaster_esc[:18]}...</code></div>
|
|
272
|
+
<div class="hud-row"><span>p2panda Manifest Seq</span><strong id="hud-seq">#{latest_seq} ({seg_count} segments)</strong></div>
|
|
273
|
+
<div class="hud-row">
|
|
274
|
+
<span>Delivery Path</span>
|
|
275
|
+
<span id="hud-delivery" class="{'status-warn' if handover_active else 'status-ok'}">
|
|
276
|
+
{'Transit Reflector (307 Handover)' if handover_active else 'Origin Direct'}
|
|
277
|
+
</span>
|
|
278
|
+
</div>
|
|
279
|
+
<div class="hud-row"><span>Active Reflector</span><code id="hud-reflector">{active_reflector}</code></div>
|
|
280
|
+
<div class="hud-row"><span>Bao Sub-Chunk Size</span><code>1024 B (RhioBaoTree)</code></div>
|
|
281
|
+
<div class="hud-row"><span>Verified Segments</span><span id="hud-verified-count" class="status-ok">0 verified</span></div>
|
|
282
|
+
<div class="hud-row"><span>Last X-Bao-Tree-Root</span><code id="hud-bao-root">pending...</code></div>
|
|
283
|
+
<div class="hud-row"><span>Settlement Mode</span><span>{'Commercial 402' if is_paid else 'Pro-Bono / Barter'}</span></div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<script>
|
|
288
|
+
const FleetmeshPlayer = (() => {{
|
|
289
|
+
const streamId = {stream_id_js};
|
|
290
|
+
let audioOnly = false;
|
|
291
|
+
let verifiedCount = 0;
|
|
292
|
+
|
|
293
|
+
function toHex(buf) {{
|
|
294
|
+
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
295
|
+
}}
|
|
296
|
+
|
|
297
|
+
function fromHex(hex) {{
|
|
298
|
+
const out = new Uint8Array(hex.length / 2);
|
|
299
|
+
for (let i = 0; i < out.length; i++) {{
|
|
300
|
+
out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
301
|
+
}}
|
|
302
|
+
return out;
|
|
303
|
+
}}
|
|
304
|
+
|
|
305
|
+
// Exact in-browser RhioBaoTree 1024-byte Merkle root verifier matching node/protocols/rhio/store.py
|
|
306
|
+
async function computeBaoTreeRoot(arrayBuffer) {{
|
|
307
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
308
|
+
const CHUNK_SIZE = 1024;
|
|
309
|
+
const leaves = [];
|
|
310
|
+
if (bytes.length === 0) {{
|
|
311
|
+
const emptyLeaf = new Uint8Array(1);
|
|
312
|
+
emptyLeaf[0] = 0x00;
|
|
313
|
+
const h = await crypto.subtle.digest('SHA-256', emptyLeaf);
|
|
314
|
+
return toHex(h);
|
|
315
|
+
}}
|
|
316
|
+
for (let offset = 0; offset < bytes.length; offset += CHUNK_SIZE) {{
|
|
317
|
+
const slice = bytes.subarray(offset, Math.min(offset + CHUNK_SIZE, bytes.length));
|
|
318
|
+
const buf = new Uint8Array(1 + slice.length);
|
|
319
|
+
buf[0] = 0x00;
|
|
320
|
+
buf.set(slice, 1);
|
|
321
|
+
const leafHash = await crypto.subtle.digest('SHA-256', buf);
|
|
322
|
+
leaves.push(new Uint8Array(leafHash));
|
|
323
|
+
}}
|
|
324
|
+
let level = leaves;
|
|
325
|
+
while (level.length > 1) {{
|
|
326
|
+
const nextLevel = [];
|
|
327
|
+
for (let i = 0; i < level.length; i += 2) {{
|
|
328
|
+
const left = level[i];
|
|
329
|
+
const right = (i + 1 < level.length) ? level[i + 1] : left;
|
|
330
|
+
const combined = new Uint8Array(1 + 32 + 32);
|
|
331
|
+
combined[0] = 0x01;
|
|
332
|
+
combined.set(left, 1);
|
|
333
|
+
combined.set(right, 33);
|
|
334
|
+
const parentHash = await crypto.subtle.digest('SHA-256', combined);
|
|
335
|
+
nextLevel.push(new Uint8Array(parentHash));
|
|
336
|
+
}}
|
|
337
|
+
level = nextLevel;
|
|
338
|
+
}}
|
|
339
|
+
return toHex(level[0]);
|
|
340
|
+
}}
|
|
341
|
+
|
|
342
|
+
async function fetchSegmentWithVerification(segUri) {{
|
|
343
|
+
const headers = {{}};
|
|
344
|
+
const tokenInput = document.getElementById('cashu-token-input');
|
|
345
|
+
if (tokenInput && tokenInput.value.trim()) {{
|
|
346
|
+
headers['X-Cashu'] = tokenInput.value.trim();
|
|
347
|
+
}}
|
|
348
|
+
const resp = await fetch(segUri, {{ headers, redirect: 'follow' }});
|
|
349
|
+
if (resp.redirected) {{
|
|
350
|
+
document.getElementById('hud-delivery').textContent = 'Transit Reflector (307 Handover)';
|
|
351
|
+
document.getElementById('hud-reflector').textContent = resp.url;
|
|
352
|
+
}}
|
|
353
|
+
if (resp.status === 402) {{
|
|
354
|
+
const quote = await resp.json().catch(() => ({{}}));
|
|
355
|
+
document.getElementById('cashu-quote-status').textContent =
|
|
356
|
+
'402 Payment Required: ' + JSON.stringify(quote);
|
|
357
|
+
return false;
|
|
358
|
+
}}
|
|
359
|
+
if (!resp.ok) return false;
|
|
360
|
+
const expectedRoot = (resp.headers.get('X-Bao-Tree-Root') || '').toLowerCase();
|
|
361
|
+
const buf = await resp.arrayBuffer();
|
|
362
|
+
const computedRoot = await computeBaoTreeRoot(buf);
|
|
363
|
+
if (expectedRoot && computedRoot !== expectedRoot) {{
|
|
364
|
+
document.getElementById('hud-bao-root').textContent = 'MISMATCH!';
|
|
365
|
+
return false;
|
|
366
|
+
}}
|
|
367
|
+
verifiedCount += 1;
|
|
368
|
+
document.getElementById('hud-verified-count').textContent = verifiedCount + ' verified';
|
|
369
|
+
document.getElementById('hud-bao-root').textContent = computedRoot.slice(0, 20) + '...';
|
|
370
|
+
return true;
|
|
371
|
+
}}
|
|
372
|
+
|
|
373
|
+
async function verifyLatestSegment() {{
|
|
374
|
+
const m3u8Url = `/video/live/${{streamId}}/index.m3u8` + (audioOnly ? '?audio_only=1' : '');
|
|
375
|
+
const resp = await fetch(m3u8Url);
|
|
376
|
+
if (!resp.ok) return false;
|
|
377
|
+
const text = await resp.text();
|
|
378
|
+
const lines = text.split('\\n').map(l => l.trim()).filter(Boolean);
|
|
379
|
+
const segLines = lines.filter(l => !l.startsWith('#') && l.endsWith('.m4s'));
|
|
380
|
+
if (segLines.length === 0) return false;
|
|
381
|
+
const latestSeg = segLines[segLines.length - 1];
|
|
382
|
+
return await fetchSegmentWithVerification(`/video/live/${{streamId}}/${{latestSeg}}`);
|
|
383
|
+
}}
|
|
384
|
+
|
|
385
|
+
function toggleAudioOnly() {{
|
|
386
|
+
audioOnly = !audioOnly;
|
|
387
|
+
const video = document.getElementById('video-player');
|
|
388
|
+
const url = `/video/live/${{streamId}}/index.m3u8` + (audioOnly ? '?audio_only=1' : '');
|
|
389
|
+
video.src = url;
|
|
390
|
+
}}
|
|
391
|
+
|
|
392
|
+
if (typeof document !== 'undefined') {{
|
|
393
|
+
document.addEventListener('DOMContentLoaded', () => {{
|
|
394
|
+
const video = document.getElementById('video-player');
|
|
395
|
+
if (video && !video.getAttribute('src')) {{
|
|
396
|
+
video.src = `/video/live/${{streamId}}/index.m3u8`;
|
|
397
|
+
}}
|
|
398
|
+
}});
|
|
399
|
+
}}
|
|
400
|
+
|
|
401
|
+
return {{ computeBaoTreeRoot, fetchSegmentWithVerification, verifyLatestSegment, toggleAudioOnly }};
|
|
402
|
+
}})();
|
|
403
|
+
</script>
|
|
404
|
+
</body>
|
|
405
|
+
</html>
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def mount_player_routes(
|
|
410
|
+
host: ProtocolHostRouter,
|
|
411
|
+
packager: VideoStreamPackager,
|
|
412
|
+
reflector: Optional[TransitReflectorManager] = None,
|
|
413
|
+
ledger: Optional[TransitLedgerStore] = None,
|
|
414
|
+
) -> None:
|
|
415
|
+
"""Mount `GET /video/watch` and `GET /video/watch/<stream_id>` onto `ProtocolHostRouter`."""
|
|
416
|
+
from node.transport import ws
|
|
417
|
+
|
|
418
|
+
def _handle_watch(conn: Any, req: Any) -> bool:
|
|
419
|
+
uri = getattr(req, 'path', '')
|
|
420
|
+
parsed = urllib.parse.urlparse(uri)
|
|
421
|
+
route_path = parsed.path.rstrip('/')
|
|
422
|
+
|
|
423
|
+
if route_path == '/video/watch':
|
|
424
|
+
page = render_directory_html(packager, reflector=reflector, ledger=ledger)
|
|
425
|
+
ws.http_response(conn, '200 OK', page.encode('utf-8'), 'text/html; charset=utf-8')
|
|
426
|
+
return True
|
|
427
|
+
|
|
428
|
+
prefix = '/video/watch/'
|
|
429
|
+
if not parsed.path.startswith(prefix):
|
|
430
|
+
return False
|
|
431
|
+
stream_id = parsed.path[len(prefix):].strip('/').split('/')[0]
|
|
432
|
+
if not stream_id:
|
|
433
|
+
page = render_directory_html(packager, reflector=reflector, ledger=ledger)
|
|
434
|
+
ws.http_response(conn, '200 OK', page.encode('utf-8'), 'text/html; charset=utf-8')
|
|
435
|
+
return True
|
|
436
|
+
|
|
437
|
+
player_html = render_player_html(stream_id, packager, reflector=reflector)
|
|
438
|
+
if player_html is None:
|
|
439
|
+
ws.http_response(
|
|
440
|
+
conn,
|
|
441
|
+
'404 Not Found',
|
|
442
|
+
b'{"error":"stream_not_found"}',
|
|
443
|
+
'application/json',
|
|
444
|
+
)
|
|
445
|
+
return True
|
|
446
|
+
ws.http_response(conn, '200 OK', player_html.encode('utf-8'), 'text/html; charset=utf-8')
|
|
447
|
+
return True
|
|
448
|
+
|
|
449
|
+
host.mount_route('/video/watch', _handle_watch, methods=('GET',), owner='video')
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
__all__ = [
|
|
453
|
+
'render_directory_html',
|
|
454
|
+
'render_player_html',
|
|
455
|
+
'mount_player_routes',
|
|
456
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Timed segments: a captured segment carries its capture time on the shared clock inside its own bytes.
|
|
2
|
+
|
|
3
|
+
MeshTube's textile model (`meshtube/docs/textile-spools.md`) asks that every fiber spun live carry its
|
|
4
|
+
moment on a shared clock (UTC, microseconds) within its bytes, so it stands on its own whatever strand or
|
|
5
|
+
spool it is in, and its content address covers the time.
|
|
6
|
+
|
|
7
|
+
- A fragmented ISO-BMFF (CMAF) segment gets a standard Producer Reference Time box (`prft`, ISO/IEC 14496-12)
|
|
8
|
+
just before its first `moof`: the UTC time (NTP format) of the media time that fragment starts at
|
|
9
|
+
(`tfdt`), for its first track (`tfhd`). The segment stays plain CMAF; players skip the box.
|
|
10
|
+
- Anything else is framed as a MeshTube timed fiber: `"MTF1" | u32 header length | header | payload`, the
|
|
11
|
+
header canonical JSON `{"clock":"utc","t_us":...,"v":1}` (byte for byte MeshTube's `timedFiberBytes`).
|
|
12
|
+
|
|
13
|
+
Only *captured* segments are stamped. Re-aired segments (VOD playlists, ad breaks) are existing fibers:
|
|
14
|
+
when they air is the spool's business, and stamping them would store a copy per airing.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import struct
|
|
20
|
+
from typing import Iterator, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
NTP_UNIX_OFFSET = 2_208_988_800
|
|
23
|
+
MTF_MAGIC = b'MTF1'
|
|
24
|
+
_CONTAINER_BOXES = {b'moof', b'traf'}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _boxes(data: bytes, start: int = 0, end: Optional[int] = None) -> Iterator[Tuple[bytes, int, int, int]]:
|
|
28
|
+
"""`(type, box_start, payload_start, box_end)` of each box in `data[start:end]`; stops at a malformed box."""
|
|
29
|
+
end = len(data) if end is None else end
|
|
30
|
+
pos = start
|
|
31
|
+
while pos + 8 <= end:
|
|
32
|
+
size, kind = struct.unpack('>I4s', data[pos:pos + 8])
|
|
33
|
+
header = 8
|
|
34
|
+
if size == 1:
|
|
35
|
+
if pos + 16 > end:
|
|
36
|
+
return
|
|
37
|
+
size = struct.unpack('>Q', data[pos + 8:pos + 16])[0]
|
|
38
|
+
header = 16
|
|
39
|
+
elif size == 0:
|
|
40
|
+
size = end - pos
|
|
41
|
+
if size < header or pos + size > end:
|
|
42
|
+
return
|
|
43
|
+
yield kind, pos, pos + header, pos + size
|
|
44
|
+
pos += size
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_fragmented_bmff(data: bytes) -> bool:
|
|
48
|
+
kinds = [k for k, *_ in _boxes(data)]
|
|
49
|
+
return bool(kinds) and b'moof' in kinds and all(k.isalnum() or k in (b'prft',) for k in kinds)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _first_fragment(data: bytes) -> Optional[Tuple[int, int, int]]:
|
|
53
|
+
"""`(moof_start, track_id, base_media_decode_time)` of the segment's first fragment; a fragment that names
|
|
54
|
+
no track or decode time refers to track 1 at media time 0, so any `moof` can take a `prft`."""
|
|
55
|
+
for kind, start, body, end in _boxes(data):
|
|
56
|
+
if kind != b'moof':
|
|
57
|
+
continue
|
|
58
|
+
for tkind, _ts, tbody, tend in _boxes(data, body, end):
|
|
59
|
+
if tkind != b'traf':
|
|
60
|
+
continue
|
|
61
|
+
track_id, media_time = None, 0
|
|
62
|
+
for ckind, _cs, cbody, cend in _boxes(data, tbody, tend):
|
|
63
|
+
if ckind == b'tfhd' and cend - cbody >= 8:
|
|
64
|
+
track_id = struct.unpack('>I', data[cbody + 4:cbody + 8])[0]
|
|
65
|
+
elif ckind == b'tfdt' and cend - cbody >= 8:
|
|
66
|
+
version = data[cbody]
|
|
67
|
+
media_time = struct.unpack('>Q', data[cbody + 4:cbody + 12])[0] if version == 1 and cend - cbody >= 12 \
|
|
68
|
+
else struct.unpack('>I', data[cbody + 4:cbody + 8])[0]
|
|
69
|
+
if track_id is not None:
|
|
70
|
+
return start, track_id, media_time
|
|
71
|
+
return start, 1, 0
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ntp(t_us: int) -> Tuple[int, int]:
|
|
76
|
+
secs, frac_us = divmod(int(t_us), 1_000_000)
|
|
77
|
+
return secs + NTP_UNIX_OFFSET, (frac_us << 32) // 1_000_000
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def prft_box(t_us: int, track_id: int, media_time: int) -> bytes:
|
|
81
|
+
"""A version-1 `prft` box: flags 0 (the time the referenced sample entered the encoder: its capture)."""
|
|
82
|
+
ntp_secs, ntp_frac = _ntp(t_us)
|
|
83
|
+
return struct.pack('>I4sB3sIIIQ', 32, b'prft', 1, b'\x00\x00\x00', track_id, ntp_secs, ntp_frac, media_time)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def timed_fiber_bytes(t_us: int, payload: bytes, *, mime: str = '') -> bytes:
|
|
87
|
+
header = {'clock': 'utc', 't_us': int(t_us), 'v': 1}
|
|
88
|
+
if mime:
|
|
89
|
+
header['mime'] = mime
|
|
90
|
+
raw = json.dumps(header, sort_keys=True, separators=(',', ':')).encode('utf-8')
|
|
91
|
+
return MTF_MAGIC + struct.pack('>I', len(raw)) + raw + bytes(payload)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def stamp_capture_time(segment: bytes, captured_us: int, *, mime: str = 'video/mp4') -> bytes:
|
|
95
|
+
"""The captured segment with its capture time inside: a `prft` box for CMAF, else timed-fiber framing.
|
|
96
|
+
|
|
97
|
+
A segment already carrying a capture time is returned unchanged."""
|
|
98
|
+
if read_capture_time(segment) is not None:
|
|
99
|
+
return segment
|
|
100
|
+
if _is_fragmented_bmff(segment):
|
|
101
|
+
frag = _first_fragment(segment)
|
|
102
|
+
if frag is not None:
|
|
103
|
+
moof_start, track_id, media_time = frag
|
|
104
|
+
return segment[:moof_start] + prft_box(captured_us, track_id, media_time) + segment[moof_start:]
|
|
105
|
+
return timed_fiber_bytes(captured_us, segment, mime=mime)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def read_capture_time(fiber: bytes) -> Optional[int]:
|
|
109
|
+
"""A fiber's capture time (UTC µs) from its `prft` box or timed-fiber header; `None` when it has none."""
|
|
110
|
+
if fiber[:4] == MTF_MAGIC and len(fiber) >= 8:
|
|
111
|
+
(length,) = struct.unpack('>I', fiber[4:8])
|
|
112
|
+
try:
|
|
113
|
+
header = json.loads(fiber[8:8 + length].decode('utf-8'))
|
|
114
|
+
except (UnicodeDecodeError, ValueError):
|
|
115
|
+
return None
|
|
116
|
+
t_us = header.get('t_us') if isinstance(header, dict) and header.get('clock') == 'utc' else None
|
|
117
|
+
return t_us if isinstance(t_us, int) else None
|
|
118
|
+
for kind, _start, body, end in _boxes(fiber):
|
|
119
|
+
if kind == b'prft' and end - body >= 20:
|
|
120
|
+
ntp_secs, ntp_frac = struct.unpack('>II', fiber[body + 8:body + 16])
|
|
121
|
+
return (ntp_secs - NTP_UNIX_OFFSET) * 1_000_000 + ((ntp_frac * 1_000_000 + (1 << 31)) >> 32)
|
|
122
|
+
if kind == b'moof':
|
|
123
|
+
return None
|
|
124
|
+
return None
|