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,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
|
+
]
|