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,898 @@
1
+ """FfmpegTranscodeExecutor: deterministic & progressive media transcoding on the Mesh Compute plane.
2
+
3
+ Executes NIP-90 kind=5201 and ComputeWire 1.1 video/audio transcode jobs on any
4
+ meshnode with `ffmpeg` installed (or in mock_mode for deterministic unit tests).
5
+ Verifies input and output blobs using 1024-byte `RhioBaoTree` Merkle roots.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import hashlib
11
+ import ipaddress
12
+ import json
13
+ import os
14
+ import shutil
15
+ import socket
16
+ import ssl
17
+ import subprocess
18
+ import tempfile
19
+ import time
20
+ import urllib.parse
21
+ import urllib.request
22
+ from typing import Any, Callable, Mapping, Optional, TypedDict
23
+
24
+ from fleetmesh_core.crypto import crypto
25
+ from node.protocols.rhio.store import RhioBaoTree
26
+ from ..common import ComputeError
27
+ from ..sandbox import ComputeLimits
28
+ from .base import ComputeExecutionResult, ComputeExecutor, ComputeQuote
29
+
30
+ FFMPEG_PROFILE_DIGEST: str = crypto.sha256(b'fleetmesh-ffmpeg-transcode-v1')
31
+ DETERMINISTIC_FFMPEG_THREADS: int = 1
32
+ BITEXACT_MUX_FLAGS: tuple[str, ...] = (
33
+ '-fflags', '+bitexact',
34
+ '-flags:v', '+bitexact',
35
+ '-flags:a', '+bitexact',
36
+ '-map_metadata', '-1',
37
+ '-map_chapters', '-1',
38
+ )
39
+ MAX_MEDIA_DOWNLOAD_BYTES: int = 256 * 1024 * 1024
40
+ # Verified downloads kept for reuse, oldest evicted past this total.
41
+ MEDIA_CACHE_MAX_BYTES: int = 2 * 1024 * 1024 * 1024
42
+
43
+
44
+ class MediaProfileSpec(TypedDict):
45
+ width: int
46
+ height: int
47
+ video_bitrate_kbps: int
48
+ audio_sample_rate_hz: int
49
+ audio_bitrate_kbps: int
50
+ is_audio: bool
51
+ fuel_per_sec: int
52
+
53
+
54
+ class MediaProbeResult(TypedDict, total=False):
55
+ video_codec: str
56
+ pix_fmt: str
57
+ audio_codec: str
58
+ has_audio: bool
59
+ width: int
60
+ height: int
61
+ duration_sec: float
62
+ format_name: str
63
+
64
+
65
+ def _validate_remote_media_url(url: str, *, allow_local: bool = False) -> None:
66
+ """Validate that an HTTP(S) media source URL does not target loopback, link-local, or private IPs."""
67
+ parsed = urllib.parse.urlparse(url)
68
+ if parsed.scheme not in ('http', 'https'):
69
+ raise ComputeError(f'unsupported media URL scheme: {parsed.scheme!r}')
70
+ host = (parsed.hostname or '').strip()
71
+ if not host:
72
+ raise ComputeError('media source_url is missing hostname')
73
+ if allow_local:
74
+ return
75
+ if host.lower() in ('localhost', 'localhost.localdomain'):
76
+ raise ComputeError(f'refusing local media source_url host: {host}')
77
+ try:
78
+ addrs = [ipaddress.ip_address(host)]
79
+ except ValueError:
80
+ try:
81
+ infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == 'https' else 80), proto=socket.IPPROTO_TCP)
82
+ except OSError as exc:
83
+ raise ComputeError(f'failed to resolve media source_url host {host}: {exc}') from exc
84
+ addrs = [ipaddress.ip_address(info[4][0]) for info in infos if info[4]]
85
+ for ip in addrs:
86
+ if (
87
+ ip.is_loopback
88
+ or ip.is_private
89
+ or ip.is_link_local
90
+ or ip.is_multicast
91
+ or ip.is_reserved
92
+ or ip.is_unspecified
93
+ ):
94
+ raise ComputeError(f'refusing non-public media source_url address: {ip}')
95
+
96
+
97
+ # Input fields only the node itself may set. A job's requester is a remote
98
+ # peer: a path would read or write any file on this host, and the rest would
99
+ # lift the SSRF guard, the download cap or ship fixture output as a result.
100
+ # run_job strips these before the node adds its own source_path.
101
+ NODE_ONLY_INPUT_KEYS: tuple[str, ...] = (
102
+ 'source_path', 'output_path', 'allow_local_http', 'max_download_bytes', 'allow_synthetic',
103
+ )
104
+
105
+
106
+ def requester_input(spec: Mapping[str, Any]) -> Mapping[str, Any]:
107
+ """`spec` without the fields a remote requester may not set."""
108
+ return {k: v for k, v in spec.items() if k not in NODE_ONLY_INPUT_KEYS}
109
+
110
+
111
+ def _public_peer(sock: socket.socket, allow_local: bool) -> None:
112
+ """Refuse a connection whose peer turned out non-public.
113
+
114
+ Checked on the connected socket, so a DNS answer that changed after
115
+ _validate_remote_media_url ran, or a redirect to an internal host, is
116
+ caught where the bytes would come from."""
117
+ if allow_local:
118
+ return
119
+ ip = ipaddress.ip_address(sock.getpeername()[0])
120
+ if (ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast
121
+ or ip.is_reserved or ip.is_unspecified):
122
+ sock.close()
123
+ raise ComputeError(f'refusing non-public media source_url address: {ip}')
124
+
125
+
126
+ def _media_opener(allow_local: bool) -> urllib.request.OpenerDirector:
127
+ """An opener whose every connection, redirects included, must reach a public address."""
128
+ import http.client
129
+
130
+ class _HTTP(http.client.HTTPConnection):
131
+ def connect(self) -> None:
132
+ super().connect()
133
+ _public_peer(self.sock, allow_local)
134
+
135
+ class _HTTPS(http.client.HTTPSConnection):
136
+ def connect(self) -> None:
137
+ super().connect()
138
+ _public_peer(self.sock, allow_local)
139
+
140
+ class _HTTPHandler(urllib.request.HTTPHandler):
141
+ def http_open(self, req: urllib.request.Request) -> Any:
142
+ return self.do_open(_HTTP, req)
143
+
144
+ tls = ssl.create_default_context()
145
+
146
+ class _HTTPSHandler(urllib.request.HTTPSHandler):
147
+ def https_open(self, req: urllib.request.Request) -> Any:
148
+ return self.do_open(_HTTPS, req, context=tls)
149
+
150
+ class _Redirect(urllib.request.HTTPRedirectHandler):
151
+ def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> Any:
152
+ _validate_remote_media_url(newurl, allow_local=allow_local)
153
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
154
+
155
+ return urllib.request.build_opener(_HTTPHandler(), _HTTPSHandler(), _Redirect())
156
+
157
+
158
+ MEDIA_PROFILES: dict[str, MediaProfileSpec] = {
159
+ 'remux_faststart_copy': {
160
+ 'width': 0,
161
+ 'height': 0,
162
+ 'video_bitrate_kbps': 0,
163
+ 'audio_sample_rate_hz': 48000,
164
+ 'audio_bitrate_kbps': 160,
165
+ 'is_audio': False,
166
+ 'fuel_per_sec': 150,
167
+ },
168
+ 'cmaf_segment_2s': {
169
+ 'width': 1280,
170
+ 'height': 720,
171
+ 'video_bitrate_kbps': 2200,
172
+ 'audio_sample_rate_hz': 44100,
173
+ 'audio_bitrate_kbps': 128,
174
+ 'is_audio': False,
175
+ 'fuel_per_sec': 500,
176
+ },
177
+ 'extract_frame_jpeg': {
178
+ 'width': 480,
179
+ 'height': 270,
180
+ 'video_bitrate_kbps': 0,
181
+ 'audio_sample_rate_hz': 0,
182
+ 'audio_bitrate_kbps': 0,
183
+ 'is_audio': False,
184
+ 'fuel_per_sec': 100,
185
+ },
186
+ 'normalize_faststart': {
187
+ 'width': 1280,
188
+ 'height': 720,
189
+ 'video_bitrate_kbps': 2800,
190
+ 'audio_sample_rate_hz': 48000,
191
+ 'audio_bitrate_kbps': 128,
192
+ 'is_audio': False,
193
+ 'fuel_per_sec': 800,
194
+ },
195
+ '1080p': {
196
+ 'width': 1920,
197
+ 'height': 1080,
198
+ 'video_bitrate_kbps': 4500,
199
+ 'audio_sample_rate_hz': 48000,
200
+ 'audio_bitrate_kbps': 192,
201
+ 'is_audio': False,
202
+ 'fuel_per_sec': 1000,
203
+ },
204
+ '720p': {
205
+ 'width': 1280,
206
+ 'height': 720,
207
+ 'video_bitrate_kbps': 2500,
208
+ 'audio_sample_rate_hz': 48000,
209
+ 'audio_bitrate_kbps': 160,
210
+ 'is_audio': False,
211
+ 'fuel_per_sec': 650,
212
+ },
213
+ '480p': {
214
+ 'width': 854,
215
+ 'height': 480,
216
+ 'video_bitrate_kbps': 1100,
217
+ 'audio_sample_rate_hz': 44100,
218
+ 'audio_bitrate_kbps': 128,
219
+ 'is_audio': False,
220
+ 'fuel_per_sec': 350,
221
+ },
222
+ '360p': {
223
+ 'width': 640,
224
+ 'height': 360,
225
+ 'video_bitrate_kbps': 600,
226
+ 'audio_sample_rate_hz': 24000,
227
+ 'audio_bitrate_kbps': 64,
228
+ 'is_audio': False,
229
+ 'fuel_per_sec': 200,
230
+ },
231
+ '320k': {
232
+ 'width': 0,
233
+ 'height': 0,
234
+ 'video_bitrate_kbps': 0,
235
+ 'audio_sample_rate_hz': 48000,
236
+ 'audio_bitrate_kbps': 320,
237
+ 'is_audio': True,
238
+ 'fuel_per_sec': 120,
239
+ },
240
+ '256k': {
241
+ 'width': 0,
242
+ 'height': 0,
243
+ 'video_bitrate_kbps': 0,
244
+ 'audio_sample_rate_hz': 48000,
245
+ 'audio_bitrate_kbps': 256,
246
+ 'is_audio': True,
247
+ 'fuel_per_sec': 100,
248
+ },
249
+ '192k': {
250
+ 'width': 0,
251
+ 'height': 0,
252
+ 'video_bitrate_kbps': 0,
253
+ 'audio_sample_rate_hz': 44100,
254
+ 'audio_bitrate_kbps': 192,
255
+ 'is_audio': True,
256
+ 'fuel_per_sec': 80,
257
+ },
258
+ '128k': {
259
+ 'width': 0,
260
+ 'height': 0,
261
+ 'video_bitrate_kbps': 0,
262
+ 'audio_sample_rate_hz': 44100,
263
+ 'audio_bitrate_kbps': 128,
264
+ 'is_audio': True,
265
+ 'fuel_per_sec': 60,
266
+ },
267
+ '64k': {
268
+ 'width': 0,
269
+ 'height': 0,
270
+ 'video_bitrate_kbps': 0,
271
+ 'audio_sample_rate_hz': 24000,
272
+ 'audio_bitrate_kbps': 64,
273
+ 'is_audio': True,
274
+ 'fuel_per_sec': 40,
275
+ },
276
+ 'vod_1080p_h264': {'width': 1920, 'height': 1080, 'video_bitrate_kbps': 4500, 'audio_sample_rate_hz': 48000, 'audio_bitrate_kbps': 192, 'is_audio': False, 'fuel_per_sec': 1000},
277
+ 'vod_720p_h264': {'width': 1280, 'height': 720, 'video_bitrate_kbps': 2500, 'audio_sample_rate_hz': 48000, 'audio_bitrate_kbps': 160, 'is_audio': False, 'fuel_per_sec': 650},
278
+ 'vod_480p_h264': {'width': 854, 'height': 480, 'video_bitrate_kbps': 1100, 'audio_sample_rate_hz': 44100, 'audio_bitrate_kbps': 128, 'is_audio': False, 'fuel_per_sec': 350},
279
+ 'vod_360p_h264': {'width': 640, 'height': 360, 'video_bitrate_kbps': 600, 'audio_sample_rate_hz': 24000, 'audio_bitrate_kbps': 64, 'is_audio': False, 'fuel_per_sec': 200},
280
+ 'audio_mp3_320k': {'width': 0, 'height': 0, 'video_bitrate_kbps': 0, 'audio_sample_rate_hz': 48000, 'audio_bitrate_kbps': 320, 'is_audio': True, 'fuel_per_sec': 120},
281
+ 'audio_mp3_128k': {'width': 0, 'height': 0, 'video_bitrate_kbps': 0, 'audio_sample_rate_hz': 44100, 'audio_bitrate_kbps': 128, 'is_audio': True, 'fuel_per_sec': 60},
282
+ }
283
+
284
+
285
+ def ffmpeg_available() -> bool:
286
+ """Return True if the host has the `ffmpeg` binary installed on PATH."""
287
+ return shutil.which('ffmpeg') is not None
288
+
289
+
290
+ def compute_deterministic_ffmpeg_job_key(
291
+ source_tree_root: str = '',
292
+ profile: str = '720p',
293
+ start_sec: float = 0.0,
294
+ duration_limit_sec: Optional[float] = None,
295
+ *,
296
+ source_sha256: str = '',
297
+ width: int = 0,
298
+ height: int = 0,
299
+ video_bitrate_kbps: int = 0,
300
+ audio_bitrate_kbps: int = 0,
301
+ audio_sample_rate_hz: int = 0,
302
+ ) -> str:
303
+ """Compute the canonical content-addressed deterministic job key for an ffmpeg compute job."""
304
+ prof = MEDIA_PROFILES.get(profile, MEDIA_PROFILES['720p'])
305
+ eff_w = int(width or prof['width'] or 0)
306
+ eff_h = int(height or prof['height'] or 0)
307
+ eff_v = int(video_bitrate_kbps or prof['video_bitrate_kbps'] or 0)
308
+ eff_a = int(audio_bitrate_kbps or prof['audio_bitrate_kbps'] or 0)
309
+ eff_ar = int(audio_sample_rate_hz or prof['audio_sample_rate_hz'] or 0)
310
+ dur_str = (
311
+ f'{float(duration_limit_sec):.3f}'
312
+ if duration_limit_sec is not None and float(duration_limit_sec) > 0
313
+ else 'full'
314
+ )
315
+ src_id = (source_tree_root or source_sha256 or '').strip().lower()
316
+ canon = (
317
+ f'fleetmesh-ffmpeg-job-v1:{FFMPEG_PROFILE_DIGEST}:{src_id}:{profile}:'
318
+ f'{float(start_sec):.3f}:{dur_str}:{eff_w}x{eff_h}:{eff_v}k:{eff_a}k:{eff_ar}hz'
319
+ )
320
+ return crypto.sha256(canon.encode('utf-8'))
321
+
322
+
323
+ def _probe_media_file(path: str) -> MediaProbeResult:
324
+ """Probe codec, dimensions, and duration via `ffprobe` when available."""
325
+ ffprobe_bin = shutil.which('ffprobe')
326
+ if not ffprobe_bin or not os.path.isfile(path):
327
+ return {}
328
+ try:
329
+ proc = subprocess.run(
330
+ [
331
+ ffprobe_bin, '-v', 'error',
332
+ '-show_entries', 'stream=codec_type,codec_name,pix_fmt,width,height:format=format_name,duration',
333
+ '-of', 'json', path,
334
+ ],
335
+ capture_output=True,
336
+ text=True,
337
+ timeout=10,
338
+ check=False,
339
+ start_new_session=True,
340
+ )
341
+ if proc.returncode != 0 or not proc.stdout:
342
+ return {}
343
+ info = json.loads(proc.stdout)
344
+ v_codec = ''
345
+ pix_fmt = ''
346
+ a_codec = ''
347
+ has_audio = False
348
+ width = 0
349
+ height = 0
350
+ for st in (info.get('streams') or []):
351
+ ctype = str(st.get('codec_type') or '')
352
+ if ctype == 'video' and not v_codec:
353
+ v_codec = str(st.get('codec_name') or '').lower()
354
+ pix_fmt = str(st.get('pix_fmt') or '').lower()
355
+ width = int(st.get('width') or 0)
356
+ height = int(st.get('height') or 0)
357
+ elif ctype == 'audio' and not a_codec:
358
+ has_audio = True
359
+ a_codec = str(st.get('codec_name') or '').lower()
360
+ fmt = info.get('format') or {}
361
+ duration_sec = float(fmt.get('duration') or 0.0)
362
+ return {
363
+ 'video_codec': v_codec,
364
+ 'pix_fmt': pix_fmt,
365
+ 'audio_codec': a_codec,
366
+ 'has_audio': has_audio,
367
+ 'width': width,
368
+ 'height': height,
369
+ 'duration_sec': duration_sec,
370
+ 'format_name': str(fmt.get('format_name') or '').lower(),
371
+ }
372
+ except Exception:
373
+ return {}
374
+
375
+
376
+ class FfmpegTranscodeExecutor(ComputeExecutor):
377
+ """Media transcoding runtime driver for H.264/AAC faststart MP4 and MP3 renditions."""
378
+
379
+ engine_id: str = 'ffmpeg'
380
+ wasi_profile_digest: str = FFMPEG_PROFILE_DIGEST
381
+
382
+ def __init__(
383
+ self,
384
+ mock_mode: bool = False,
385
+ *,
386
+ allow_local_http: bool = False,
387
+ max_download_bytes: int = MAX_MEDIA_DOWNLOAD_BYTES,
388
+ allow_synthetic: bool = False,
389
+ cache_dir: Optional[str] = None,
390
+ cache_max_bytes: int = MEDIA_CACHE_MAX_BYTES,
391
+ ) -> None:
392
+ self.mock_mode: bool = mock_mode
393
+ # Where verified source downloads are kept (the node's data dir, made
394
+ # owner-only); None keeps nothing between jobs.
395
+ self.cache_dir: Optional[str] = cache_dir
396
+ self.cache_max_bytes: int = cache_max_bytes
397
+ # Operator settings, never read from a job (NODE_ONLY_INPUT_KEYS).
398
+ self.allow_local_http: bool = allow_local_http
399
+ self.max_download_bytes: int = max_download_bytes
400
+ self.allow_synthetic: bool = allow_synthetic
401
+
402
+ def is_live_runnable(self) -> bool:
403
+ return self.mock_mode or ffmpeg_available()
404
+
405
+ def quote(
406
+ self,
407
+ module_spec: Mapping[str, Any],
408
+ input_spec: Mapping[str, Any],
409
+ limits: ComputeLimits,
410
+ ) -> ComputeQuote:
411
+ profile_name = str(input_spec.get('profile') or '720p')
412
+ prof = MEDIA_PROFILES.get(profile_name, MEDIA_PROFILES['720p'])
413
+ dur = float(input_spec.get('duration_limit_sec') or input_spec.get('duration_sec') or 30.0)
414
+ dur = max(1.0, dur)
415
+ w = int(input_spec.get('width') or prof['width'] or 0)
416
+ h = int(input_spec.get('height') or prof['height'] or 0)
417
+ if w > 0 and h > 0:
418
+ pixel_ratio = max(0.15, (w * h) / float(1920 * 1080))
419
+ fuel_est = max(1000, int(dur * pixel_ratio * 1000))
420
+ else:
421
+ fuel_est = max(500, int(dur * int(prof.get('fuel_per_sec', 100))))
422
+ fuel_est = min(fuel_est, limits.max_fuel)
423
+ return ComputeQuote(
424
+ fuel_estimate=fuel_est,
425
+ rate_unit='msat_per_mfuel',
426
+ rate_amount=15,
427
+ engine_id=self.engine_id,
428
+ )
429
+
430
+ def _cache_get(self, sha256: str) -> Optional[str]:
431
+ """A cached source whose bytes still hash to `sha256`, or None."""
432
+ if not self.cache_dir:
433
+ return None
434
+ path = os.path.join(self.cache_dir, f'{sha256}.bin')
435
+ if not os.path.isfile(path) or os.path.islink(path) or os.path.getsize(path) == 0:
436
+ return None
437
+ if RhioBaoTree.compute_file_root(path)[0].lower() != sha256:
438
+ return None
439
+ os.utime(path) # recently used: evicted last
440
+ return path
441
+
442
+ def _cache_put(self, sha256: str, staged: str) -> None:
443
+ """Keep a verified download, then evict the least recently used past cache_max_bytes."""
444
+ if not self.cache_dir:
445
+ return
446
+ with contextlib.suppress(OSError):
447
+ os.makedirs(self.cache_dir, mode=0o700, exist_ok=True)
448
+ target = os.path.join(self.cache_dir, f'{sha256}.bin')
449
+ if not os.path.isfile(target):
450
+ tmp = f'{target}.{os.getpid()}.tmp'
451
+ shutil.copy2(staged, tmp)
452
+ os.replace(tmp, target)
453
+ entries = sorted(
454
+ (os.stat(p).st_mtime, os.path.getsize(p), p)
455
+ for p in (os.path.join(self.cache_dir, fn) for fn in os.listdir(self.cache_dir))
456
+ if p.endswith('.bin') and os.path.isfile(p)
457
+ )
458
+ total = sum(size for _, size, _ in entries)
459
+ for _, size, old in entries:
460
+ if total <= self.cache_max_bytes or old == target:
461
+ continue
462
+ os.remove(old)
463
+ total -= size
464
+
465
+ def _resolve_input_to_file(
466
+ self,
467
+ input_spec: Mapping[str, Any],
468
+ work_dir: str,
469
+ ) -> tuple[str, int]:
470
+ """Resolve input specification to a local file path and byte size."""
471
+ src_path = str(input_spec.get('source_path') or '').strip()
472
+ if src_path and os.path.isfile(src_path):
473
+ return src_path, os.path.getsize(src_path)
474
+
475
+ raw = input_spec.get('raw')
476
+ if raw is not None:
477
+ if isinstance(raw, str):
478
+ try:
479
+ raw_bytes = bytes.fromhex(raw)
480
+ except ValueError:
481
+ raw_bytes = raw.encode('utf-8')
482
+ elif isinstance(raw, (bytes, bytearray)):
483
+ raw_bytes = bytes(raw)
484
+ else:
485
+ raw_bytes = b''
486
+ if raw_bytes:
487
+ staged_in = os.path.join(work_dir, 'input_raw.bin')
488
+ with open(staged_in, 'wb') as f:
489
+ f.write(raw_bytes)
490
+ return staged_in, len(raw_bytes)
491
+
492
+ src_url = str(input_spec.get('source_url') or '').strip()
493
+ expected_sha = str(input_spec.get('source_sha256') or '').strip().lower()
494
+ expected_root = str(input_spec.get('source_tree_root') or '').strip().lower()
495
+ if len(expected_sha) == 64:
496
+ cached_bin = self._cache_get(expected_sha)
497
+ if cached_bin is not None:
498
+ return cached_bin, os.path.getsize(cached_bin)
499
+
500
+ if src_url:
501
+ # Only http(s): a file:// URL or a bare path would read this host's files.
502
+ if not src_url.startswith(('http://', 'https://')):
503
+ raise ComputeError('media source_url must be http(s)')
504
+ _validate_remote_media_url(src_url, allow_local=self.allow_local_http)
505
+ max_bytes = self.max_download_bytes
506
+ staged_in = os.path.join(work_dir, 'input_download.bin')
507
+ req = urllib.request.Request(src_url, headers={'User-Agent': 'FleetMesh-Compute/1.1'})
508
+ total_read = 0
509
+ opener = _media_opener(self.allow_local_http)
510
+ with opener.open(req, timeout=180) as resp, open(staged_in, 'wb') as out_f:
511
+ while True:
512
+ chunk = resp.read(256 * 1024)
513
+ if not chunk:
514
+ break
515
+ total_read += len(chunk)
516
+ if total_read > max_bytes:
517
+ raise ComputeError(f'media input download exceeded {max_bytes} byte limit')
518
+ out_f.write(chunk)
519
+ if len(expected_sha) == 64 or len(expected_root) == 64:
520
+ got_sha, got_root, _ = RhioBaoTree.compute_file_root(staged_in)
521
+ if len(expected_sha) == 64 and got_sha.lower() != expected_sha:
522
+ raise ComputeError(f'input sha256 mismatch: expected {expected_sha}, got {got_sha}')
523
+ if len(expected_root) == 64 and got_root.lower() != expected_root:
524
+ raise ComputeError(f'input tree_root mismatch: expected {expected_root}, got {got_root}')
525
+ if len(expected_sha) == 64:
526
+ self._cache_put(expected_sha, staged_in)
527
+ return staged_in, os.path.getsize(staged_in)
528
+
529
+ if self.mock_mode or self.allow_synthetic:
530
+ staged_in = os.path.join(work_dir, 'input_synthetic.bin')
531
+ seed_bytes = json.dumps(dict(input_spec), sort_keys=True).encode('utf-8')
532
+ with open(staged_in, 'wb') as f:
533
+ f.write(seed_bytes)
534
+ return staged_in, len(seed_bytes)
535
+
536
+ raise ComputeError('ffmpeg job requires a valid source_path, raw input, or reachable source_url')
537
+
538
+ @staticmethod
539
+ def _build_synthetic_output(
540
+ src_path: str,
541
+ out_path: str,
542
+ profile_name: str,
543
+ w: int,
544
+ h: int,
545
+ v_kbps: int,
546
+ a_kbps: int,
547
+ ar_hz: int,
548
+ is_audio: bool,
549
+ ) -> None:
550
+ """Produce a deterministic tagged container payload in mock_mode or for synthetic fixtures."""
551
+ with open(src_path, 'rb') as f:
552
+ raw_in = f.read(32 * 1024 * 1024)
553
+ meta_box = json.dumps({
554
+ 'engine': 'ffmpeg',
555
+ 'profile': profile_name,
556
+ 'resolution': f'{w}x{h}' if not is_audio else f'Audio {a_kbps}kbps',
557
+ 'video_bitrate_kbps': v_kbps,
558
+ 'audio_bitrate_kbps': a_kbps,
559
+ 'audio_sample_rate_hz': ar_hz,
560
+ }, sort_keys=True).encode('utf-8')
561
+ if profile_name == 'extract_frame_jpeg':
562
+ payload = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00' + meta_box + hashlib.sha256(raw_in).digest() + b'\xff\xd9'
563
+ elif is_audio:
564
+ tag = b'TAG' + f'{profile_name}:{a_kbps}k:{ar_hz}'.encode('utf-8').ljust(125, b'\x00')[:125]
565
+ payload = raw_in + tag
566
+ else:
567
+ uuid_hdr = b'uuid' + hashlib.md5(f'fleetmesh:{profile_name}'.encode('utf-8')).digest() + meta_box
568
+ payload = raw_in + (len(uuid_hdr) + 4).to_bytes(4, 'big') + uuid_hdr
569
+ with open(out_path, 'wb') as out_f:
570
+ out_f.write(payload)
571
+
572
+ def execute(
573
+ self,
574
+ job_payload: Mapping[str, Any],
575
+ fuel_cap: int,
576
+ ) -> ComputeExecutionResult:
577
+ v_mode = self.verification_mode(job_payload, default='delivery')
578
+ raw_inp = job_payload.get('input')
579
+ input_spec = dict(raw_inp) if isinstance(raw_inp, Mapping) else {}
580
+ for k in (
581
+ 'raw', 'profile', 'source_path', 'source_url', 'source_sha256', 'source_tree_root',
582
+ 'output_path', 'duration_sec', 'duration_limit_sec', 'start_sec',
583
+ 'width', 'height', 'video_bitrate_kbps', 'audio_bitrate_kbps',
584
+ 'audio_sample_rate_hz', 'is_audio', 'vod_id', 'threads',
585
+ ):
586
+ if k not in input_spec and k in job_payload:
587
+ input_spec[k] = job_payload[k]
588
+
589
+ profile_name = str(input_spec.get('profile') or '720p')
590
+ prof = MEDIA_PROFILES.get(profile_name, MEDIA_PROFILES['720p'])
591
+ is_audio = bool(input_spec.get('is_audio', prof['is_audio']))
592
+ w = int(input_spec.get('width') or prof['width'] or (0 if is_audio else 1280))
593
+ h = int(input_spec.get('height') or prof['height'] or (0 if is_audio else 720))
594
+ v_kbps = int(input_spec.get('video_bitrate_kbps') or prof['video_bitrate_kbps'] or (0 if is_audio else 2500))
595
+ a_kbps = int(input_spec.get('audio_bitrate_kbps') or prof['audio_bitrate_kbps'] or 128)
596
+ ar_hz = int(input_spec.get('audio_sample_rate_hz') or prof['audio_sample_rate_hz'] or 48000)
597
+ start_sec = float(input_spec.get('start_sec') or 0.0)
598
+ dur_limit_raw = input_spec.get('duration_limit_sec')
599
+ dur_limit: Optional[float] = float(dur_limit_raw) if dur_limit_raw is not None and float(dur_limit_raw) > 0 else None
600
+ timeout_s = int(job_payload.get('timeout_seconds') or input_spec.get('timeout_seconds') or 3600)
601
+ raw_threads = input_spec.get('threads')
602
+ max_threads = max(1, int(raw_threads)) if raw_threads is not None else DETERMINISTIC_FFMPEG_THREADS
603
+ x264_det_params = f'threads={max_threads}:deterministic=1:sliced-threads=0'
604
+ job_key = compute_deterministic_ffmpeg_job_key(
605
+ source_tree_root=str(input_spec.get('source_tree_root') or ''),
606
+ source_sha256=str(input_spec.get('source_sha256') or input_spec.get('sha256') or ''),
607
+ profile=profile_name,
608
+ start_sec=start_sec,
609
+ duration_limit_sec=dur_limit,
610
+ width=int(input_spec.get('width') or 0),
611
+ height=int(input_spec.get('height') or 0),
612
+ video_bitrate_kbps=int(input_spec.get('video_bitrate_kbps') or 0),
613
+ audio_bitrate_kbps=int(input_spec.get('audio_bitrate_kbps') or 0),
614
+ audio_sample_rate_hz=int(input_spec.get('audio_sample_rate_hz') or 0),
615
+ )
616
+ progress_cb: Optional[Callable[[str, float, Mapping[str, Any]], None]] = job_payload.get('progress_callback')
617
+ cb_errors = 0
618
+
619
+ t0_wall = time.monotonic()
620
+ t0_cpu = time.process_time()
621
+
622
+ with tempfile.TemporaryDirectory(prefix='fm_ffmpeg_') as work_dir:
623
+ src_path, input_bytes = self._resolve_input_to_file(input_spec, work_dir)
624
+ ext = 'jpg' if profile_name == 'extract_frame_jpeg' else ('mp3' if is_audio else 'mp4')
625
+ staged_out = os.path.join(work_dir, f'output_{profile_name}.{ext}')
626
+
627
+ ffmpeg_bin = shutil.which('ffmpeg')
628
+ ran_ffmpeg = False
629
+
630
+ if not self.mock_mode and ffmpeg_bin and input_bytes > 256:
631
+ pre_probe = _probe_media_file(src_path)
632
+ v_codec = str(pre_probe.get('video_codec') or '')
633
+ pix_fmt = str(pre_probe.get('pix_fmt') or '')
634
+ a_codec = str(pre_probe.get('audio_codec') or '')
635
+ has_audio = bool(pre_probe.get('has_audio', True))
636
+
637
+ def _nice_child() -> None:
638
+ if hasattr(os, 'nice'):
639
+ with contextlib.suppress(OSError):
640
+ os.nice(2)
641
+
642
+ preexec = _nice_child if os.name == 'posix' else None
643
+ pre_input_args: list[str] = []
644
+ post_input_args: list[str] = []
645
+ if start_sec > 0:
646
+ pre_input_args.extend(['-ss', f'{start_sec:.3f}'])
647
+ if dur_limit is not None:
648
+ post_input_args.extend(['-t', f'{int(dur_limit) if dur_limit.is_integer() else dur_limit}'])
649
+
650
+ if callable(progress_cb):
651
+ try:
652
+ progress_cb('running', 0.1, {'profile': profile_name, 'input_bytes': input_bytes})
653
+ except Exception:
654
+ cb_errors += 1
655
+
656
+ # Fast remux path when normalizing an already 8-bit H.264 stream without duration clipping
657
+ if (
658
+ profile_name in ('normalize_faststart', 'remux_faststart_copy')
659
+ and not is_audio
660
+ and dur_limit is None
661
+ and start_sec <= 0
662
+ and v_codec == 'h264'
663
+ and pix_fmt in ('yuv420p', 'yuvj420p', '')
664
+ ):
665
+ remux_cmd = [
666
+ ffmpeg_bin, '-y', '-v', 'error', '-threads', str(max_threads),
667
+ '-i', src_path,
668
+ *BITEXACT_MUX_FLAGS,
669
+ '-map', '0:v:0', '-map', '0:a:0?',
670
+ '-c:v', 'copy',
671
+ *(['-c:a', 'copy'] if (not has_audio or a_codec == 'aac') else ['-c:a', 'aac', '-b:a', f'{a_kbps}k', '-ac', '2']),
672
+ '-movflags', '+faststart', staged_out,
673
+ ]
674
+ try:
675
+ proc = subprocess.run(
676
+ remux_cmd,
677
+ capture_output=True,
678
+ timeout=min(timeout_s, 600),
679
+ check=False,
680
+ preexec_fn=preexec,
681
+ start_new_session=True,
682
+ )
683
+ except subprocess.TimeoutExpired:
684
+ ran_ffmpeg = False
685
+ else:
686
+ if proc.returncode == 0 and os.path.isfile(staged_out) and os.path.getsize(staged_out) > 256:
687
+ ran_ffmpeg = True
688
+
689
+ if not ran_ffmpeg:
690
+ if profile_name == 'extract_frame_jpeg':
691
+ frame_w = w if w > 0 else 480
692
+ cmd = [
693
+ ffmpeg_bin, '-y', '-v', 'error', '-threads', str(max_threads),
694
+ *pre_input_args,
695
+ '-i', src_path,
696
+ *BITEXACT_MUX_FLAGS,
697
+ '-frames:v', '1',
698
+ '-vf', f'scale={frame_w}:-2:flags=fast_bilinear',
699
+ '-q:v', '5',
700
+ '-f', 'image2',
701
+ staged_out,
702
+ ]
703
+ elif is_audio:
704
+ cmd = [
705
+ ffmpeg_bin, '-y', '-v', 'error', '-threads', str(max_threads),
706
+ *pre_input_args,
707
+ '-i', src_path,
708
+ *post_input_args,
709
+ *BITEXACT_MUX_FLAGS,
710
+ '-vn', '-c:a', 'libmp3lame', '-ar', str(ar_hz), '-b:a', f'{a_kbps}k',
711
+ '-write_xing', '0', '-id3v2_version', '0',
712
+ staged_out,
713
+ ]
714
+ elif profile_name in ('normalize_faststart', 'remux_faststart_copy', 'cmaf_segment_2s'):
715
+ scale_vf = "scale='min(1280,trunc(iw/2)*2)':-2:flags=fast_bilinear"
716
+ mov_flags = (
717
+ 'frag_keyframe+empty_moov+default_base_moof'
718
+ if profile_name == 'cmaf_segment_2s'
719
+ else '+faststart'
720
+ )
721
+ cmd = [
722
+ ffmpeg_bin, '-y', '-v', 'error', '-threads', str(max_threads),
723
+ *pre_input_args,
724
+ '-i', src_path,
725
+ *post_input_args,
726
+ *BITEXACT_MUX_FLAGS,
727
+ '-map', '0:v:0', '-map', '0:a:0?',
728
+ '-vf', scale_vf,
729
+ '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'fastdecode', '-crf', '24',
730
+ '-x264-params', x264_det_params,
731
+ '-profile:v', 'high', '-level', '4.1', '-pix_fmt', 'yuv420p',
732
+ '-video_track_timescale', '90000',
733
+ '-c:a', 'aac', '-ar', str(ar_hz), '-b:a', f'{a_kbps}k', '-ac', '2',
734
+ '-movflags', mov_flags, staged_out,
735
+ ]
736
+ else:
737
+ scale_vf = f'scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2'
738
+ cmd = [
739
+ ffmpeg_bin, '-y', '-v', 'error', '-threads', str(max_threads),
740
+ *pre_input_args,
741
+ '-i', src_path,
742
+ *post_input_args,
743
+ *BITEXACT_MUX_FLAGS,
744
+ '-map', '0:v:0', '-map', '0:a:0?',
745
+ '-vf', scale_vf,
746
+ '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'fastdecode',
747
+ '-x264-params', x264_det_params,
748
+ '-b:v', f'{v_kbps}k', '-maxrate', f'{v_kbps}k', '-bufsize', f'{v_kbps * 2}k',
749
+ '-pix_fmt', 'yuv420p', '-video_track_timescale', '90000',
750
+ '-c:a', 'aac', '-ar', str(ar_hz), '-b:a', f'{a_kbps}k', '-ac', '2',
751
+ '-movflags', '+faststart', staged_out,
752
+ ]
753
+
754
+ try:
755
+ proc = subprocess.run(
756
+ cmd,
757
+ capture_output=True,
758
+ timeout=timeout_s,
759
+ check=False,
760
+ preexec_fn=preexec,
761
+ start_new_session=True,
762
+ )
763
+ if proc.returncode == 0 and os.path.isfile(staged_out) and os.path.getsize(staged_out) > 256:
764
+ ran_ffmpeg = True
765
+ except subprocess.TimeoutExpired as exc:
766
+ raise ComputeError(f'ffmpeg transcode timed out after {timeout_s}s') from exc
767
+
768
+ # Video-only fallback if source audio track is absent or corrupt
769
+ if not ran_ffmpeg and not is_audio and profile_name != 'extract_frame_jpeg':
770
+ scale_vf = (
771
+ "scale='min(1280,trunc(iw/2)*2)':-2:flags=fast_bilinear"
772
+ if profile_name in ('normalize_faststart', 'remux_faststart_copy', 'cmaf_segment_2s')
773
+ else f'scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2'
774
+ )
775
+ fallback_cmd = [
776
+ ffmpeg_bin, '-y', '-v', 'error', '-threads', str(max_threads),
777
+ *pre_input_args,
778
+ '-i', src_path,
779
+ *post_input_args,
780
+ *BITEXACT_MUX_FLAGS,
781
+ '-map', '0:v:0', '-an',
782
+ '-vf', scale_vf,
783
+ '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'fastdecode', '-crf', '24',
784
+ '-x264-params', x264_det_params,
785
+ '-profile:v', 'baseline', '-level', '3.0', '-pix_fmt', 'yuv420p',
786
+ '-video_track_timescale', '90000',
787
+ '-movflags', '+faststart', staged_out,
788
+ ]
789
+ try:
790
+ proc2 = subprocess.run(
791
+ fallback_cmd,
792
+ capture_output=True,
793
+ timeout=timeout_s,
794
+ check=False,
795
+ preexec_fn=preexec,
796
+ start_new_session=True,
797
+ )
798
+ if proc2.returncode == 0 and os.path.isfile(staged_out) and os.path.getsize(staged_out) > 256:
799
+ ran_ffmpeg = True
800
+ except subprocess.TimeoutExpired as exc:
801
+ raise ComputeError(f'ffmpeg fallback transcode timed out after {timeout_s}s') from exc
802
+
803
+ if not ran_ffmpeg:
804
+ if self.mock_mode or self.allow_synthetic:
805
+ self._build_synthetic_output(src_path, staged_out, profile_name, w, h, v_kbps, a_kbps, ar_hz, is_audio)
806
+ else:
807
+ raise ComputeError(f'ffmpeg failed to transcode media for profile {profile_name}')
808
+
809
+ out_sha, out_root, out_size = RhioBaoTree.compute_file_root(staged_out)
810
+ if len(out_sha) == 64:
811
+ self._cache_put(out_sha, staged_out)
812
+ bstore = job_payload.get('_blob_store')
813
+ if bstore is not None and hasattr(bstore, 'store_blob_from_file'):
814
+ with contextlib.suppress(Exception):
815
+ bstore.store_blob_from_file(staged_out, move=False)
816
+ post_probe: MediaProbeResult = _probe_media_file(staged_out) if ran_ffmpeg else {}
817
+ out_dur = float(post_probe.get('duration_sec') or dur_limit or input_spec.get('duration_sec') or 15.0)
818
+ out_w = int(post_probe.get('width') or w)
819
+ out_h = int(post_probe.get('height') or h)
820
+ out_res = f'{out_w}x{out_h}' if (out_w > 0 and out_h > 0 and not is_audio) else (f'Audio {a_kbps}kbps' if is_audio else '1280x720')
821
+
822
+ dest_out_path = str(input_spec.get('output_path') or '').strip()
823
+ if dest_out_path:
824
+ os.makedirs(os.path.dirname(os.path.abspath(dest_out_path)), exist_ok=True)
825
+ shutil.move(staged_out, dest_out_path)
826
+
827
+ wall_s = max(0.001, round(time.monotonic() - t0_wall, 4))
828
+ cpu_s = max(0.001, round(time.process_time() - t0_cpu, 4))
829
+
830
+ if callable(progress_cb):
831
+ try:
832
+ progress_cb('finalizing', 1.0, {'output_sha256': out_sha, 'output_size': out_size})
833
+ except Exception:
834
+ cb_errors += 1
835
+
836
+ pixel_factor = max(0.15, (out_w * out_h) / float(1920 * 1080)) if not is_audio else 0.1
837
+ fuel_used = max(
838
+ 10,
839
+ int(max(1.0, out_dur) * pixel_factor * 100) + int(out_size // 4096),
840
+ )
841
+ if fuel_cap > 0 and fuel_used > fuel_cap:
842
+ raise ComputeError(f'media job exceeded fuel cap ({fuel_used} > {fuel_cap})')
843
+
844
+ manifest = {
845
+ 'engine': self.engine_id,
846
+ 'job_key': job_key,
847
+ 'profile': profile_name,
848
+ 'vod_id': str(input_spec.get('vod_id') or ''),
849
+ 'output_sha256': out_sha,
850
+ 'output_tree_root': out_root,
851
+ 'output_size': out_size,
852
+ 'output_path': dest_out_path,
853
+ 'resolution': out_res,
854
+ 'width': out_w,
855
+ 'height': out_h,
856
+ 'duration_sec': round(out_dur, 2),
857
+ 'video_bitrate_kbps': v_kbps,
858
+ 'audio_bitrate_kbps': a_kbps,
859
+ 'audio_sample_rate_hz': ar_hz,
860
+ 'is_audio': is_audio,
861
+ 'ran_ffmpeg': ran_ffmpeg,
862
+ 'deterministic': True,
863
+ 'threads': max_threads,
864
+ 'cpu_seconds': cpu_s,
865
+ 'wall_seconds': wall_s,
866
+ 'fuel_used': fuel_used,
867
+ }
868
+ raw_output = json.dumps(manifest, sort_keys=True, separators=(',', ':')).encode('utf-8')
869
+ return ComputeExecutionResult(
870
+ result_hash=out_sha,
871
+ raw_output=raw_output,
872
+ fuel_used=fuel_used,
873
+ engine_id=self.engine_id,
874
+ wasi_profile_digest=self.wasi_profile_digest,
875
+ counters={
876
+ 'cpu_seconds': cpu_s,
877
+ 'wall_seconds': wall_s,
878
+ 'input_bytes': input_bytes,
879
+ 'output_bytes': out_size,
880
+ 'duration_sec': round(out_dur, 2),
881
+ 'callback_errors': cb_errors,
882
+ },
883
+ verification_mode=v_mode,
884
+ )
885
+
886
+
887
+ __all__ = [
888
+ 'BITEXACT_MUX_FLAGS',
889
+ 'DETERMINISTIC_FFMPEG_THREADS',
890
+ 'FFMPEG_PROFILE_DIGEST',
891
+ 'MAX_MEDIA_DOWNLOAD_BYTES',
892
+ 'MEDIA_PROFILES',
893
+ 'MediaProbeResult',
894
+ 'MediaProfileSpec',
895
+ 'compute_deterministic_ffmpeg_job_key',
896
+ 'ffmpeg_available',
897
+ 'FfmpegTranscodeExecutor',
898
+ ]