From d3562dda52dda3d5bd971094eebcffa81ea6ee1a Mon Sep 17 00:00:00 2001 From: Josh Allmann Date: Tue, 13 Oct 2026 17:40:00 -0700 Subject: [PATCH] Remove legacy LV2V/gRPC APIs and update runner examples Move the composite viewer into echo, migrate examples to Livepeer Node --- .gitignore | 3 - CHANGELOG.md | 2 + README | 161 ++---- examples/camera_capture.py | 274 ----------- examples/echo/README.md | 44 +- examples/echo/client.py | 627 ++++++++++++++++++++---- examples/get_orchestrator_info.py | 426 ---------------- examples/in_out_composite.py | 603 ----------------------- examples/payments.py | 122 ----- examples/ping-pong/README.md | 12 +- examples/select_orchestrator.py | 96 ---- examples/select_runner.py | 94 ++++ examples/start_job.py | 70 --- examples/subscribe_events.py | 71 --- examples/text/README.md | 29 +- examples/text/go-livepeer.conf | 10 - examples/text/runner.py | 5 +- examples/text/runners.json | 14 - examples/text/runners.toml | 10 + examples/write_control.py | 89 ---- examples/write_frames.py | 91 ---- pyproject.toml | 14 +- src/livepeer_gateway/__init__.py | 28 +- src/livepeer_gateway/capabilities.py | 194 -------- src/livepeer_gateway/codegen.py | 55 --- src/livepeer_gateway/control.py | 15 - src/livepeer_gateway/discovery.py | 17 - src/livepeer_gateway/errors.py | 33 +- src/livepeer_gateway/http.py | 26 +- src/livepeer_gateway/live_runner.py | 12 +- src/livepeer_gateway/lp_rpc_pb2.py | 129 ----- src/livepeer_gateway/lp_rpc_pb2_grpc.py | 334 ------------- src/livepeer_gateway/lv2v.py | 398 --------------- src/livepeer_gateway/media_output.py | 14 +- src/livepeer_gateway/orch_info.py | 358 -------------- src/livepeer_gateway/orchestrator.py | 48 -- src/livepeer_gateway/remote_signer.py | 268 +--------- src/livepeer_gateway/scope.py | 143 ------ src/livepeer_gateway/selection.py | 133 +---- tests/test_control_keepalive.py | 139 ------ tests/test_echo_example.py | 143 ++++++ tests/test_errors.py | 30 -- tests/test_live_payment_session.py | 101 +--- tests/test_live_runner.py | 88 +--- tests/test_start_scope.py | 542 -------------------- tests/test_stats_pull.py | 32 +- uv.lock | 143 +----- 47 files changed, 943 insertions(+), 5347 deletions(-) delete mode 100644 examples/camera_capture.py delete mode 100644 examples/get_orchestrator_info.py delete mode 100644 examples/in_out_composite.py delete mode 100644 examples/payments.py delete mode 100644 examples/select_orchestrator.py create mode 100644 examples/select_runner.py delete mode 100644 examples/start_job.py delete mode 100644 examples/subscribe_events.py delete mode 100644 examples/text/go-livepeer.conf delete mode 100644 examples/text/runners.json create mode 100644 examples/text/runners.toml delete mode 100644 examples/write_control.py delete mode 100644 examples/write_frames.py delete mode 100644 src/livepeer_gateway/capabilities.py delete mode 100644 src/livepeer_gateway/codegen.py delete mode 100644 src/livepeer_gateway/lp_rpc_pb2.py delete mode 100644 src/livepeer_gateway/lp_rpc_pb2_grpc.py delete mode 100644 src/livepeer_gateway/lv2v.py delete mode 100644 src/livepeer_gateway/orch_info.py delete mode 100644 src/livepeer_gateway/orchestrator.py delete mode 100644 src/livepeer_gateway/scope.py create mode 100644 tests/test_echo_example.py delete mode 100644 tests/test_errors.py delete mode 100644 tests/test_start_scope.py diff --git a/.gitignore b/.gitignore index 3f4e68a..c98b645 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,6 @@ __pycache__/ dist/ htmlcov/ -# Generated files -lp_rpc.proto - # environment file env .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b40583..1909d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Removed +- gRPC orchestrator information and selection including protobuf bindings +- LV2V and Scope startup APIs, including 720p pricing - BYOC (bring your own container) inference and training jobs: the `submit_byoc_job`, `submit_training_job`, `refresh_training_payment`, `get_training_status`, `wait_for_training`, and `list_capabilities` diff --git a/README b/README index af5018d..6e32fcf 100644 --- a/README +++ b/README @@ -1,7 +1,7 @@ # Livepeer Python SDK -The official Python SDK for the Livepeer network. Submit AI and video compute -jobs directly to orchestrators, use a remote signer for payment, and stream +The official Python SDK for the Livepeer network. Discover and call Live Runner +apps through orchestrators, use a remote signer for payment, and stream media and control data over Livepeer's trickle protocol. ## Requirements @@ -19,157 +19,92 @@ python -m pip install livepeer-gateway The import package is named `livepeer_gateway`: ```python -from livepeer_gateway import StartJobRequest, start_lv2v +from livepeer_gateway import call_runner, reserve_session, runner_selector ``` ## Development -Install the locked development dependencies with -[uv](https://docs.astral.sh/uv/): +Install dependencies and run the tests with [uv](https://docs.astral.sh/uv/): ```bash uv sync --locked --group test +uv run --group test pytest ``` -Generate protobufs after installing the code-generation extra: - -```bash -uv sync --extra dev -uv run generate-lp-rpc -``` - -## Tests +## Usage Examples -Install the locked test dependencies and run the complete pytest suite: +Install the example dependencies: ```bash -uv sync --locked --group test -uv run --group test pytest +uv sync --extra examples ``` -Pass a test file or node ID to pytest for a focused run: - -```bash -uv run --group test pytest tests/test_live_runner.py -uv run --group test pytest tests/test_live_runner.py::TestLiveRunnerHelpers::test_parse_go_duration -``` +- [Echo video runner](examples/echo/README.md): view input, loopback, and output, + publish video, and update app parameters. +- [Ping/pong websocket runner](examples/ping-pong/README.md): discover a runner + and exchange websocket messages. +- [Text streaming runner](examples/text/README.md): stream text and SSE through + a statically configured runner. -Run the suite with the configured line and branch coverage: +Inspect a matching runner's discovery metadata: ```bash -uv run --group test pytest --cov=livepeer_gateway --cov-branch --cov-report=term-missing +uv run examples/select_runner.py http://localhost:8935 --app livepeer-sample/echo ``` -## Usage Examples - -First install dependencies for example code -```bash -uv sync --extra examples -``` +Runner discovery uses explicit `orchestrators` (their `/discovery` endpoints), +then `discovery_url`, then the endpoint derived from `signer_url`. -Get orchestrator info, offchain mode -```bash -uv run examples/get_orchestrator_info.py localhost:8935 -``` +Reserve a persistent session using a gateway token and release it on exit: -On-chain mode with a remote signer +```python +import asyncio -```bash -uv run examples/get_orchestrator_info.py --signer "" +from livepeer_gateway import parse_token, reserve_session -# Use a custom discovery endpoint to filter orchestrators -uv run examples/get_orchestrator_info.py --signer "" '/discover-orchestrators?cap=streamdiffusion-sdxl-v2v' -``` +async def main(token): + config = parse_token(token) + async with await reserve_session( + app="livepeer-sample/echo", + orchestrators=config.get("orchestrators"), + signer_url=config.get("signer"), + signer_headers=config.get("signer_headers"), + discovery_url=config.get("discovery"), + discovery_headers=config.get("discovery_headers"), + ) as session: + print(session.app_url) -Get orchestrator info using a token encoding signer / discovery parameters -```bash -uv run examples/get_orchestrator_info.py --token "" -``` -Write raw frames to a LiveVideoToVideo job -```bash -uv run examples/write_frames.py localhost:8935 +asyncio.run(main("")) ``` -Capture MacOS camera frames and publish via write_frame -```bash -uv run examples/camera_capture.py localhost:8935 -``` +For single-shot apps, use `runner_selector()` and `await cursor.next()`, or +`call_runner()` for a known runner URL. Paid runners support `hour`, `seconds`, +and `fixed` pricing through a remote signer. -Capture MacOS camera frames and subscribe to media output (stdout or file) -```bash -uv run examples/camera_capture.py localhost:8935 --output - | ffplay -fflags nobuffer -flags low_delay -probesize 32 -i - -uv run examples/camera_capture.py localhost:8935 --output out.ts -``` +Read demuxed packets without decoding, using the output channel URL returned +by the app: -Read demuxed media output packets without decoding ```python -async with job.media_output() as output: - async for packet in output.packets(): - print(packet.kind, packet.stream_index, packet.pts_time, packet.size) -``` +from livepeer_gateway import MediaOutput -Composite camera input and decoded output side-by-side with PTS delta -```bash -uv sync --extra examples -uv run examples/in_out_composite.py localhost:8935 -``` - -Subscribe to a LiveVideoToVideo trickle events channel -```bash -uv run examples/subscribe_events.py localhost:8935 -``` -Start a LiveVideoToVideo job using a token (base64 JSON) -```python -import base64 -import json - -from livepeer_gateway.lv2v import StartJobRequest, start_lv2v - -payload = { - "orchestrators": [ - "https://orch-1.example.com:8935", - "https://orch-2.example.com:8935", - ], - "signer": "https://signer.example.com", - "signer_headers": {"Authorization": "Bearer abcdef"}, - "discovery": "https://discovery.example.com", - "discovery_headers": {"Authorization": "Bearer qwerty"}, -} -token = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8") - -job = start_lv2v( - orch_url=None, - req=StartJobRequest(model_id="noop"), - token=token, - timeout=5.0, # timeout for the initial /live-video-to-video request - # signer_url="https://fallback-signer.example.com", # used only if token omits signer -) +async def read_packets(output_url): + async with MediaOutput(output_url) as output: + async for packet in output.packets(): + print(packet.kind, packet.stream_index, packet.pts_time, packet.size) ``` ## Token schema (base64-encoded JSON object) | Field | Type | Description | |---|---|---| -| `orchestrators` | `string[]` (optional) | Ordered orchestrator addresses to try before discovery | +| `orchestrators` | `string[]` (optional) | Ordered, non-empty orchestrator addresses used instead of discovery | | `signer` | `string` (optional) | Signer base URL | | `signer_headers` | `{"key": "value"}` (optional) | Extra HTTP headers sent to all signer endpoints | | `discovery` | `string` (optional) | Discovery endpoint URL | -| `discovery_headers` | `{"key": "value"}` (optional) | Extra HTTP headers sent to the discovery endpoint | - -Token values take precedence over explicit keyword arguments. -Explicit keyword arguments are used only for fields missing in the token. - -For token payloads, `orchestrators` must be a JSON array of non-empty strings. -Comma-delimited string format is not supported in the token. - -Selection/discovery precedence (highest -> lowest): -1) token `orchestrators` -2) explicit `orch_url` -3) token `discovery` -4) explicit `discovery_url` -5) signer-derived discovery endpoint +| `discovery_headers` | `{"key": "value"}` (optional) | Extra HTTP headers sent to an explicit discovery endpoint | -`signer_headers` are sent with requests to the signer service. `discovery_headers` are only used when an explicit `discovery_url` is provided (and not when using the signer service as a discovery fallback). +Parse tokens with `parse_token()` and pass the resulting fields explicitly to +runner APIs, as shown above. diff --git a/examples/camera_capture.py b/examples/camera_capture.py deleted file mode 100644 index 18375f1..0000000 --- a/examples/camera_capture.py +++ /dev/null @@ -1,274 +0,0 @@ -# Camera capture and streaming to Livepeer; works on MacOS only - -import argparse -import asyncio -import logging -import queue -import threading -import sys -import time -from contextlib import suppress - -import av - -from livepeer_gateway.errors import LivepeerGatewayError -from livepeer_gateway.lv2v import StartJobRequest, start_lv2v -from livepeer_gateway.media_publish import MediaPublishConfig, VideoOutputConfig - -DEFAULT_MODEL_ID = "noop" # fix -DEFAULT_DEVICE = "0" -DEFAULT_FPS = 30.0 -DEFAULT_VIDEO_SIZE = "640x480" -DEFAULT_PIXEL_FORMAT = "nv12" - -_STOP = object() - - -class _OneLineExceptionFormatter(logging.Formatter): - def formatException(self, ei) -> str: - return "" - - -def _configure_logging() -> None: - logging.basicConfig( - level=logging.INFO, - format="%(levelname)s:%(name)s:%(message)s", - force=True, - ) - for handler in logging.getLogger().handlers: - handler.setFormatter(_OneLineExceptionFormatter("%(levelname)s:%(name)s:%(message)s")) - - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - description="Capture camera frames on MacOS (avfoundation) and publish via write_frame." - ) - p.add_argument( - "orchestrator", - nargs="?", - default=None, - help="Orchestrator (host:port). If omitted, discovery is used.", - ) - p.add_argument( - "--signer", - default=None, - help="Remote signer URL (no path). If omitted, runs in offchain mode.", - ) - p.add_argument( - "--model", - default=DEFAULT_MODEL_ID, - help=f"Pipeline model to start via /live-video-to-video. Default: {DEFAULT_MODEL_ID}", - ) - p.add_argument( - "--device", - default=DEFAULT_DEVICE, - help=( - "Camera device index for avfoundation (default: 0). " - 'List devices with: ffmpeg -f avfoundation -list_devices true -i "". ' - "Ignored when --input is used." - ), - ) - p.add_argument("--fps", type=float, default=DEFAULT_FPS, help="Frames per second (default: 30).") - p.add_argument( - "--video-size", - default=DEFAULT_VIDEO_SIZE, - help=f"Capture size (e.g. '1920x1080'). Default: {DEFAULT_VIDEO_SIZE}. Ignored when --input is used.", - ) - p.add_argument( - "--pixel-format", - default=DEFAULT_PIXEL_FORMAT, - help=( - "Capture pixel format for avfoundation. " - "Supported formats vary by device; common options: uyvy422, yuyv422, nv12. " - "Ignored when --input is used." - ), - ) - p.add_argument( - "--input", - default=None, - help="Path to a local media file. If omitted, captures from camera.", - ) - p.add_argument( - "--output", - default=None, - help="Write subscribed media output to '-' (stdout) or a file path.", - ) - return p.parse_args() - - -def _capture_frames( - input_: av.container.InputContainer, - frame_queue: "queue.Queue[object]", - stop_event: threading.Event, -) -> None: - try: - print("Running camera capture...") - while not stop_event.is_set(): - try: - for frame in input_.decode(video=0): - if stop_event.is_set(): - break - # Use wall-clock PTS via MediaPublish, not captured PTS - frame.pts = None - frame_queue.put(frame) - except av.BlockingIOError: - continue - finally: - frame_queue.put(_STOP) - - -def _capture_file_frames( - input_: av.container.InputContainer, - frame_queue: "queue.Queue[object]", - stop_event: threading.Event, -) -> None: - prev_pts: int | None = None - prev_wall: float | None = None - try: - print("Running file capture...") - for frame in input_.decode(video=0): - if stop_event.is_set(): - break - - if ( - prev_pts is not None - and prev_wall is not None - and frame.pts is not None - and frame.time_base is not None - ): - delta_s = float((frame.pts - prev_pts) * frame.time_base) - elapsed_s = time.monotonic() - prev_wall - sleep_s = max(0.0, delta_s - elapsed_s) - if sleep_s > 0: - time.sleep(sleep_s) - - if frame.pts is not None and frame.time_base is not None: - prev_pts = frame.pts - # Track just before enqueue so next sleep subtracts enqueue/processing cost. - prev_wall = time.monotonic() - else: - prev_pts = None - prev_wall = None - - frame_queue.put(frame) - finally: - frame_queue.put(_STOP) - - -async def _write_media_output(job, output: str) -> None: - if output == "-" or output == "stdout": - out = sys.stdout.buffer - close_out = False - else: - out = open(output, "wb") - close_out = True - - try: - async with job.media_output() as sub: - async for chunk in sub.bytes(): - await asyncio.to_thread(out.write, chunk) - finally: - if close_out: - out.close() - - -async def main() -> None: - _configure_logging() - args = _parse_args() - job = None - input_ = None - capture_thread: threading.Thread | None = None - stop_event = threading.Event() - output_task: asyncio.Task[None] | None = None - - try: - job = start_lv2v( - args.orchestrator, - StartJobRequest(model_id=args.model), - signer_url=args.signer, - ) - - print("=== LiveVideoToVideo ===") - print("publish_url:", job.publish_url) - if args.output: - print("subscribe_url:", job.subscribe_url) - print() - - av.logging.set_level(av.logging.ERROR) - capture_target = _capture_frames - capture_name = "CameraCapture" - media_fps = args.fps - if args.input: - input_ = av.open(args.input) - if not input_.streams.video: - raise LivepeerGatewayError(f"No video stream found in input file: {args.input}") - video_stream = input_.streams.video[0] - rate = video_stream.average_rate or video_stream.guessed_rate - if rate is not None: - media_fps = float(rate) - capture_target = _capture_file_frames - capture_name = "FileCapture" - else: - input_ = av.open( - args.device, - format="avfoundation", - container_options={ - "framerate": str(args.fps), - "video_size": args.video_size, - "pixel_format": args.pixel_format, - }, - ) - - media = job.start_media( - MediaPublishConfig( - tracks=[VideoOutputConfig(fps=media_fps)], - ) - ) - if args.output: - output_task = asyncio.create_task(_write_media_output(job, args.output)) - - frame_queue: "queue.Queue[object]" = queue.Queue(maxsize=8) - capture_thread = threading.Thread( - target=capture_target, - args=(input_, frame_queue, stop_event), - name=capture_name, - daemon=True, - ) - capture_thread.start() - - print("Running publish...") - while True: - item = await asyncio.to_thread(frame_queue.get) - if item is _STOP: - break - frame = item - await media.write_frame(frame) - except KeyboardInterrupt: - print("Recording stopped by user") - except LivepeerGatewayError as e: - print(f"Error processing frame: {e}") - finally: - stop_event.set() - if capture_thread is not None and capture_thread.is_alive(): - # Let the capture thread observe stop_event and exit before closing - # the input to avoid concurrent decode/close races in PyAV. - await asyncio.to_thread(capture_thread.join, 2.0) - if output_task is not None: - output_task.cancel() - with suppress(asyncio.CancelledError): - await output_task - if input_ is not None: - try: - input_.close() - except Exception: - pass - if job is not None: - try: - await job.close() - except LivepeerGatewayError as e: - print(f"Error closing job: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) - diff --git a/examples/echo/README.md b/examples/echo/README.md index b00a2ac..e500a1d 100644 --- a/examples/echo/README.md +++ b/examples/echo/README.md @@ -1,40 +1,44 @@ # Echo Live Runner Demo -This example demonstrates: +Registers `livepeer-sample/echo` and shows input, loopback, and output in a +2×2 composite with FPS and latency overlays. -* Runner registration -* Video input - taken from a local file -* Video output - echoed to output with blur applied -* Parameter updates - adjust the amount of blur - -Start go-livepeer: +Install [Livepeer Node](https://github.com/livepeer/node), put its executables on +your `PATH`, then run from the repository root: ```sh -./livepeer -orchestrator -useLiveRunners -serviceAddr localhost:8935 -v 99 -orchSecret abcdef +uv sync --extra examples +LIVEPEER_ORCHESTRATOR_BOOTSTRAP_SECRET=abcdef livepeer orchestrator \ + --config="" --listen 127.0.0.1:8935 --runner-grants 127.0.0.1:8989 ``` -Start the runner: +Start the runner in another terminal: ```sh -uv run examples/echo/runner.py --orchestrator https://localhost:8935 --orchSecret abcdef +uv run examples/echo/runner.py --orchestrator http://localhost:8935 --orchSecret abcdef ``` -Run the client with a local sample input (`~/samples/bbb_720p.mp4`): +Run the client with the macOS camera, or supply a video file on any platform: ```sh -uv run examples/echo/client.py --blur ~/samples/bbb_720p.mp4 +uv run examples/echo/client.py --blur +uv run examples/echo/client.py --blur "" ``` -The resulting file is stored at echo-out.ts. To use a different file -or redirect to stdout for live playback: +`--blur` sweeps the runner's blur radius; without it, frames return unchanged. +Press `q`, close the window, or press Ctrl-C to stop. Files play at their source +rate and the client exits after the output drains. -```sh -uv run client.py --blur --output - ~/samples/bbb_720p.mp4 | ffplay - -``` +The latency overlays show input minus loopback (RTT), input minus output (Total Delta), +and loopback minus output (Processing). These are differences between the +latest frame timestamps, not measurements of individual frame processing. -The client discovers the `livepeer-sample/echo` runner automatically. To use a -different orchestrator or discovery endpoint: +Use `--output` to save raw MPEG-TS or pipe it to a player instead of opening +the composite. Diagnostics go to stderr: ```sh -uv run examples/echo/client.py --discovery http://localhost:8935/discovery --blur ~/samples/bbb_720p.mp4 +uv run examples/echo/client.py --blur --output echo-out.ts "" +uv run examples/echo/client.py --blur --output - "" | ffplay -fflags nobuffer -flags low_delay -probesize 32 -i - ``` + +See `uv run examples/echo/client.py --help` for camera and runner selection options. diff --git a/examples/echo/client.py b/examples/echo/client.py index 0b91738..cd22b0a 100755 --- a/examples/echo/client.py +++ b/examples/echo/client.py @@ -1,42 +1,96 @@ #!/usr/bin/env python3 +"""Show camera/file input, loopback, and echo output, or stream raw output.""" + from __future__ import annotations import argparse import asyncio +import logging +import os +import queue import sys +import threading import time +from collections import deque from contextlib import nullcontext, suppress +from dataclasses import dataclass, field +from fractions import Fraction from pathlib import Path +from typing import Any, BinaryIO import av from livepeer_gateway.errors import LivepeerGatewayError +from livepeer_gateway.http import post_json from livepeer_gateway.live_runner import stop_runner_session from livepeer_gateway.media_output import MediaOutput -from livepeer_gateway.media_publish import MediaPublish -from livepeer_gateway.http import post_json +from livepeer_gateway.media_publish import ( + MediaPublish, + MediaPublishConfig, + VideoOutputConfig, +) from livepeer_gateway.selection import reserve_session +from livepeer_gateway.token import parse_token DEFAULT_DISCOVERY = "http://localhost:8935/discovery" ECHO_APP_ID = "livepeer-sample/echo" -DEFAULT_OUTPUT = "echo-out.ts" -BLUR_UPDATE_INTERVAL_S = 0.01 +BLUR_UPDATE_INTERVAL_S = 0.05 +BLUR_RADIUS_STEP = 5 MAX_BLUR_RADIUS = 100 +_TIME_BASE = Fraction(1, 90_000) +_STOP = object() def _log(*args: object) -> None: print(*args, file=sys.stderr) -def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Run the proxied echo Live Runner demo.") - parser.add_argument("input") - parser.add_argument("--discovery", default=DEFAULT_DISCOVERY) - parser.add_argument("--output", default=DEFAULT_OUTPUT) - parser.add_argument("--radius", type=int, default=75) - parser.add_argument("--max-frames", type=int, default=0, help="Stop after this many input video frames (0 = full file).") - parser.add_argument("--blur", action="store_true", help="Sweep blur radius while publishing the sample.") - return parser.parse_args() +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "input", nargs="?", help="Local video file; otherwise capture the macOS camera." + ) + parser.add_argument( + "--orchestrator", help="Orchestrator host:port or URL to discover runners on." + ) + parser.add_argument( + "--discovery", help=f"Discovery endpoint (default: {DEFAULT_DISCOVERY})." + ) + parser.add_argument( + "--signer", help="Remote signer URL; omitted for offchain mode." + ) + parser.add_argument( + "--token", help="Base64 gateway token; non-empty fields override CLI settings." + ) + parser.add_argument( + "--output", + help="Write raw output to a file or '-' / 'stdout' instead of opening the composite.", + ) + parser.add_argument( + "--device", + default="0", + help="avfoundation camera index (default: 0); ignored for files.", + ) + parser.add_argument( + "--fps", type=float, default=30.0, help="Camera FPS (default: 30)." + ) + parser.add_argument( + "--video-size", default="640x480", help="Camera size (default: 640x480)." + ) + parser.add_argument( + "--pixel-format", default="nv12", help="Camera pixel format (default: nv12)." + ) + parser.add_argument("--debug", action="store_true", help="Enable debug logging.") + parser.add_argument( + "--max-frames", + type=int, + default=0, + help="Stop after this many video frames (0 = unlimited).", + ) + parser.add_argument( + "--blur", action="store_true", help="Sweep blur radius while publishing." + ) + return parser.parse_args(argv) def _channel_url(echo_response: dict[str, object], name: str) -> str: @@ -46,117 +100,476 @@ def _channel_url(echo_response: dict[str, object], name: str) -> str: return url -async def _publish_video( - input_path: Path, - publish_url: str, - *, - max_frames: int = 0, - app_url: str = "", - blur: bool = False, +def _enqueue(item: object, frames: queue.Queue[object], stop: threading.Event) -> bool: + while not stop.is_set(): + try: + frames.put(item, timeout=0.1) + return True + except queue.Full: + pass + return False + + +def _capture_frames( + args: argparse.Namespace, frames: queue.Queue[object], stop: threading.Event ) -> None: - input_ = av.open(str(input_path)) + """Own the input container entirely on this thread, including its close.""" try: - if not input_.streams.video: - raise LivepeerGatewayError(f"No video stream found in input file: {input_path}") - publisher = MediaPublish(publish_url) - prev_pts_time: float | None = None - prev_wall: float | None = None - next_update_pts_time: float | None = None - blur_radius = 0 - blur_direction = 1 + if args.input: + input_ = av.open(str(Path(args.input).expanduser())) + else: + input_ = av.open( + args.device, + format="avfoundation", + container_options={ + "framerate": str(args.fps), + "video_size": args.video_size, + "pixel_format": args.pixel_format, + }, + ) + with input_: + if not input_.streams.video: + raise LivepeerGatewayError( + f"No video stream found in input: {args.input or args.device}" + ) + stream = input_.streams.video[0] + rate = stream.average_rate or stream.guessed_rate + fps = float(rate) if args.input and rate else args.fps + if not _enqueue(VideoOutputConfig(fps=fps), frames, stop): + return + start_time: float | None = None + prev_pts_time: float | None = None + prev_wall: float | None = None + count = 0 + while not stop.is_set(): + try: + for frame in input_.decode(video=0): + if stop.is_set(): + return + now = time.monotonic() + if args.input: + pts_time = ( + float(frame.pts * frame.time_base) + if frame.pts is not None and frame.time_base is not None + else None + ) + if ( + pts_time is not None + and prev_pts_time is not None + and prev_wall is not None + ): + if stop.wait( + max( + 0.0, + pts_time - prev_pts_time - (now - prev_wall), + ) + ): + return + prev_pts_time = pts_time + prev_wall = ( + time.monotonic() if pts_time is not None else None + ) + else: + if start_time is None: + start_time = now + frame.pts = round((now - start_time) / _TIME_BASE) + frame.time_base = _TIME_BASE + if not _enqueue(frame, frames, stop): + return + count += 1 + if args.max_frames > 0 and count >= args.max_frames: + return + return + except av.BlockingIOError: + if args.input: + raise + stop.wait(0.01) + except Exception as exc: + _enqueue(exc, frames, stop) + finally: + _enqueue(_STOP, frames, stop) + + +@dataclass +class _Tile: + image: Any = None + pts_time: float | None = None + frame_times: deque[float] = field(default_factory=deque) + +class _CompositeViewer: + def __init__(self) -> None: + try: + import cv2 + import numpy as np + except ImportError as exc: + raise LivepeerGatewayError( + "The composite requires OpenCV and numpy; run: uv sync --extra examples" + ) from exc + self.cv2 = cv2 + self.np = np + self.tiles = {name: _Tile() for name in ("input", "loopback", "output")} + self.samples: dict[str, deque[tuple[float, float]]] = { + name: deque() for name in ("RTT", "Total Delta", "Processing") + } + self.title = "input | loopback | output | empty (press q to quit)" + + def update(self, name: str, frame: av.VideoFrame, pts_time: float | None) -> None: + tile = self.tiles[name] + tile.image = frame.to_ndarray(format="bgr24") + tile.pts_time = pts_time + tile.frame_times.append(time.monotonic()) + + def _latency_label(self, name: str, delta: float | None, now: float) -> str: + samples = self.samples[name] + while samples and samples[0][0] < now - 3.5: + samples.popleft() + if delta is None: + return f"{name}: n/a" + samples.append((now, delta)) + average = sum(value for _, value in samples) / len(samples) + return f"{name}: {delta:+.3f}s (avg {average:+.3f}s)" + + def _letterbox(self, image: Any, size: int) -> Any: + tile = self.np.zeros((size, size, 3), dtype=self.np.uint8) + if image is not None: + height, width = image.shape[:2] + scale = min(size / width, size / height) + new_width, new_height = ( + max(1, round(width * scale)), + max(1, round(height * scale)), + ) + resized = self.cv2.resize(image, (new_width, new_height)) + x, y = (size - new_width) // 2, (size - new_height) // 2 + tile[y : y + new_height, x : x + new_width] = resized + return tile + + def _draw_labels(self, image: Any, lines: list[str]) -> None: + for index, line in enumerate(lines, start=1): + width = self.cv2.getTextSize(line, self.cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2)[ + 0 + ][0] + scale = min(0.7, 0.7 * max(1, image.shape[1] - 24) / max(1, width)) + (text_width, text_height), baseline = self.cv2.getTextSize( + line, self.cv2.FONT_HERSHEY_SIMPLEX, scale, 2 + ) + y = 28 * index + self.cv2.rectangle( + image, + (8, y - text_height - 4), + (16 + text_width, y + baseline + 3), + (0, 0, 0), + self.cv2.FILLED, + ) + self.cv2.putText( + image, + line, + (12, y), + fontFace=self.cv2.FONT_HERSHEY_SIMPLEX, + fontScale=scale, + color=(0, 255, 0), + thickness=2, + lineType=self.cv2.LINE_AA, + ) + + def render(self) -> Any: + now = time.monotonic() + source, loop, output = ( + self.tiles[name] for name in ("input", "loopback", "output") + ) + base = next( + (tile.image for tile in self.tiles.values() if tile.image is not None), None + ) + size = min(base.shape[:2]) if base is not None else 480 + rtt = ( + source.pts_time - loop.pts_time + if source.pts_time is not None and loop.pts_time is not None + else None + ) + total = ( + source.pts_time - output.pts_time + if source.pts_time is not None and output.pts_time is not None + else None + ) + processing = ( + loop.pts_time - output.pts_time + if loop.pts_time is not None and output.pts_time is not None + else None + ) + rtt_label = self._latency_label("RTT", rtt, now) + total_label = self._latency_label("Total Delta", total, now) + processing_label = self._latency_label("Processing", processing, now) + images = [] + for name, tile in self.tiles.items(): + while tile.frame_times and tile.frame_times[0] < now - 1.0: + tile.frame_times.popleft() + title = ( + name.upper() if tile.image is not None else f"{name.upper()} PENDING" + ) + labels = [title, f"FPS: {len(tile.frame_times):.1f}"] + if name == "loopback": + labels.append(rtt_label) + elif name == "output": + labels.extend([processing_label, total_label]) + image = self._letterbox(tile.image, size) + self._draw_labels(image, labels) + images.append(image) + empty = self._letterbox(None, size) + self._draw_labels(empty, ["EMPTY"]) + return self.np.vstack( + [self.np.hstack(images[:2]), self.np.hstack([images[2], empty])] + ) + + async def run(self) -> None: try: - for index, frame in enumerate(input_.decode(video=0), start=1): - if max_frames > 0 and index > max_frames: - break - current_pts_time = None - if frame.pts is not None and frame.time_base is not None: - current_pts_time = float(frame.pts * frame.time_base) - if next_update_pts_time is None: - next_update_pts_time = current_pts_time - - while ( - blur - and app_url - and current_pts_time is not None - and next_update_pts_time is not None - and current_pts_time >= next_update_pts_time - ): - await post_json(f"{app_url.rstrip('/')}/update", {"mode": "blur", "radius": blur_radius}) - if blur_radius == MAX_BLUR_RADIUS: - blur_direction = -1 - elif blur_radius == 0: - blur_direction = 1 - blur_radius += blur_direction - next_update_pts_time += BLUR_UPDATE_INTERVAL_S - - if ( - prev_pts_time is not None - and prev_wall is not None - and current_pts_time is not None - ): - delta_s = current_pts_time - prev_pts_time - elapsed_s = time.monotonic() - prev_wall - sleep_s = max(0.0, delta_s - elapsed_s) - if sleep_s > 0: - await asyncio.sleep(sleep_s) - - if current_pts_time is not None: - prev_pts_time = current_pts_time - prev_wall = time.monotonic() - - await publisher.write_frame(frame) + self.cv2.namedWindow(self.title, self.cv2.WINDOW_AUTOSIZE) + while True: + self.cv2.imshow(self.title, self.render()) + if self.cv2.waitKey(1) & 0xFF == ord("q"): + return + try: + visible = self.cv2.getWindowProperty( + self.title, self.cv2.WND_PROP_VISIBLE + ) + except self.cv2.error: + # Some backends remove the window as soon as it is closed. + return + if visible < 1: + return + await asyncio.sleep(0.005) + except self.cv2.error as exc: + raise LivepeerGatewayError( + "Cannot display the composite; use GUI-capable OpenCV and a desktop, or --output." + ) from exc finally: + with suppress(self.cv2.error): + self.cv2.destroyAllWindows() + + +async def _sweep_blur(app_url: str) -> None: + update_url = f"{app_url.rstrip('/')}/update" + radius, step = 0, BLUR_RADIUS_STEP + while True: + started = time.monotonic() + await post_json(update_url, {"mode": "blur", "radius": radius}) + if radius == MAX_BLUR_RADIUS: + step = -BLUR_RADIUS_STEP + elif radius == 0: + step = BLUR_RADIUS_STEP + radius += step + # Slow requests reduce the update rate without accumulating catch-up work. + await asyncio.sleep( + max(0.0, BLUR_UPDATE_INTERVAL_S - (time.monotonic() - started)) + ) + + +async def _publish_video( + args: argparse.Namespace, + publish_url: str, + *, + viewer: _CompositeViewer | None = None, +) -> None: + frames: queue.Queue[object] = queue.Queue(maxsize=8) + stop = threading.Event() + capture = threading.Thread( + target=_capture_frames, + args=(args, frames, stop), + name="EchoCapture", + daemon=True, + ) + publisher = None + capture.start() + try: + while True: + try: + item = await asyncio.to_thread(frames.get, True, 0.1) + except queue.Empty: + continue + if item is _STOP: + break + if isinstance(item, Exception): + raise item + if isinstance(item, VideoOutputConfig): + publisher = MediaPublish( + publish_url, config=MediaPublishConfig(tracks=[item]) + ) + continue + frame = item + if viewer is not None: + pts_time = ( + float(frame.pts * frame.time_base) + if frame.pts is not None and frame.time_base is not None + else None + ) + viewer.update("input", frame, pts_time) + await publisher.write_frame(frame) + finally: + stop.set() + await asyncio.to_thread(capture.join, 2.0) + if capture.is_alive(): + _log( + "Camera decode is still stopping; its capture thread will close the input." + ) + if publisher is not None: await publisher.close() + _log("Media publish stats:", publisher.get_stats()) + + +async def _run_media( + args: argparse.Namespace, + in_url: str, + out_url: str, + app_url: str, + viewer: _CompositeViewer | None, + output_file: BinaryIO | None, + output_stdout: bool, +) -> None: + streams: list[MediaOutput] = [] + publish = display = blur = None + drain = False + try: + if viewer is not None: + + def _on_output(decoded) -> None: + if decoded.kind == "video": + viewer.update("output", decoded.frame, decoded.pts_time) + + def _on_loopback(decoded) -> None: + if decoded.kind == "video": + viewer.update("loopback", decoded.frame, decoded.pts_time) + + streams.append(MediaOutput(out_url, on_frame=_on_output)) + streams.append(MediaOutput(in_url, on_frame=_on_loopback)) + else: + + def _write_chunk(chunk: bytes) -> None: + output_file.write(chunk) + if output_stdout: + output_file.flush() + + streams.append(MediaOutput(out_url, on_bytes=_write_chunk)) + display = asyncio.create_task(viewer.run()) if viewer is not None else None + publish = asyncio.create_task(_publish_video(args, in_url, viewer=viewer)) + blur = asyncio.create_task(_sweep_blur(app_url)) if args.blur else None + waiters = { + publish, + *(task for stream in streams for task in stream.callback_tasks()), + } + if display is not None: + waiters.add(display) + if blur is not None: + waiters.add(blur) + done, _ = await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED) + for task in done: + task.result() + drain = publish in done + if drain: + _log("publish complete; waiting for output to drain...") finally: - input_.close() + close_error = None + tasks = [task for task in (publish, blur) if task is not None] + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + for stream in streams: + try: + await stream.close(wait_callbacks=drain) + except Exception as exc: + if close_error is None: + close_error = exc + _log("Error closing media stream:", exc) + _log(f"Media output stats ({stream.subscribe_url}):", stream.get_stats()) + if display is not None: + display.cancel() + await asyncio.gather(display, return_exceptions=True) + # Reached only if the media loop had no error or cancellation to preserve. + if close_error is not None: + raise close_error async def main() -> None: args = _parse_args() - input_path = Path(args.input).expanduser() - output_stdout = args.output.strip().lower() in {"-", "stdout"} - output_path = None if output_stdout else Path(args.output).expanduser() - if not input_path.exists(): - raise SystemExit(f"input file does not exist: {input_path}") - + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, stream=sys.stderr + ) + av.logging.set_level(av.logging.ERROR) session = None - try: - session = await reserve_session(discovery_url=args.discovery, app=ECHO_APP_ID) - _log("runner_url:", session.runner.url if session.runner is not None else session.runner_url) - _log("session_id:", session.session_id) - _log("app_url:", session.app_url) - - echo = await post_json(f"{session.app_url.rstrip('/')}/echo", {"radius": args.radius}) - in_url = _channel_url(echo, "in") - out_url = _channel_url(echo, "out") - _log("in:", in_url) - _log("out:", out_url) - - with nullcontext(sys.stdout.buffer) if output_stdout else output_path.open("wb") as fh: - def _write_chunk(chunk: bytes) -> None: - fh.write(chunk) - if output_stdout: - fh.flush() - - async with MediaOutput(out_url, on_bytes=_write_chunk): - await _publish_video( - input_path, - in_url, - max_frames=max(0, args.max_frames), - app_url=session.app_url, - blur=args.blur, - ) - _log("publish complete; waiting for output to drain...") - fh.flush() - except LivepeerGatewayError as exc: + if args.input: + if not Path(args.input).expanduser().is_file(): + raise LivepeerGatewayError(f"input file does not exist: {args.input}") + elif sys.platform != "darwin": + raise LivepeerGatewayError( + "Camera capture uses macOS avfoundation; supply a file input on other platforms." + ) + viewer = _CompositeViewer() if args.output is None else None + output_stdout = args.output is not None and args.output.strip().lower() in { + "-", + "stdout", + } + output_context = nullcontext(sys.stdout.buffer if output_stdout else None) + if args.output is not None and not output_stdout: + output_context = Path(args.output).expanduser().open("wb") + with output_context as output_file: + token = parse_token(args.token) if args.token else {} + orchestrators = token.get("orchestrators") or ( + [args.orchestrator] if args.orchestrator else None + ) + signer_url = token.get("signer") or args.signer + discovery_url = token.get("discovery") or args.discovery + if not (orchestrators or signer_url or discovery_url): + discovery_url = DEFAULT_DISCOVERY + session = await reserve_session( + app=ECHO_APP_ID, + orchestrators=orchestrators, + signer_url=signer_url, + signer_headers=token.get("signer_headers"), + discovery_url=discovery_url, + discovery_headers=token.get("discovery_headers"), + ) + _log( + "runner_url:", + session.runner.url + if session.runner is not None + else session.runner_url, + ) + _log("session_id:", session.session_id) + _log("app_url:", session.app_url) + echo = await post_json(f"{session.app_url.rstrip('/')}/echo", {}) + in_url, out_url = _channel_url(echo, "in"), _channel_url(echo, "out") + _log("in:", in_url) + _log("out:", out_url) + await _run_media( + args, + in_url, + out_url, + session.app_url, + viewer, + output_file, + output_stdout, + ) + if output_file is not None: + output_file.flush() + except BrokenPipeError: + _log("Playback pipe closed.") + raise + except (LivepeerGatewayError, av.FFmpegError, OSError) as exc: raise SystemExit(f"ERROR: {exc}") from exc finally: if session is not None: - with suppress(Exception): + try: await stop_runner_session(session) + except Exception as exc: + _log("Error releasing runner session:", exc) if __name__ == "__main__": - asyncio.run(main()) + try: + asyncio.run(main()) + except BrokenPipeError: + # Prevent the interpreter's final stdout flush from raising again. + with open(os.devnull, "wb") as sink, suppress(OSError, ValueError): + os.dup2(sink.fileno(), sys.stdout.fileno()) + except KeyboardInterrupt: + _log("Stopped by user.") diff --git a/examples/get_orchestrator_info.py b/examples/get_orchestrator_info.py deleted file mode 100644 index 1ba239a..0000000 --- a/examples/get_orchestrator_info.py +++ /dev/null @@ -1,426 +0,0 @@ -import argparse -import json -import logging -from typing import Any - -from livepeer_gateway.capabilities import ( - compute_available, - format_capability, - get_capacity_in_use, - get_per_capability_map, -) -from livepeer_gateway import get_orch_info -from livepeer_gateway.discovery import discover_orchestrators -from livepeer_gateway.errors import LivepeerGatewayError -from livepeer_gateway.token import parse_token - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - description="Fetch Livepeer orchestrator info.", - epilog=( - "Examples, in priority order of application (highest first):\n" - " # Orchestrator list\n" - " python examples/get_orchestrator_info.py localhost:8935 localhost:8936\n" - " python examples/get_orchestrator_info.py 'localhost:8935,localhost:8936'\n" - " python examples/get_orchestrator_info.py localhost:8935 --signer https://signer.example.com\n" - "\n" - " # Discovery URL\n" - " python examples/get_orchestrator_info.py --discovery https://discover.example.com/orchestrators\n" - " python examples/get_orchestrator_info.py --discovery https://discover.example.com/orchestrators --signer https://signer.example.com\n" - "\n" - " # Gateway token\n" - " python examples/get_orchestrator_info.py --token \n" - " python examples/get_orchestrator_info.py localhost:8935 --token \n" - "\n" - " # Signer URL\n" - " python examples/get_orchestrator_info.py --signer https://signer.example.com\n" - "\n" - " # JSON / JSONL output\n" - " python examples/get_orchestrator_info.py localhost:8935 --format json\n" - " python examples/get_orchestrator_info.py localhost:8935 --format jsonl\n" - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - p.add_argument( - "orchestrators", - nargs="*", - help="Optional list of orchestrators (host:port) or comma-delimited string.", - ) - p.add_argument( - "--discovery", - default=None, - help="Explicit discovery endpoint URL (overrides signer discovery).", - ) - p.add_argument( - "--signer", - default=None, - help="Remote signer base URL (no path). Can be combined with list/discovery.", - ) - p.add_argument( - "--token", - default=None, - help="Base64-encoded gateway token; token fields override explicit signer/discovery/orchestrator args.", - ) - p.add_argument( - "--debug", - action="store_true", - help="Enable debug logging for discovery diagnostics.", - ) - p.add_argument( - "--format", - choices=("text", "json", "jsonl"), - default="text", - help="Output format: text (default), json, or jsonl.", - ) - return p.parse_args() - - -def _orch_error_summary(orch_url: str | None, err: Exception) -> dict[str, Any]: - return { - "orchestrator": orch_url, - "ok": False, - "error": str(err), - } - - -def _orch_info_summary(orch_url: str, info: Any) -> dict[str, Any]: - result: dict[str, Any] = { - "orchestrator": orch_url, - "ok": True, - "transcoder": info.transcoder, - "eth_address": info.address.hex(), - "version": None, - "capabilities": None, - "pricing": None, - "hardware": None, - } - - if not (info.HasField("capabilities") and info.capabilities.version): - return result - - result["version"] = info.capabilities.version - caps = info.capabilities - per_capability = get_per_capability_map(caps) - cap_ids = set(caps.capacities.keys()) - cap_ids.update(per_capability.keys()) - - capabilities: list[dict[str, Any]] = [] - for cap_id in sorted(cap_ids): - cap_entry: dict[str, Any] = { - "capability_name": format_capability(cap_id), - } - has_capacity = cap_id in caps.capacities - if has_capacity and caps.capacities[cap_id] > 1: - cap_entry["total_capacity"] = int(caps.capacities[cap_id]) - - cap_constraints = per_capability.get(cap_id) - models = getattr(cap_constraints, "models", None) if cap_constraints else None - if models: - model_entries: dict[str, Any] = {} - for model_name, model_constraint in models.items(): - warm = bool(getattr(model_constraint, "warm", False)) - runner = str(getattr(model_constraint, "runnerVersion", "")) or "-" - capacity = int(getattr(model_constraint, "capacity", 0) or 0) - in_use = get_capacity_in_use(model_constraint) - available = compute_available(capacity, in_use) - model_entries[model_name] = { - "warm": warm, - "runner": runner, - "capacity": capacity, - "in_use": in_use, - "available": available, - } - cap_entry["models"] = model_entries - capabilities.append(cap_entry) - result["capabilities"] = capabilities - - has_general_price = info.HasField("price_info") and info.price_info.pricePerUnit > 0 - has_cap_prices = bool(info.capabilities_prices) - if has_general_price or has_cap_prices: - general = None - if has_general_price: - general = { - "price_per_unit_wei": int(info.price_info.pricePerUnit), - "pixels_per_unit": int(info.price_info.pixelsPerUnit if info.price_info.pixelsPerUnit > 0 else 1), - } - - cap_specific = [] - for cap_price in info.capabilities_prices: - if cap_price.pricePerUnit <= 0: - continue - cap_id = cap_price.capability if cap_price.capability else "?" - cap_name = format_capability(cap_id) if cap_id != "?" else "unknown" - cap_specific.append( - { - "capability_name": cap_name, - "constraint": cap_price.constraint or None, - "price_per_unit_wei": int(cap_price.pricePerUnit), - "pixels_per_unit": int(cap_price.pixelsPerUnit if cap_price.pixelsPerUnit > 0 else 1), - } - ) - - result["pricing"] = { - "general": general, - "capability_specific": cap_specific, - } - - if info.hardware: - hardware_entries = [] - for hw in info.hardware: - hw_entry: dict[str, Any] = { - "pipeline": hw.pipeline or "-", - "model_id": hw.model_id or "-", - "gpu_info": None, - } - if hw.gpu_info: - gpu_info: dict[str, Any] = {} - for key, gpu in hw.gpu_info.items(): - mem_free_bytes = int(gpu.memory_free) - mem_total_bytes = int(gpu.memory_total) - gpu_info[key] = { - "id": gpu.id or "-", - "name": gpu.name or "-", - "compute": f"{gpu.major}.{gpu.minor}", - "memory_free_bytes": mem_free_bytes, - "memory_total_bytes": mem_total_bytes, - "memory_free_human": format_bytes(mem_free_bytes), - "memory_total_human": format_bytes(mem_total_bytes), - } - hw_entry["gpu_info"] = gpu_info - hardware_entries.append(hw_entry) - result["hardware"] = hardware_entries - - return result - - -def _print_text_info(orch_url: str, info: Any) -> None: - print("=== OrchestratorInfo ===") - print("Orchestrator:", orch_url) - print("Transcoder URI:", info.transcoder) - print("ETH Address:", info.address.hex()) - if info.HasField("capabilities") and info.capabilities.version: - print("Version:", info.capabilities.version) - else: - print("Capabilities: not provided") - print() - return - print() - - caps = info.capabilities - per_capability = get_per_capability_map(caps) - - cap_ids = set(caps.capacities.keys()) - cap_ids.update(per_capability.keys()) - - if not cap_ids: - print("Capabilities: none advertised") - print() - return - - print("Capabilities:") - for cap_id in sorted(cap_ids): - print(f"- {format_capability(cap_id)}") - - has_capacity = cap_id in caps.capacities - if has_capacity and caps.capacities[cap_id] > 1: - print(f" Total capacity: {caps.capacities[cap_id]}") - - cap_constraints = per_capability.get(cap_id) - models = getattr(cap_constraints, "models", None) if cap_constraints else None - if models: - print(" Models:") - for model_name, model_constraint in models.items(): - warm = bool(getattr(model_constraint, "warm", False)) - runner = str(getattr(model_constraint, "runnerVersion", "")) or "-" - capacity = int(getattr(model_constraint, "capacity", 0) or 0) - in_use = get_capacity_in_use(model_constraint) - available = compute_available(capacity, in_use) - print( - " " - f"{model_name}: warm={warm} runner={runner} " - f"capacity={capacity} in_use={in_use} available={available}" - ) - - if not has_capacity and not models: - print(" Capacity: not provided") - - print() - - has_general_price = info.HasField("price_info") and info.price_info.pricePerUnit > 0 - has_cap_prices = bool(info.capabilities_prices) - - if has_general_price or has_cap_prices: - print("Pricing:") - - if has_general_price: - price_per_unit = info.price_info.pricePerUnit - pixels_per_unit = info.price_info.pixelsPerUnit if info.price_info.pixelsPerUnit > 0 else 1 - print(f" General: {price_per_unit} wei per {pixels_per_unit} pixel(s)") - - if has_cap_prices: - if has_general_price: - print(" Capability-specific prices:") - for cap_price in info.capabilities_prices: - if cap_price.pricePerUnit > 0: - cap_id = cap_price.capability if cap_price.capability else "?" - cap_name = format_capability(cap_id) if cap_id != "?" else "unknown" - price_per_unit = cap_price.pricePerUnit - pixels_per_unit = cap_price.pixelsPerUnit if cap_price.pixelsPerUnit > 0 else 1 - constraint = f" [{cap_price.constraint}]" if cap_price.constraint else "" - indent = " " if has_general_price else " " - print(f"{indent}{cap_name} {constraint}: {price_per_unit} wei per {pixels_per_unit} pixel(s)") - - print() - else: - print("Pricing: not provided") - print() - - if info.hardware: - print("Hardware / GPU:") - for hw in info.hardware: - pipeline = hw.pipeline or "-" - model_id = hw.model_id or "-" - print(f"- Pipeline: {pipeline} | Model: {model_id}") - if not hw.gpu_info: - print(" GPU info: not provided") - continue - for key, gpu in hw.gpu_info.items(): - gpu_id = gpu.id or "-" - name = gpu.name or "-" - compute = f"{gpu.major}.{gpu.minor}" - mem_free = format_bytes(int(gpu.memory_free)) - mem_total = format_bytes(int(gpu.memory_total)) - print( - " " - f"{key}: id={gpu_id} name={name} " - f"compute={compute} mem_free={mem_free} mem_total={mem_total}" - ) - print() - else: - print("Hardware / GPU: not provided") - print() - - -def _print_text_error(orch_url: str | None, err: Exception) -> None: - if orch_url is None: - print(f"ERROR: {err}") - print() - return - print("=== OrchestratorInfo ===") - print("Orchestrator:", orch_url) - print(f"ERROR: {err}") - print() - - -def _print_json_info(orch_url: str, info: Any, json_results: list[dict[str, Any]]) -> None: - json_results.append(_orch_info_summary(orch_url, info)) - - -def _print_json_error(orch_url: str | None, err: Exception, json_results: list[dict[str, Any]]) -> None: - json_results.append(_orch_error_summary(orch_url, err)) - - -def _print_jsonl_info(orch_url: str, info: Any) -> None: - print(json.dumps(_orch_info_summary(orch_url, info), sort_keys=True)) - - -def _print_jsonl_error(orch_url: str | None, err: Exception) -> None: - print(json.dumps(_orch_error_summary(orch_url, err), sort_keys=True)) - - -def _resolve_discovery_args(args: argparse.Namespace) -> tuple[Any, str | None, dict[str, str] | None, str | None, dict[str, str] | None]: - token_data = parse_token(args.token) if args.token else None - - orchestrators = token_data.get("orchestrators") if token_data else None - if orchestrators is None: - orchestrators = args.orchestrators - - signer = token_data.get("signer") if token_data else None - if signer is None: - signer = args.signer - - signer_headers = token_data.get("signer_headers") if token_data else None - - discovery = token_data.get("discovery") if token_data else None - if discovery is None: - discovery = args.discovery - - discovery_headers = token_data.get("discovery_headers") if token_data else None - - return orchestrators, signer, signer_headers, discovery, discovery_headers - - -def main() -> None: - args = _parse_args() - - if args.debug: - logging.basicConfig(level=logging.DEBUG) - - json_results: list[dict[str, Any]] = [] - - def _json_info(orch_url: str, info: Any) -> None: - _print_json_info(orch_url, info, json_results) - - def _json_error(orch_url: str | None, err: Exception) -> None: - _print_json_error(orch_url, err, json_results) - - info_printers = { - "text": _print_text_info, - "json": _json_info, - "jsonl": _print_jsonl_info, - } - error_printers = { - "text": _print_text_error, - "json": _json_error, - "jsonl": _print_jsonl_error, - } - print_info = info_printers[args.format] - print_error = error_printers[args.format] - - try: - orchestrators, signer, signer_headers, discovery, discovery_headers = _resolve_discovery_args(args) - orch_list = discover_orchestrators( - orchestrators, - signer_url=signer, - signer_headers=signer_headers, - discovery_url=discovery, - discovery_headers=discovery_headers, - ) - - for orch_url in orch_list: - try: - info = get_orch_info( - orch_url, - signer_url=signer, - signer_headers=signer_headers, - ) - except LivepeerGatewayError as e: - print_error(orch_url, e) - continue - - print_info(orch_url, info) - - if args.format == "json": - print(json.dumps(json_results, indent=2, sort_keys=True)) - - except LivepeerGatewayError as e: - json_results.clear() - print_error(None, e) - if args.format == "json": - print(json.dumps(json_results, indent=2, sort_keys=True)) - -def format_bytes(num_bytes: int) -> str: - if num_bytes < 0: - return f"{num_bytes} B" - units = ["B", "KiB", "MiB", "GiB", "TiB"] - size = float(num_bytes) - unit_idx = 0 - while size >= 1024.0 and unit_idx < len(units) - 1: - size /= 1024.0 - unit_idx += 1 - if unit_idx == 0: - return f"{int(size)} {units[unit_idx]}" - return f"{size:.2f} {units[unit_idx]} ({num_bytes} B)" - -if __name__ == "__main__": - main() diff --git a/examples/in_out_composite.py b/examples/in_out_composite.py deleted file mode 100644 index dc3c331..0000000 --- a/examples/in_out_composite.py +++ /dev/null @@ -1,603 +0,0 @@ -""" -Capture MacOS camera frames, publish to Livepeer, and show a 2x2 composite -of input, loopback, output, and an empty tile with latency overlays. -""" - -import argparse -import asyncio -from collections import deque -import logging -import queue -import threading -import time -from contextlib import suppress -from fractions import Fraction -from typing import Optional - -import av - -from livepeer_gateway.errors import LivepeerGatewayError -from livepeer_gateway.lv2v import StartJobRequest, start_lv2v -from livepeer_gateway.media_publish import AudioOutputConfig, MediaPublishConfig, VideoOutputConfig -from livepeer_gateway.media_output import MediaOutput - -DEFAULT_MODEL_ID = "noop" # fix -DEFAULT_DEVICE = "0" -DEFAULT_FPS = 30.0 -DEFAULT_VIDEO_SIZE = "640x480" -DEFAULT_PIXEL_FORMAT = "nv12" -_TIME_BASE = 90_000 -_STOP = object() - - -class _OneLineExceptionFormatter(logging.Formatter): - def formatException(self, ei) -> str: - return "" - - -def _configure_logging(debug: bool = False) -> None: - logging.basicConfig( - level=logging.DEBUG if debug else logging.INFO, - format="%(levelname)s:%(name)s:%(message)s", - force=True, - ) - for handler in logging.getLogger().handlers: - handler.setFormatter(_OneLineExceptionFormatter("%(levelname)s:%(name)s:%(message)s")) - - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - description="Capture camera frames and show a 2x2 composite with latency overlays." - ) - p.add_argument( - "orchestrator", - nargs="?", - default=None, - help="Orchestrator (host:port). If omitted, discovery is used.", - ) - p.add_argument( - "--signer", - default=None, - help="Remote signer URL (no path). If omitted, runs in offchain mode.", - ) - p.add_argument( - "--token", - default=None, - help="Base64-encoded gateway token; token fields override explicit signer/discovery/orchestrator args.", - ) - p.add_argument( - "--discovery", - default=None, - help="Discovery endpoint for orchestrators.", - ) - p.add_argument( - "--model", - default=DEFAULT_MODEL_ID, - help=f"Pipeline model to start via /live-video-to-video. Default: {DEFAULT_MODEL_ID}", - ) - p.add_argument( - "--device", - default=DEFAULT_DEVICE, - help=( - "Camera device index for avfoundation (default: 0). " - 'List devices with: ffmpeg -f avfoundation -list_devices true -i "". ' - "Ignored when --input is used." - ), - ) - p.add_argument("--fps", type=float, default=DEFAULT_FPS, help="Frames per second (default: 30).") - p.add_argument( - "--video-size", - default=DEFAULT_VIDEO_SIZE, - help=f"Capture size (e.g. '1920x1080'). Default: {DEFAULT_VIDEO_SIZE}. Ignored when --input is used.", - ) - p.add_argument( - "--pixel-format", - default=DEFAULT_PIXEL_FORMAT, - help=( - "Capture pixel format for avfoundation. " - "Supported formats vary by device; common options: uyvy422, yuyv422, nv12. " - "Ignored when --input is used." - ), - ) - p.add_argument( - "--input", - default=None, - help="Path to a local media file. If omitted, captures from camera.", - ) - p.add_argument( - "--avg-window", - type=float, - default=3.5, - help="Moving average window in seconds for latency metrics (default: 3.5).", - ) - p.add_argument( - "--debug", - action="store_true", - help="Enable debug logging.", - ) - return p.parse_args() - - -def _capture_frames( - input_: av.container.InputContainer, - frame_queue: "queue.Queue[object]", - stop_event: threading.Event, -) -> None: - try: - print("Running camera capture...") - while not stop_event.is_set(): - try: - for frame in input_.decode(video=0): - if stop_event.is_set(): - break - frame_queue.put(frame) - except av.BlockingIOError: - continue - finally: - frame_queue.put(_STOP) - - -def _capture_file_frames( - input_: av.container.InputContainer, - frame_queue: "queue.Queue[object]", - stop_event: threading.Event, -) -> None: - prev_pts_time: float | None = None - prev_wall: float | None = None - try: - print("Running file capture...") - - # Determine which streams should be decoded (skip any data streams) - decode_kwargs: dict[str, int] = {"video": 0} - if input_.streams.audio: - decode_kwargs["audio"] = 0 - - # Go. - for frame in input_.decode(**decode_kwargs): - if stop_event.is_set(): - break - - # Convert per-track PTS into seconds so pacing works across - # streams that have different time bases. - current_pts_time: Optional[float] = None - if frame.pts is not None and frame.time_base is not None: - current_pts_time = float(frame.pts * frame.time_base) - - if ( - prev_pts_time is not None - and prev_wall is not None - and current_pts_time is not None - ): - delta_s = current_pts_time - prev_pts_time - elapsed_s = time.monotonic() - prev_wall - sleep_s = max(0.0, delta_s - elapsed_s) - if sleep_s > 0: - time.sleep(sleep_s) - - if current_pts_time is not None: - prev_pts_time = current_pts_time - # Track just before enqueue so next sleep subtracts enqueue/processing cost. - prev_wall = time.monotonic() - else: - prev_pts_time = None - prev_wall = None - - frame_queue.put(frame) - finally: - frame_queue.put(_STOP) - - -def _bgr_from_frame(frame: av.VideoFrame): - return frame.to_ndarray(format="bgr24") - - -def _resize_to_height(img, height: int): - import cv2 - - if img is None: - return None - h, w = img.shape[:2] - if h == height: - return img - width = max(1, int(round(w * (height / h)))) - return cv2.resize(img, (width, height)) - - -def _letterbox_to_square(img, size: int): - import cv2 - import numpy as np - - tile = np.zeros((size, size, 3), dtype=np.uint8) - if img is None: - return tile - h, w = img.shape[:2] - if h <= 0 or w <= 0: - return tile - scale = min(size / w, size / h) - new_w = max(1, int(round(w * scale))) - new_h = max(1, int(round(h * scale))) - resized = cv2.resize(img, (new_w, new_h)) - x = (size - new_w) // 2 - y = (size - new_h) // 2 - tile[y : y + new_h, x : x + new_w] = resized - return tile - - -def _draw_labels(img, lines): - import cv2 - - y = 28 - for line in lines: - cv2.putText( - img, - line, - (12, y), - cv2.FONT_HERSHEY_SIMPLEX, - 0.7, - (0, 255, 0), - 2, - cv2.LINE_AA, - ) - y += 28 - - -async def main() -> None: - args = _parse_args() - _configure_logging(debug=args.debug) - - try: - import cv2 # type: ignore[import-not-found] - import numpy as np # type: ignore[import-not-found] - except ImportError as e: - print( - "This example requires opencv-python and numpy.\n" - "Install with: pip install opencv-python numpy\n" - f"ImportError: {e}" - ) - return - - job = None - input_ = None - capture_thread: Optional[threading.Thread] = None - stop_event = threading.Event() - stop_async = asyncio.Event() - output_task: Optional[asyncio.Task[None]] = None - loopback_task: Optional[asyncio.Task[None]] = None - display_task: Optional[asyncio.Task[None]] = None - media = None - output_stream: Optional[MediaOutput] = None - loopback_stream: Optional[MediaOutput] = None - - latest = { - "cam_img": None, - "cam_pts_time": None, - "cam_fps": None, - "loop_img": None, - "loop_pts_time": None, - "loop_fps": None, - "out_img": None, - "out_pts_time": None, - "out_fps": None, - } - latest_lock = threading.Lock() - avg_window_s = max(0.1, float(args.avg_window)) - fps_window_s = 1.0 - rtt_samples: "deque[tuple[float, float]]" = deque() - total_samples: "deque[tuple[float, float]]" = deque() - inference_samples: "deque[tuple[float, float]]" = deque() - cam_frame_times: "deque[float]" = deque() - loop_frame_times: "deque[float]" = deque() - out_frame_times: "deque[float]" = deque() - - def _push_avg(samples: "deque[tuple[float, float]]", now: float, value: float) -> float: - samples.append((now, value)) - cutoff = now - avg_window_s - while samples and samples[0][0] < cutoff: - samples.popleft() - return sum(v for _, v in samples) / len(samples) - - def _push_fps(samples: "deque[float]", now: float) -> float: - samples.append(now) - cutoff = now - fps_window_s - while samples and samples[0] < cutoff: - samples.popleft() - return float(len(samples)) / fps_window_s - - def _update_cam(img, pts_time: Optional[float]) -> None: - with latest_lock: - latest["cam_img"] = img - latest["cam_pts_time"] = pts_time - latest["cam_fps"] = _push_fps(cam_frame_times, time.monotonic()) - - def _update_out(img, pts_time: Optional[float]) -> None: - with latest_lock: - latest["out_img"] = img - latest["out_pts_time"] = pts_time - latest["out_fps"] = _push_fps(out_frame_times, time.monotonic()) - - def _update_loop(img, pts_time: Optional[float]) -> None: - with latest_lock: - latest["loop_img"] = img - latest["loop_pts_time"] = pts_time - latest["loop_fps"] = _push_fps(loop_frame_times, time.monotonic()) - - async def _subscribe_output() -> None: - nonlocal output_stream - if job is None: - return - async with job.media_output() as output: - output_stream = output - async for decoded in output.frames(): - if stop_async.is_set(): - break - if decoded.kind != "video": - continue - try: - img = _bgr_from_frame(decoded.frame) - except Exception: - continue - _update_out(img, decoded.pts_time) - - async def _subscribe_loopback() -> None: - nonlocal loopback_stream - if job is None or not job.publish_url: - return - async with MediaOutput(job.publish_url) as loopback: - loopback_stream = loopback - async for decoded in loopback.frames(): - if stop_async.is_set(): - break - if decoded.kind != "video": - continue - try: - img = _bgr_from_frame(decoded.frame) - except Exception: - continue - _update_loop(img, decoded.pts_time) - - async def _display_loop() -> None: - title = "input | loopback | output | empty (press q to quit)" - while not stop_async.is_set(): - with latest_lock: - cam_img = latest["cam_img"] - out_img = latest["out_img"] - cam_pts_time = latest["cam_pts_time"] - cam_fps = latest["cam_fps"] - out_pts_time = latest["out_pts_time"] - out_fps = latest["out_fps"] - loop_img = latest["loop_img"] - loop_pts_time = latest["loop_pts_time"] - loop_fps = latest["loop_fps"] - - if cam_img is not None: - base_img = cam_img - elif loop_img is not None: - base_img = loop_img - else: - base_img = out_img - if base_img is not None: - tile_size = min(base_img.shape[0], base_img.shape[1]) - else: - tile_size = 480 - - if tile_size > 0: - now = time.time() - if cam_pts_time is not None and loop_pts_time is not None: - rtt_s = cam_pts_time - loop_pts_time - rtt_avg = _push_avg(rtt_samples, now, rtt_s) - rtt_label = f"RTT: {rtt_s:+.3f}s (avg {rtt_avg:+.3f}s)" - else: - rtt_s = None - rtt_label = "RTT: n/a" - - if cam_pts_time is not None and out_pts_time is not None: - total_delta_s = cam_pts_time - out_pts_time - total_avg = _push_avg(total_samples, now, total_delta_s) - total_label = f"Total Delta: {total_delta_s:+.3f}s (avg {total_avg:+.3f}s)" - else: - total_delta_s = None - total_label = "Total Delta: n/a" - - if total_delta_s is not None and rtt_s is not None: - inference_s = total_delta_s - rtt_s - inference_avg = _push_avg(inference_samples, now, inference_s) - inference_label = f"Inference: {inference_s:+.3f}s (avg {inference_avg:+.3f}s)" - else: - inference_label = "Inference: n/a" - input_fps_label = f"FPS: {cam_fps:.1f}" if cam_fps is not None else "FPS: n/a" - loop_fps_label = f"FPS: {loop_fps:.1f}" if loop_fps is not None else "FPS: n/a" - out_fps_label = f"FPS: {out_fps:.1f}" if out_fps is not None else "FPS: n/a" - - input_tile = _letterbox_to_square(cam_img, tile_size) - loop_tile = _letterbox_to_square(loop_img, tile_size) - out_tile = _letterbox_to_square(out_img, tile_size) - empty_tile = _letterbox_to_square(None, tile_size) - - if cam_img is None: - _draw_labels(input_tile, ["CAMERA PENDING", input_fps_label]) - else: - _draw_labels(input_tile, ["INPUT", input_fps_label]) - - if loop_img is None: - _draw_labels(loop_tile, ["LOOPBACK PENDING", loop_fps_label]) - else: - _draw_labels(loop_tile, ["LOOPBACK", loop_fps_label, rtt_label]) - - if out_img is None: - _draw_labels(out_tile, ["OUTPUT PENDING", out_fps_label]) - else: - _draw_labels(out_tile, ["OUTPUT", out_fps_label, inference_label, total_label]) - - _draw_labels(empty_tile, ["EMPTY"]) - - top = np.hstack([input_tile, loop_tile]) - bottom = np.hstack([out_tile, empty_tile]) - composite = np.vstack([top, bottom]) - cv2.imshow(title, composite) - - key = cv2.waitKey(1) & 0xFF - if key == ord("q"): - stop_event.set() - stop_async.set() - break - await asyncio.sleep(0.005) - - cv2.destroyAllWindows() - - try: - job = start_lv2v( - args.orchestrator, - StartJobRequest(model_id=args.model), - token=args.token, - signer_url=args.signer, - discovery_url=args.discovery, - ) - - print("=== LiveVideoToVideo ===") - print("publish_url:", job.publish_url) - print("subscribe_url:", job.subscribe_url) - print() - - av.logging.set_level(av.logging.ERROR) - capture_target = _capture_frames - capture_name = "CameraCapture" - media_fps = args.fps - is_file_input = bool(args.input) - media_tracks: list[VideoOutputConfig | AudioOutputConfig] = [] - if args.input: - input_ = av.open(args.input) - if not input_.streams.video: - raise LivepeerGatewayError(f"No video stream found in input file: {args.input}") - video_stream = input_.streams.video[0] - rate = video_stream.average_rate or video_stream.guessed_rate - if rate is not None: - media_fps = float(rate) - media_tracks.append(VideoOutputConfig(fps=media_fps)) - if input_.streams.audio: - audio_stream = input_.streams.audio[0] - audio_layout = "mono" - if audio_stream.layout is not None and audio_stream.layout.name: - audio_layout = str(audio_stream.layout.name) - audio_sample_rate = int(getattr(audio_stream, "rate", 0) or 0) - if audio_sample_rate <= 0: - audio_sample_rate = 48_000 - media_tracks.append( - AudioOutputConfig( - sample_rate=audio_sample_rate, - layout=audio_layout, - ) - ) - capture_target = _capture_file_frames - capture_name = "FileCapture" - else: - input_ = av.open( - args.device, - format="avfoundation", - container_options={ - "framerate": str(args.fps), - "video_size": args.video_size, - "pixel_format": args.pixel_format, - }, - ) - media_tracks.append(VideoOutputConfig(fps=media_fps)) - - media = job.start_media(MediaPublishConfig(tracks=media_tracks)) - - frame_queue: "queue.Queue[object]" = queue.Queue(maxsize=8) - capture_thread = threading.Thread( - target=capture_target, - args=(input_, frame_queue, stop_event), - name=capture_name, - daemon=True, - ) - capture_thread.start() - - output_task = asyncio.create_task(_subscribe_output()) - loopback_task = asyncio.create_task(_subscribe_loopback()) - display_task = asyncio.create_task(_display_loop()) - - time_base = Fraction(1, _TIME_BASE) - last_pts = 0 - last_time: Optional[float] = None - - print("Running publish...") - while not stop_async.is_set(): - item = await asyncio.to_thread(frame_queue.get) - if item is _STOP: - break - frame = item - if isinstance(frame, av.VideoFrame): - if not is_file_input: - now = time.time() - if last_time is not None: - last_pts += int((now - last_time) * _TIME_BASE) - else: - last_pts = 0 - last_time = now - frame.pts = last_pts - frame.time_base = time_base - cam_pts_time = last_pts / _TIME_BASE - else: - cam_pts_time = None - if frame.pts is not None and frame.time_base is not None: - cam_pts_time = float(frame.pts * frame.time_base) - try: - cam_img = _bgr_from_frame(frame) - _update_cam(cam_img, cam_pts_time) - except Exception: - _update_cam(None, cam_pts_time) - await media.write_frame(frame) - continue - - if isinstance(frame, av.AudioFrame): - await media.write_frame(frame) - except KeyboardInterrupt: - print("Recording stopped by user") - except LivepeerGatewayError as e: - print(f"Error processing frame: {e}") - finally: - stop_event.set() - stop_async.set() - if capture_thread is not None and capture_thread.is_alive(): - # Let the capture thread observe stop_event and exit before closing - # the input to avoid concurrent decode/close races in PyAV. - await asyncio.to_thread(capture_thread.join, 2.0) - if output_task is not None: - output_task.cancel() - with suppress(asyncio.CancelledError): - await output_task - if loopback_task is not None: - loopback_task.cancel() - with suppress(asyncio.CancelledError): - await loopback_task - if display_task is not None: - display_task.cancel() - with suppress(asyncio.CancelledError): - await display_task - if input_ is not None: - try: - input_.close() - except Exception: - pass - if media is not None: - media_stats = media.get_stats() - print("\nMedia publish stats:") - print(media_stats) - if output_stream is not None: - output_stats = output_stream.get_stats() - print("\nOutput stream stats:") - print(output_stats) - if loopback_stream is not None: - loopback_stats = loopback_stream.get_stats() - print("\nLoopback stream stats:") - print(loopback_stats) - if job is not None: - try: - await job.close() - except LivepeerGatewayError as e: - print(f"Error closing job: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) - diff --git a/examples/payments.py b/examples/payments.py deleted file mode 100644 index b6e91f9..0000000 --- a/examples/payments.py +++ /dev/null @@ -1,122 +0,0 @@ -# Example: per-segment payments in a mixed sync/async scenario -# -# start_lv2v() is a sync function. When called from within a running -# event loop (eg inside an `async def`), the payment sender background -# task starts automatically. When called from plain sync code, a -# warning is logged and you must call job.start_payment_sender() once -# you enter an async context. -# -# GIANT DISCLAIMER: the mixed sync / async regime in this API is not -# super optimal, but that's how it works for now. This demonstrates the -# control flow that may need to be used. -# -# This example demonstrates a sync-start / async-run pattern if -# orchestrator selection and job creation are synchronous but the main -# workload is async (publishing frames, subscribing to output, etc). - -import argparse -import asyncio -import json -import logging - -from livepeer_gateway.errors import LivepeerGatewayError -from livepeer_gateway.lv2v import StartJobRequest, start_lv2v - -DEFAULT_MODEL_ID = "noop" - - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - description="Start an LV2V job with per-segment payments." - ) - p.add_argument( - "orchestrator", - nargs="?", - default=None, - help="Orchestrator (host:port). If omitted, discovery is used.", - ) - p.add_argument( - "--signer", - default=None, - help="Remote signer URL (no path). If omitted, runs in offchain mode.", - ) - p.add_argument( - "--model", - default=DEFAULT_MODEL_ID, - help=f"Pipeline model. Default: {DEFAULT_MODEL_ID}", - ) - p.add_argument( - "--discovery", - default=None, - help="Discovery endpoint for orchestrators.", - ) - p.add_argument( - "--duration", - type=float, - default=30.0, - help="How long to keep the job alive, in seconds. Default: 30.", - ) - p.add_argument( - "--debug", - action="store_true", - help="Enable debug logging.", - ) - return p.parse_args() - - -def main() -> None: - args = _parse_args() - if args.debug: - logging.basicConfig( - level=logging.DEBUG, - format="%(levelname)s %(name)s: %(message)s", - ) - else: - logging.basicConfig( - level=logging.INFO, - format="%(levelname)s %(name)s: %(message)s", - ) - - # ---- Sync part: create the job ---- - # start_lv2v is synchronous. Because there is no running event loop - # here, the automatic start_payment_sender() call inside start_lv2v - # will log a warning and skip. That is expected. - try: - job = start_lv2v( - args.orchestrator, - StartJobRequest(model_id=args.model), - signer_url=args.signer, - discovery_url=args.discovery, - ) - except LivepeerGatewayError as e: - print(f"ERROR: {e}") - return - - print("=== Job started ===") - print(json.dumps(job.raw, indent=2, sort_keys=True)) - print() - - # ---- Async part: run the payment sender ---- - async def run() -> None: - # Now we have a running event loop. Start the payment sender. - # This is idempotent; if start_lv2v already started it (eg - # when called from an async context), this is a no-op. - job.start_payment_sender() - print("Payment sender started (background task)") - print(f"Running for {args.duration}s...") - print() - - try: - await asyncio.sleep(args.duration) - except KeyboardInterrupt: - print("Interrupted by user") - finally: - print("Closing job (cancels payment sender)...") - await job.close() - print("Done.") - - asyncio.run(run()) - - -if __name__ == "__main__": - main() diff --git a/examples/ping-pong/README.md b/examples/ping-pong/README.md index e09ba38..34cbc7a 100644 --- a/examples/ping-pong/README.md +++ b/examples/ping-pong/README.md @@ -9,22 +9,24 @@ runner. The runner exposes a websocket endpoint: When the websocket closes, the single-shot workload is over and the runner handler releases its per-connection state. -Start go-livepeer: +Install [Livepeer Node](https://github.com/livepeer/node), put its executables on +your `PATH`, and start its local orchestrator: ```sh -./livepeer -orchestrator -useLiveRunners -serviceAddr localhost:8935 -v 99 -orchSecret abcdef +LIVEPEER_ORCHESTRATOR_BOOTSTRAP_SECRET=abcdef livepeer orchestrator \ + --config="" --listen 127.0.0.1:8935 --runner-grants 127.0.0.1:8991 ``` Start the runner: ```sh -uv run runner.py --orchestrator http://localhost:8935 --orchSecret abcdef +uv run examples/ping-pong/runner.py --orchestrator http://localhost:8935 --orchSecret abcdef ``` Run the client: ```sh -uv run client.py +uv run examples/ping-pong/client.py ``` The client discovers the `livepeer-sample/ping-pong` runner, connects @@ -34,5 +36,5 @@ receiver-side delta and the client round-trip time. To send a fixed number of pings: ```sh -uv run client.py --count 10 +uv run examples/ping-pong/client.py --count 10 ``` diff --git a/examples/select_orchestrator.py b/examples/select_orchestrator.py deleted file mode 100644 index 0290f98..0000000 --- a/examples/select_orchestrator.py +++ /dev/null @@ -1,96 +0,0 @@ -import argparse -import logging - -from livepeer_gateway.capabilities import CapabilityId, build_capabilities -from livepeer_gateway.errors import LivepeerGatewayError, NoOrchestratorAvailableError -from livepeer_gateway.selection import orchestrator_selector - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser( - description="Select an orchestrator via discovery or explicit list.", - epilog=( - "Examples, in priority order of application (highest first):\n" - " # Orchestrator list\n" - " python examples/select_orchestrator.py localhost:8935 localhost:8936\n" - " python examples/select_orchestrator.py 'localhost:8935,localhost:8936'\n" - " python examples/select_orchestrator.py localhost:8935 --signer https://signer.example.com\n" - "\n" - " # Discover via given URL\n" - " python examples/select_orchestrator.py --discovery https://discover.example.com/orchestrators\n" - " python examples/select_orchestrator.py --discovery https://discover.example.com/orchestrators \\\n" - " --signer https://signer.example.com\n" - "\n" - " # Discover via signer URL\n" - " python examples/select_orchestrator.py --signer https://signer.example.com\n" - ), - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - p.add_argument( - "orchestrators", - nargs="*", - help="Optional list of orchestrators (host:port) or comma-delimited string.", - ) - p.add_argument( - "--discovery", - default=None, - help="Explicit discovery endpoint URL (overrides signer discovery).", - ) - p.add_argument( - "--model", - default=None, - help="Pipeline model ID for capability filtering. If omitted, no capability filter is applied.", - ) - p.add_argument( - "--signer", - default=None, - help="Remote signer base URL (no path). Can be combined with list/discovery.", - ) - p.add_argument( - "--debug", - action="store_true", - help="Enable debug logging for selection diagnostics.", - ) - return p.parse_args() - - -def main() -> None: - args = _parse_args() - if args.debug: - logging.basicConfig(level=logging.DEBUG) - - capabilities = build_capabilities(CapabilityId.LIVE_VIDEO_TO_VIDEO, args.model) if args.model else None - - try: - cursor = orchestrator_selector( - args.orchestrators or None, - signer_url=args.signer, - discovery_url=args.discovery, - capabilities=capabilities, - ) - orch_url, info = cursor.next() - if args.orchestrators: - mode = "orchestrator list" - elif args.discovery: - mode = "discovery URL" - elif args.signer: - mode = "signer discovery" - else: - mode = "no options" - - print("=== Selected Orchestrator ===") - print("Mode:", mode) - if args.model: - print("Model:", args.model) - print("Orchestrator:", orch_url) - print("Transcoder URI:", info.transcoder) - print("ETH Address:", info.address.hex()) - except NoOrchestratorAvailableError as e: - print(f"ERROR: {e}") - for r in e.rejections: - print(f" rejected {r.url}: {r.reason}") - except LivepeerGatewayError as e: - print(f"ERROR: {e}") - - -if __name__ == "__main__": - main() diff --git a/examples/select_runner.py b/examples/select_runner.py new file mode 100644 index 0000000..a7d8eeb --- /dev/null +++ b/examples/select_runner.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Discover a runner candidate using Livepeer Node's app and GPU filters.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import sys + +from livepeer_gateway.errors import LivepeerGatewayError +from livepeer_gateway.selection import runner_selector + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=__doc__, + epilog=( + "Examples:\n" + " uv run examples/select_runner.py http://localhost:8935\n" + " uv run examples/select_runner.py http://localhost:8935 \\\n" + " --app livepeer-sample/echo\n" + " uv run examples/select_runner.py --signer http://localhost:8937 \\\n" + " --app example/app --gpu H100 --gpu L40S\n" + "\n" + "This prints the first discovered candidate. Calling cursor.next()\n" + "would execute a single-shot app or reserve a persistent session." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "orchestrators", + nargs="*", + help="Orchestrator base URLs or host:port values; comma-separated lists are accepted.", + ) + parser.add_argument( + "--discovery", + help="Explicit discovery endpoint URL (overrides signer discovery).", + ) + parser.add_argument( + "--signer", + help="Remote signer base URL; discovery uses /discover-orchestrators.", + ) + parser.add_argument( + "--app", + action="append", + help="Exact app ID; repeat to match any listed app. Omit to allow all apps.", + ) + parser.add_argument( + "--gpu", + action="append", + help="Exact GPU name (gpu.name); repeat to match any listed GPU. Both app and GPU must match.", + ) + parser.add_argument( + "--debug", action="store_true", help="Enable discovery diagnostics." + ) + args = parser.parse_args(argv) + args.orchestrators = [ + url.strip() + for value in args.orchestrators + for url in value.split(",") + if url.strip() + ] + if not (args.orchestrators or args.discovery or args.signer): + parser.error("provide orchestrators, --discovery, or --signer") + return args + + +async def main(args: argparse.Namespace) -> int: + if args.debug: + logging.basicConfig(level=logging.DEBUG) + + try: + cursor = await runner_selector( + orchestrators=args.orchestrators or None, + signer_url=args.signer, + discovery_url=args.discovery, + app=args.app, + gpu=args.gpu, + ) + # Inspect discovery metadata without executing an app or reserving capacity. + runner = cursor.candidates[0] + print("=== Selected Runner Candidate ===") + print("Orchestrator:", runner.orchestrator_url) + print(json.dumps(runner.raw, indent=2)) + except LivepeerGatewayError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main(_parse_args()))) diff --git a/examples/start_job.py b/examples/start_job.py deleted file mode 100644 index a8e2caf..0000000 --- a/examples/start_job.py +++ /dev/null @@ -1,70 +0,0 @@ -import argparse -import json -import logging - -from livepeer_gateway.errors import LivepeerGatewayError -from livepeer_gateway.lv2v import StartJobRequest, start_lv2v - -DEFAULT_MODEL_ID = "noop" # fix - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="Fetch orchestrator info via Livepeer gRPC.") - p.add_argument( - "orchestrator", - nargs="?", - default=None, - help="Orchestrator (host:port). If omitted, discovery is used.", - ) - p.add_argument( - "--signer", - default=None, - help="Remote signer URL (no path). If omitted, runs in offchain mode.", - ) - p.add_argument( - "--model", - default=DEFAULT_MODEL_ID, - help=f"Pipeline model to start via /live-video-to-video. Default: {DEFAULT_MODEL_ID}", - ) - p.add_argument( - "--discovery", - default=None, - help="Discovery endpoint for orchestrators.", - ) - p.add_argument( - "--debug", - action="store_true", - help="Enable debug logging.", - ) - return p.parse_args() - -def main() -> None: - args = _parse_args() - if args.debug: - logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s: %(message)s") - - try: - job = start_lv2v( - args.orchestrator, - StartJobRequest( - model_id=args.model, - ), - signer_url=args.signer, - discovery_url=args.discovery, - ) - orch = job.orchestrator_info - - print("=== OrchestratorInfo ===") - print("Orchestrator:", orch.transcoder) - print("ETH Address:", orch.address.hex()) - print() - - print("=== start_lv2v ===") - print(json.dumps(job.raw, indent=2, sort_keys=True)) - print() - - except LivepeerGatewayError as e: - print(f"ERROR: {e}") - print() - -if __name__ == "__main__": - main() diff --git a/examples/subscribe_events.py b/examples/subscribe_events.py deleted file mode 100644 index 554205f..0000000 --- a/examples/subscribe_events.py +++ /dev/null @@ -1,71 +0,0 @@ -import argparse -import asyncio -import json - -from livepeer_gateway.errors import LivepeerGatewayError -from livepeer_gateway.lv2v import StartJobRequest, start_lv2v - - -DEFAULT_MODEL_ID = "noop" # fix - - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="Start an LV2V job and subscribe to its events channel.") - p.add_argument( - "orchestrator", - nargs="?", - default=None, - help="Orchestrator (host:port). If omitted, discovery is used.", - ) - p.add_argument( - "--signer", - default=None, - help="Remote signer URL (no path). If omitted, runs in offchain mode.", - ) - p.add_argument( - "--model", - default=DEFAULT_MODEL_ID, - help=f"Pipeline model to start via /live-video-to-video. Default: {DEFAULT_MODEL_ID}", - ) - p.add_argument("--count", type=int, default=0, help="Max events to print (0 = no limit).") - return p.parse_args() - - -async def main() -> None: - args = _parse_args() - - try: - job = start_lv2v( - args.orchestrator, - StartJobRequest(model_id=args.model), - signer_url=args.signer, - ) - - print("=== LiveVideoToVideo ===") - print("events_url:", job.events_url) - print() - - if not job.events: - raise LivepeerGatewayError("No events_url present on this LiveVideoToVideo job") - - seen = 0 - async for event in job.events(): - print(json.dumps(event, indent=2, sort_keys=True)) - print() - seen += 1 - if args.count and seen >= args.count: - break - - except LivepeerGatewayError as e: - print(f"ERROR: {e}") - finally: - try: - if "job" in locals(): - await job.close() - except Exception: - pass - - -if __name__ == "__main__": - asyncio.run(main()) - diff --git a/examples/text/README.md b/examples/text/README.md index 7720e9f..e384493 100644 --- a/examples/text/README.md +++ b/examples/text/README.md @@ -1,28 +1,35 @@ # Text Stream Demo -Single-shot text streaming on Livpeeer with static configuration. +Single-shot text streaming on Livepeer Node with static configuration. This demo exposes a tiny aiohttp runner with two streaming endpoints: - `/text` streams a story as `text/plain`, one character at a time. - `/sse` streams a story as Server-Sent Events, one line per event. -The app also exposes `/healthz` for go-livepeer runner health checks. +The app also exposes `/healthz` for Livepeer Node runner health checks. -Start go-livepeer with a static runner config: +Install [Livepeer Node](https://github.com/livepeer/node) and put its executables +on your `PATH`. From the repository root, start its orchestrator: ```sh -# assumes `livepeer` is somewhere in your PATH -livepeer -config go-livepeer.conf +livepeer orchestrator --config="" --runner-config examples/text/runners.toml \ + --runner-grants 127.0.0.1:8990 --health-grants 127.0.0.1:8990 ``` -Start the runner app: +Start the runner app in another terminal: ```sh -uv run runner.py +uv run examples/text/runner.py ``` -Call the endpoints through go-livepeer: +Allow up to five seconds for the first health check, then verify registration: + +```sh +curl http://localhost:8935/discovery | jq +``` + +Call the endpoints through Livepeer Node: ```sh # Plain text stream. `-N` disables curl output buffering @@ -31,9 +38,3 @@ curl -N http://localhost:8935/apps/story-runner/app/text # SSE stream. Each story line is emitted as one `data:` event curl -N http://localhost:8935/apps/story-runner/app/sse ``` - -Verify the runner registration with go-livepeer: - -``` -curl http://localhost:8935/discovery | jq -``` diff --git a/examples/text/go-livepeer.conf b/examples/text/go-livepeer.conf deleted file mode 100644 index 88cc50b..0000000 --- a/examples/text/go-livepeer.conf +++ /dev/null @@ -1,10 +0,0 @@ -# go-livepeer config files use the same keys as CLI flags in `key value` form. -orchestrator true -useLiveRunners true -httpAddr http://localhost:8935 -serviceAddr http://localhost:8935 -orchSecret abcdef -v 99 - -# Point this at the static runner config below instead of registering from Python. -liveRunnerConfig runners.json diff --git a/examples/text/runner.py b/examples/text/runner.py index 973d45c..17434bd 100644 --- a/examples/text/runner.py +++ b/examples/text/runner.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from pathlib import Path from aiohttp import web @@ -18,7 +19,7 @@ async def _handle_sse(request: web.Request) -> web.StreamResponse: ) await response.prepare(request) - with open("story.txt", encoding="utf-8", errors="replace") as lines: + with Path(__file__).with_name("story.txt").open(encoding="utf-8", errors="replace") as lines: for line in lines: await response.write(f"data: {line.rstrip('\n')}\n\n".encode()) await asyncio.sleep(0.5) @@ -38,7 +39,7 @@ async def _handle_text(request: web.Request) -> web.StreamResponse: ) await response.prepare(request) - with open("story.txt", encoding="utf-8", errors="replace") as story: + with Path(__file__).with_name("story.txt").open(encoding="utf-8", errors="replace") as story: while char := story.read(1): await response.write(char.encode("utf-8")) await asyncio.sleep(0.02) diff --git a/examples/text/runners.json b/examples/text/runners.json deleted file mode 100644 index 780396d..0000000 --- a/examples/text/runners.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "runners": [ - { - "label": "story-runner", - "app": "livepeer/read-story", - "runner_url": "http://127.0.0.1:8990", - "health_url": "/healthz", - "routing": "label", - "capacity": 10, - "metadata":"{'title':'The Open Window', 'author': 'Saki'}", - "mode": "single-shot" - } - ] -} diff --git a/examples/text/runners.toml b/examples/text/runners.toml new file mode 100644 index 0000000..0ebb404 --- /dev/null +++ b/examples/text/runners.toml @@ -0,0 +1,10 @@ +[[Runners]] +ID = "story-runner" +Label = "story-runner" +App = "livepeer/read-story" +RunnerURL = "http://127.0.0.1:8990" +HealthURL = "http://127.0.0.1:8990/healthz" +HealthCode = 200 +Capacity = 10 +Metadata = '{"title": "The Open Window", "author": "Saki"}' +Mode = "single-shot" diff --git a/examples/write_control.py b/examples/write_control.py deleted file mode 100644 index d5d2a7f..0000000 --- a/examples/write_control.py +++ /dev/null @@ -1,89 +0,0 @@ -import argparse -import asyncio -import json - -from livepeer_gateway import TricklePublisherTerminalError, TrickleSegmentWriteError -from livepeer_gateway.errors import LivepeerGatewayError -from livepeer_gateway.lv2v import StartJobRequest, start_lv2v - - -DEFAULT_MODEL_ID = "noop" # fix - - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="Start an LV2V job and write JSON messages to its control channel.") - p.add_argument( - "orchestrator", - nargs="?", - default=None, - help="Orchestrator (host:port). If omitted, discovery is used.", - ) - p.add_argument( - "--signer", - default=None, - help="Remote signer URL (no path). If omitted, runs in offchain mode.", - ) - p.add_argument( - "--model", - default=DEFAULT_MODEL_ID, - help=f"Pipeline model to start via /live-video-to-video. Default: {DEFAULT_MODEL_ID}", - ) - p.add_argument( - "--message", - default='{"type":"ping"}', - help='JSON object to send on the control channel (default: {"type":"ping"}).', - ) - p.add_argument("--count", type=int, default=1, help="How many times to send the message (default: 1).") - p.add_argument("--interval", type=float, default=0.2, help="Seconds between messages (default: 0.2).") - return p.parse_args() - - -async def main() -> None: - args = _parse_args() - - try: - job = start_lv2v( - args.orchestrator, - StartJobRequest(model_id=args.model), - signer_url=args.signer, - ) - - print("=== LiveVideoToVideo ===") - print("control_url:", job.control_url) - print() - - if not job.control: - raise LivepeerGatewayError("No control_url present on this LiveVideoToVideo job") - - msg = json.loads(args.message) - if not isinstance(msg, dict): - raise ValueError("--message must be a JSON object") - - for i in range(max(0, args.count)): - try: - await job.control.write({**msg, "n": i}) - except TrickleSegmentWriteError as e: - # Segment failures can be transient; keep sending later messages. - print(f"WARN: dropped control message n={i}: {e}") - continue - except TricklePublisherTerminalError as e: - # Terminal publisher errors require stopping/recreating publisher state. - print(f"ERROR: terminal control publisher failure: {e}") - break - if i + 1 < args.count: - await asyncio.sleep(args.interval) - - except LivepeerGatewayError as e: - print(f"ERROR: {e}") - finally: - try: - if "job" in locals(): - await job.close() - except Exception: - pass - - -if __name__ == "__main__": - asyncio.run(main()) - - diff --git a/examples/write_frames.py b/examples/write_frames.py deleted file mode 100644 index 05b33ef..0000000 --- a/examples/write_frames.py +++ /dev/null @@ -1,91 +0,0 @@ -import argparse -import asyncio -from fractions import Fraction - -import av - -from livepeer_gateway.errors import LivepeerGatewayError -from livepeer_gateway.lv2v import StartJobRequest, start_lv2v -from livepeer_gateway.media_publish import MediaPublishConfig, VideoOutputConfig - -DEFAULT_MODEL_ID = "noop" # fix - - -def _parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(description="Start an LV2V job and publish raw frames via publish_url.") - p.add_argument( - "orchestrator", - nargs="?", - default=None, - help="Orchestrator (host:port). If omitted, discovery is used.", - ) - p.add_argument( - "--signer", - default=None, - help="Remote signer URL (no path). If omitted, runs in offchain mode.", - ) - p.add_argument( - "--token", - default=None, - help="Base64-encoded gateway token (signer, discovery, headers); overrides missing signer/orchestrator.", - ) - p.add_argument( - "--model", - default=DEFAULT_MODEL_ID, - help=f"Pipeline model to start via /live-video-to-video. Default: {DEFAULT_MODEL_ID}", - ) - p.add_argument("--width", type=int, default=320, help="Frame width (default: 320).") - p.add_argument("--height", type=int, default=180, help="Frame height (default: 180).") - p.add_argument("--fps", type=float, default=30.0, help="Frames per second (default: 30).") - p.add_argument("--count", type=int, default=90, help="Number of frames to send (default: 90).") - return p.parse_args() - - -def _solid_rgb_frame(width: int, height: int, rgb: tuple[int, int, int]) -> av.VideoFrame: - frame = av.VideoFrame(width, height, "rgb24") - r, g, b = rgb - frame.planes[0].update(bytes([r, g, b]) * (width * height)) - return frame - - -async def main() -> None: - args = _parse_args() - frame_interval = 1.0 / max(1e-6, args.fps) - - job = None - try: - job = start_lv2v( - args.orchestrator, - StartJobRequest(model_id=args.model), - token=args.token, - signer_url=args.signer, - ) - - print("=== LiveVideoToVideo ===") - print("publish_url:", job.publish_url) - print() - - media = job.start_media( - MediaPublishConfig( - tracks=[VideoOutputConfig(fps=args.fps)], - ) - ) - - time_base = Fraction(1, int(round(args.fps))) - for i in range(max(0, args.count)): - color = (i * 5) % 255 - frame = _solid_rgb_frame(args.width, args.height, (color, 0, 255 - color)) - frame.pts = i - frame.time_base = time_base - await media.write_frame(frame) - await asyncio.sleep(frame_interval) - except LivepeerGatewayError as e: - print(f"ERROR: {e}") - finally: - if job is not None: - await job.close() - - -if __name__ == "__main__": - asyncio.run(main()) - diff --git a/pyproject.toml b/pyproject.toml index cd7813c..3c19135 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,8 +24,6 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "grpcio>=1.76.0", - "protobuf>=6.31.1", "aiohttp>=3.9.0", "av>=11.0.0", ] @@ -36,17 +34,10 @@ Repository = "https://github.com/livepeer/livepeer-python-gateway" Issues = "https://github.com/livepeer/livepeer-python-gateway/issues" Changelog = "https://github.com/livepeer/livepeer-python-gateway/blob/main/CHANGELOG.md" -[project.scripts] -generate-lp-rpc = "livepeer_gateway.codegen:main" - [project.optional-dependencies] -dev = [ - "grpcio-tools>=1.76.0", -] - examples = [ "numpy>=2.2.6", - "opencv-python-headless>=4.13.0.90", + "opencv-python>=4.13.0.90", ] [dependency-groups] @@ -98,6 +89,3 @@ target-version = "py312" [tool.ruff.lint] # Baseline defaults from #56; CI scopes UP diagnostics to changed lines. select = ["E4", "E7", "E9", "F", "UP"] - -[tool.ruff.lint.per-file-ignores] -"src/livepeer_gateway/lp_rpc_pb2_grpc.py" = ["F401"] diff --git a/src/livepeer_gateway/__init__.py b/src/livepeer_gateway/__init__.py index b3eaac3..9e4c284 100644 --- a/src/livepeer_gateway/__init__.py +++ b/src/livepeer_gateway/__init__.py @@ -1,8 +1,7 @@ -from .capabilities import CapabilityId, build_capabilities from .channel_reader import ChannelEventCallback, ChannelReader, JSONLReader from .channel_writer import ChannelWriter, JSONLWriter -from .control import Control, ControlConfig, ControlMode -from .errors import LivepeerHTTPError, LivepeerGatewayError, NoOrchestratorAvailableError, NoRunnerAvailableError, PaymentError +from .control import Control +from .errors import LivepeerHTTPError, LivepeerGatewayError, NoRunnerAvailableError, PaymentError from .events import Events from .media_publish import ( AudioOutputConfig, @@ -26,8 +25,7 @@ MediaOutputStats, MediaPacketCallback, ) -from .errors import OrchestratorRejection, RunnerRejection -from .lv2v import LiveVideoToVideo, StartJobRequest, start_lv2v +from .errors import RunnerRejection from .live_runner import ( LiveRunnerCallResult, LiveRunnerCallStream, @@ -47,13 +45,9 @@ stop_runner_session, ) from .discovery import discover_orchestrators, discover_runners -from .orch_info import get_orch_info -from .remote_signer import LivePaymentChallenge, LivePaymentSession, PaymentSession -from .scope import start_scope +from .remote_signer import LivePaymentChallenge, LivePaymentSession from .selection import ( RunnerSelectionCursor, - SelectionCursor, - orchestrator_selector, runner_selector, reserve_session, ) @@ -70,15 +64,9 @@ __all__ = [ "Control", - "ControlConfig", - "ControlMode", "ChannelWriter", - "CapabilityId", - "build_capabilities", "discover_orchestrators", "discover_runners", - "get_orch_info", - "LiveVideoToVideo", "LiveRunnerCallResult", "LiveRunnerCallStream", "LiveRunnerGPU", @@ -93,9 +81,7 @@ "LiveRunnerProxy", "LivepeerGatewayError", "LivepeerHTTPError", - "NoOrchestratorAvailableError", "NoRunnerAvailableError", - "OrchestratorRejection", "RunnerRejection", "PaymentError", "MediaPublish", @@ -118,21 +104,15 @@ "JSONLReader", "JSONLWriter", "Events", - "PaymentSession", "parse_token", "RunnerSelectionCursor", - "SelectionCursor", - "orchestrator_selector", "runner_selector", "reserve_session", - "StartJobRequest", "call_runner", "create_proxy", "create_trickle_channels", "register_runner", "remove_trickle_channels", - "start_lv2v", - "start_scope", "stop_runner_session", "TricklePublishError", "TricklePublisher", diff --git a/src/livepeer_gateway/capabilities.py b/src/livepeer_gateway/capabilities.py deleted file mode 100644 index 27e5688..0000000 --- a/src/livepeer_gateway/capabilities.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -from enum import IntEnum -from typing import Any, Mapping, Optional - -from . import lp_rpc_pb2 - - -class CapabilityId(IntEnum): - INVALID = -2 - UNUSED = -1 - H264 = 0 - MPEGTS = 1 - MP4 = 2 - FRACTIONAL_FRAMERATES = 3 - STORAGE_DIRECT = 4 - STORAGE_S3 = 5 - STORAGE_GCS = 6 - H264_BASELINE_PROFILE = 7 - H264_MAIN_PROFILE = 8 - H264_HIGH_PROFILE = 9 - H264_CONSTRAINED_CONTAINED_HIGH_PROFILE = 10 - GOP = 11 - AUTH_TOKEN = 12 - MPEG7_SIGNATURE = 14 - HEVC_DECODE = 15 - HEVC_ENCODE = 16 - VP8_DECODE = 17 - VP9_DECODE = 18 - VP8_ENCODE = 19 - VP9_ENCODE = 20 - H264_DECODE_YUV444_8BIT = 21 - H264_DECODE_YUV422_8BIT = 22 - H264_DECODE_YUV444_10BIT = 23 - H264_DECODE_YUV422_10BIT = 24 - H264_DECODE_YUV420_10BIT = 25 - SEGMENT_SLICING = 26 - TEXT_TO_IMAGE = 27 - IMAGE_TO_IMAGE = 28 - IMAGE_TO_VIDEO = 29 - UPSCALE = 30 - AUDIO_TO_TEXT = 31 - SEGMENT_ANYTHING_2 = 32 - LLM = 33 - IMAGE_TO_TEXT = 34 - LIVE_VIDEO_TO_VIDEO = 35 - TEXT_TO_SPEECH = 36 - -CAPABILITY_ID_TO_NAME: dict[int, str] = { - -2: "Invalid", - -1: "Unused", - 0: "H.264", - 1: "MPEGTS", - 2: "MP4", - 3: "Fractional framerates", - 4: "Storage direct", - 5: "Storage S3", - 6: "Storage GCS", - 7: "H264 Baseline profile", - 8: "H264 Main profile", - 9: "H264 High profile", - 10: "H264 Constrained, Contained High profile", - 11: "GOP", - 12: "Auth token", - 14: "MPEG7 signature", - 15: "HEVC decode", - 16: "HEVC encode", - 17: "VP8 decode", - 18: "VP9 decode", - 19: "VP8 encode", - 20: "VP9 encode", - 21: "H264 Decode YUV444 8-bit", - 22: "H264 Decode YUV422 8-bit", - 23: "H264 Decode YUV444 10-bit", - 24: "H264 Decode YUV422 10-bit", - 25: "H264 Decode YUV420 10-bit", - 26: "Segment slicing", - 27: "Text to image", - 28: "Image to image", - 29: "Image to video", - 30: "Upscale", - 31: "Audio to text", - 32: "Segment anything 2", - 33: "Llm", - 34: "Image to text", - 35: "Live video to video", - 36: "Text to speech", -} - - -def capability_name(cap_id: int) -> str: - return CAPABILITY_ID_TO_NAME.get(cap_id, "Unknown capability") - - -def format_capability(cap_id: int) -> str: - return f"{capability_name(cap_id)} ({cap_id})" - - -def compute_available(capacity: int, in_use: int) -> int: - return max(0, capacity - in_use) - - -def get_per_capability_map(capabilities: Any) -> Mapping[int, Any]: - """ - Best-effort access to the PerCapability map across protobuf field name variants. - """ - constraints = getattr(capabilities, "constraints", None) - if constraints is None: - return {} - - for name in ("PerCapability", "per_capability", "perCapability"): - if hasattr(constraints, name): - return getattr(constraints, name) - - return {} - - -def get_capacity_in_use(model_constraint: Any) -> int: - if hasattr(model_constraint, "capacityInUse"): - return int(getattr(model_constraint, "capacityInUse") or 0) - if hasattr(model_constraint, "capacity_in_use"): - return int(getattr(model_constraint, "capacity_in_use") or 0) - return 0 - - -def build_capabilities( - capability: CapabilityId, - constraint: Optional[str], -) -> lp_rpc_pb2.Capabilities: - """ - Build a capabilities message with an optional per-capability model constraint. - """ - caps = lp_rpc_pb2.Capabilities() - cap_id = int(capability) - caps.capacities[cap_id] = 1 - if constraint: - caps.constraints.PerCapability[cap_id].models[constraint] - return caps - - -def capability_pipeline_id(cap_id: int) -> Optional[str]: - """ - Convert a capability ID to a discovery pipeline ID. - - Example: - LIVE_VIDEO_TO_VIDEO -> live-video-to-video - """ - try: - enum_name = CapabilityId(cap_id).name - except ValueError: - return None - return enum_name.lower().replace("_", "-") - - -def capabilities_to_query(caps: Optional[lp_rpc_pb2.Capabilities]) -> list[str]: - """ - Build discovery query values in `pipeline-id/model` form. - - Models are taken as-is from the protobuf constraints map keys. - """ - if caps is None: - return [] - - constraints = getattr(caps, "constraints", None) - if constraints is None: - return [] - - per_capability = getattr(constraints, "PerCapability", None) - if per_capability is None: - return [] - - query_values: list[str] = [] - seen: set[str] = set() - for cap_id in sorted(per_capability.keys()): - pipeline_id = capability_pipeline_id(int(cap_id)) - if not pipeline_id: - continue - - cap_constraint = per_capability[cap_id] - models = getattr(cap_constraint, "models", None) - if models is None: - continue - - for model in sorted(models.keys()): - if not isinstance(model, str) or not model: - continue - value = f"{pipeline_id}/{model}" - if value in seen: - continue - seen.add(value) - query_values.append(value) - - return query_values - diff --git a/src/livepeer_gateway/codegen.py b/src/livepeer_gateway/codegen.py deleted file mode 100644 index 5f51d01..0000000 --- a/src/livepeer_gateway/codegen.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -import re -import subprocess -import sys -from pathlib import Path -from urllib.request import urlopen - - -PROTO_URL = ( - "https://raw.githubusercontent.com/livepeer/go-livepeer/" - "refs/heads/master/net/lp_rpc.proto" -) - -def main() -> None: - root = Path(__file__).resolve().parents[2] - src_dir = root / "src/livepeer_gateway" - - proto_path = root / "lp_rpc.proto" - - print(f"[codegen] Downloading {PROTO_URL}") - with urlopen(PROTO_URL) as resp: - proto_path.write_bytes(resp.read()) - - print("[codegen] Running grpc_tools.protoc") - subprocess.check_call( - [ - sys.executable, - "-m", - "grpc_tools.protoc", - f"-I{root}", - f"--python_out={src_dir}", - f"--grpc_python_out={src_dir}", - str(proto_path), - ] - ) - - patch_grpc_imports(src_dir / "lp_rpc_pb2_grpc.py") - - print("[codegen] Done") - print("[codegen] Generated:") - print(f" {src_dir / 'lp_rpc_pb2.py'}") - print(f" {src_dir / 'lp_rpc_pb2_grpc.py'}") - -def patch_grpc_imports(grpc_file: Path) -> None: - txt = grpc_file.read_text(encoding="utf-8") - patched, n = re.subn( - r"^import\s+lp_rpc_pb2\s+as\s+lp__rpc__pb2\s*$", - "from . import lp_rpc_pb2 as lp__rpc__pb2", - txt, - flags=re.MULTILINE, - ) - if n == 0: - raise RuntimeError(f"Expected import not found in {grpc_file}") - grpc_file.write_text(patched, encoding="utf-8") diff --git a/src/livepeer_gateway/control.py b/src/livepeer_gateway/control.py index a179958..4cfc8cf 100644 --- a/src/livepeer_gateway/control.py +++ b/src/livepeer_gateway/control.py @@ -3,8 +3,6 @@ import asyncio import json import logging -from dataclasses import dataclass -from enum import Enum from typing import Any, Optional from .trickle_publisher import ( @@ -16,18 +14,6 @@ _LOG = logging.getLogger(__name__) -class ControlMode(str, Enum): - MESSAGE = "message" - TIME = "time" - DISABLED = "disabled" - - -@dataclass(frozen=True) -class ControlConfig: - mode: ControlMode = ControlMode.MESSAGE - segment_interval: float = 10.0 - - class Control: def __init__( self, @@ -101,4 +87,3 @@ async def _keepalive_loop(self) -> None: except TricklePublisherTerminalError: _LOG.error("Control keepalive stopped due to terminal publisher failure", exc_info=True) return - diff --git a/src/livepeer_gateway/discovery.py b/src/livepeer_gateway/discovery.py index 5d1e6c4..e11352e 100644 --- a/src/livepeer_gateway/discovery.py +++ b/src/livepeer_gateway/discovery.py @@ -6,8 +6,6 @@ from collections.abc import Sequence from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlunparse -from . import lp_rpc_pb2 -from .capabilities import capabilities_to_query from .errors import LivepeerGatewayError from .remote_signer import RemoteSignerError from .http import _http_origin, _parse_http_url, get_json, get_json_sync @@ -39,17 +37,6 @@ def _append_query_values(url: str, values: Sequence[tuple[str, str]]) -> str: return urlunparse(parsed._replace(query=query)) -def _append_caps(url: str, capabilities: lp_rpc_pb2.Capabilities | None) -> str: - """ - Append repeated `caps` query parameters to a URL. - - Existing query params are preserved. Capability values keep `/` unescaped. - """ - if capabilities is None: - return url - return _append_query_values(url, [("caps", cap) for cap in capabilities_to_query(capabilities)]) - - def _append_runner_filters( url: str, *, @@ -69,7 +56,6 @@ def discover_orchestrators( signer_headers: dict[str, str] | None = None, discovery_url: str | None = None, discovery_headers: dict[str, str] | None = None, - capabilities: lp_rpc_pb2.Capabilities | None = None, ) -> list[str]: """ Discover orchestrators and return a list of addresses. @@ -104,9 +90,6 @@ def discover_orchestrators( _LOG.debug("discover_orchestrators failed: no discovery inputs") raise LivepeerGatewayError("discover_orchestrators requires discovery_url or signer_url") - if capabilities is not None: - discovery_endpoint = _append_caps(discovery_endpoint, capabilities) - try: _LOG.debug("discover_orchestrators running discovery: %s", discovery_endpoint) data = get_json_sync(discovery_endpoint, headers=request_headers) diff --git a/src/livepeer_gateway/errors.py b/src/livepeer_gateway/errors.py index 9b44713..175ca06 100644 --- a/src/livepeer_gateway/errors.py +++ b/src/livepeer_gateway/errors.py @@ -17,13 +17,6 @@ def __init__(self, status_code: int, url: str, body: str = "", message: str | No super().__init__(message or f"HTTP {status_code} from endpoint (url={url})") -@dataclass -class OrchestratorRejection: - """Records a single orchestrator that was tried and rejected.""" - url: str - reason: str - - @dataclass class RunnerRejection: """Records a single runner that was tried and rejected.""" @@ -31,21 +24,6 @@ class RunnerRejection: reason: str -class NoOrchestratorAvailableError(LivepeerGatewayError): - """Raised when no orchestrator could be selected.""" - - def __init__(self, message: str, rejections: list[OrchestratorRejection] | None = None) -> None: - super().__init__(message) - self.rejections: list[OrchestratorRejection] = rejections or [] - - def __str__(self) -> str: - message = super().__str__() - if not self.rejections: - return message - reasons = "; ".join(f"{r.url}: {r.reason}" for r in self.rejections) - return f"{message}: {reasons}" - - class NoRunnerAvailableError(LivepeerGatewayError): """Raised when no runner could be selected.""" @@ -64,19 +42,10 @@ def __str__(self) -> str: class SignerRefreshRequired(LivepeerGatewayError): """Raised when the remote signer returns HTTP 480 and a refresh is required.""" - def __init__( - self, - message: str, - *, - orchestrator_url: str | None = None, - ) -> None: - super().__init__(message) - self.orchestrator_url = orchestrator_url - class SkipPaymentCycle(LivepeerGatewayError): """Raised when the signer returns HTTP 482 to skip a payment cycle.""" class PaymentError(LivepeerGatewayError): - """Raised when a PaymentSession operation fails.""" + """Raised when a payment operation fails.""" diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index 699d042..4558c73 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -16,8 +16,6 @@ SkipPaymentCycle, ) -_REFRESH_SESSION_ORCHESTRATOR_URL_HEADER = "Livepeer-Orchestrator-URL" - def _truncate(s: str, max_len: int = 2000) -> str: if len(s) <= max_len: @@ -75,14 +73,6 @@ def _extract_error_message(e: HTTPError) -> str: return _extract_error_message_from_body(_http_error_body(e)) -def _header_value(headers: dict[str, str], name: str) -> str | None: - needle = name.lower() - for key, value in headers.items(): - if key.lower() == needle and isinstance(value, str) and value.strip(): - return value.strip() - return None - - def _json_request_parts( url: str, *, @@ -109,14 +99,12 @@ def _raise_http_json_error( status: int, url: str, body: str = "", - headers: dict[str, str] | None = None, ) -> None: message = _extract_error_message_from_body(body) body_part = f"; body={message!r}" if message else "" if status == 480: raise SignerRefreshRequired( - f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}", - orchestrator_url=_header_value(headers or {}, _REFRESH_SESSION_ORCHESTRATOR_URL_HEADER), + f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}" ) if status == 482: raise SkipPaymentCycle( @@ -161,7 +149,7 @@ def request_json_sync( ) req = Request(url, data=body, headers=req_headers, method=resolved_method) - # Always ignore HTTPS certificate validation (matches our gRPC behavior). + # Always ignore HTTPS certificate validation. ssl_ctx = ssl._create_unverified_context() try: @@ -174,11 +162,7 @@ def request_json_sync( body_part = f"; body={body_text!r}" if body_text else "" if e.code == 480: raise SignerRefreshRequired( - f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}", - orchestrator_url=_header_value( - dict(e.headers.items()), - _REFRESH_SESSION_ORCHESTRATOR_URL_HEADER, - ), + f"Signer returned HTTP 480 (refresh session required) (url={url}){body_part}" ) from e if e.code == 482: raise SkipPaymentCycle( @@ -273,7 +257,7 @@ async def _request_body( content_type = resp.content_type or "" if resp.status >= 400: _raise_http_json_error( - resp.status, url, raw.decode(errors="replace"), dict(resp.headers.items()) + resp.status, url, raw.decode(errors="replace") ) except (SignerRefreshRequired, SkipPaymentCycle, LivepeerGatewayError): raise @@ -368,7 +352,7 @@ async def open_stream( raw = await resp.text() resp.release() await session.close() - _raise_http_json_error(resp.status, url, raw, dict(resp.headers.items())) + _raise_http_json_error(resp.status, url, raw) return session, resp diff --git a/src/livepeer_gateway/live_runner.py b/src/livepeer_gateway/live_runner.py index 75faa28..8d80b3b 100644 --- a/src/livepeer_gateway/live_runner.py +++ b/src/livepeer_gateway/live_runner.py @@ -45,14 +45,8 @@ _RUNNER_PAYMENT_TYPES_BY_UNIT = { "hour": "live", "seconds": "live", - "720p": "lv2v", - "720p-pixel-seconds": "lv2v", "fixed": "fixed", } -# Metered types are billed for as long as the work runs, so they need ongoing -# payments. Fixed pricing is settled by the upfront payment alone. -_METERED_PAYMENT_TYPES = frozenset({"live", "lv2v"}) - # golang format duration, eg "10s" _DURATION_RE = re.compile(r"^\s*(?P[0-9]+(?:\.[0-9]+)?)(?Pns|us|\u00b5s|ms|s|m|h)\s*$") @@ -863,7 +857,7 @@ async def call_runner( session_id = challenge.manifest_id # Metered pricing bills for as long as the work runs. - needs_ongoing_funding = payment_type in _METERED_PAYMENT_TYPES + needs_ongoing_funding = payment_type == "live" try: request_kwargs: dict[str, Any] = {"timeout": timeout} @@ -1045,10 +1039,6 @@ def _runner_payment_type( ) return payment_type - # Compatibility for callers constructing instances without discovery - # price metadata. Discovery price_info.unit is authoritative when present. - if runner is not None and runner.app == "live-video-to-video/scope": - return "lv2v" return "live" diff --git a/src/livepeer_gateway/lp_rpc_pb2.py b/src/livepeer_gateway/lp_rpc_pb2.py deleted file mode 100644 index 5e8c313..0000000 --- a/src/livepeer_gateway/lp_rpc_pb2.py +++ /dev/null @@ -1,129 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# NO CHECKED-IN PROTOBUF GENCODE -# source: lp_rpc.proto -# Protobuf Python Version: 6.31.1 -"""Generated protocol buffer code.""" -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import runtime_version as _runtime_version -from google.protobuf import symbol_database as _symbol_database -from google.protobuf.internal import builder as _builder -_runtime_version.ValidateProtobufRuntimeVersion( - _runtime_version.Domain.PUBLIC, - 6, - 31, - 1, - '', - 'lp_rpc.proto' -) -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0clp_rpc.proto\x12\x03net\"\x19\n\x08PingPong\x12\r\n\x05value\x18\x01 \x01(\x0c\"B\n\x1c\x45ndTranscodingSessionRequest\x12\"\n\nauth_token\x18\x01 \x01(\x0b\x32\x0e.net.AuthToken\"\x1f\n\x1d\x45ndTranscodingSessionResponse\"y\n\x13OrchestratorRequest\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x0b\n\x03sig\x18\x02 \x01(\x0c\x12\'\n\x0c\x63\x61pabilities\x18\x03 \x01(\x0b\x32\x11.net.Capabilities\x12\x1b\n\x13ignoreCapacityCheck\x18\x04 \x01(\x08\"\x84\x01\n\x06OSInfo\x12,\n\x0bstorageType\x18\x01 \x01(\x0e\x32\x17.net.OSInfo.StorageType\x12\x1d\n\x06s3info\x18\x10 \x01(\x0b\x32\r.net.S3OSInfo\"-\n\x0bStorageType\x12\n\n\x06\x44IRECT\x10\x00\x12\x06\n\x02S3\x10\x01\x12\n\n\x06GOOGLE\x10\x02\"n\n\x08S3OSInfo\x12\x0c\n\x04host\x18\x01 \x01(\t\x12\x0b\n\x03key\x18\x02 \x01(\t\x12\x0e\n\x06policy\x18\x03 \x01(\t\x12\x11\n\tsignature\x18\x04 \x01(\t\x12\x12\n\ncredential\x18\x05 \x01(\t\x12\x10\n\x08xAmzDate\x18\x06 \x01(\t\"`\n\tPriceInfo\x12\x14\n\x0cpricePerUnit\x18\x01 \x01(\x03\x12\x15\n\rpixelsPerUnit\x18\x02 \x01(\x03\x12\x12\n\ncapability\x18\x03 \x01(\r\x12\x12\n\nconstraint\x18\x04 \x01(\t\"\xd9\x05\n\x0c\x43\x61pabilities\x12\x11\n\tbitstring\x18\x01 \x03(\x04\x12\x13\n\x0bmandatories\x18\x02 \x03(\x04\x12\x35\n\ncapacities\x18\x03 \x03(\x0b\x32!.net.Capabilities.CapacitiesEntry\x12\x0f\n\x07version\x18\x04 \x01(\t\x12\x32\n\x0b\x63onstraints\x18\x05 \x01(\x0b\x32\x1d.net.Capabilities.Constraints\x1a\x31\n\x0f\x43\x61pacitiesEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\r:\x02\x38\x01\x1a\xc9\x01\n\x0b\x43onstraints\x12\x12\n\nminVersion\x18\x01 \x01(\t\x12G\n\rPerCapability\x18\x02 \x03(\x0b\x32\x30.net.Capabilities.Constraints.PerCapabilityEntry\x1a]\n\x12PerCapabilityEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\x36\n\x05value\x18\x02 \x01(\x0b\x32\'.net.Capabilities.CapabilityConstraints:\x02\x38\x01\x1a\xa5\x02\n\x15\x43\x61pabilityConstraints\x12\x43\n\x06models\x18\x01 \x03(\x0b\x32\x33.net.Capabilities.CapabilityConstraints.ModelsEntry\x1a_\n\x0fModelConstraint\x12\x0c\n\x04warm\x18\x01 \x01(\x08\x12\x10\n\x08\x63\x61pacity\x18\x02 \x01(\r\x12\x15\n\rrunnerVersion\x18\x03 \x01(\t\x12\x15\n\rcapacityInUse\x18\x04 \x01(\r\x1a\x66\n\x0bModelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x46\n\x05value\x18\x02 \x01(\x0b\x32\x37.net.Capabilities.CapabilityConstraints.ModelConstraint:\x02\x38\x01\"\xd8\x02\n\x10OrchestratorInfo\x12\x12\n\ntranscoder\x18\x01 \x01(\t\x12(\n\rticket_params\x18\x02 \x01(\x0b\x32\x11.net.TicketParams\x12\"\n\nprice_info\x18\x03 \x01(\x0b\x32\x0e.net.PriceInfo\x12\x0f\n\x07\x61\x64\x64ress\x18\x04 \x01(\x0c\x12\'\n\x0c\x63\x61pabilities\x18\x05 \x01(\x0b\x32\x11.net.Capabilities\x12\"\n\nauth_token\x18\x06 \x01(\x0b\x32\x0e.net.AuthToken\x12*\n\x08hardware\x18\x07 \x03(\x0b\x32\x18.net.HardwareInformation\x12\x1c\n\x07storage\x18 \x03(\x0b\x32\x0b.net.OSInfo\x12+\n\x13\x63\x61pabilities_prices\x18! \x03(\x0b\x32\x0e.net.PriceInfo\x12\r\n\x05nodes\x18\" \x03(\t\"B\n\tAuthToken\x12\r\n\x05token\x18\x01 \x01(\x0c\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x12\n\nexpiration\x18\x03 \x01(\x03\"\xbb\x03\n\x07SegData\x12\x12\n\nmanifestId\x18\x01 \x01(\x0c\x12\x0b\n\x03seq\x18\x02 \x01(\x03\x12\x0c\n\x04hash\x18\x03 \x01(\x0c\x12\x10\n\x08profiles\x18\x04 \x01(\x0c\x12\x0b\n\x03sig\x18\x05 \x01(\x0c\x12\x10\n\x08\x64uration\x18\x06 \x01(\x05\x12\'\n\x0c\x63\x61pabilities\x18\x07 \x01(\x0b\x32\x11.net.Capabilities\x12\"\n\nauth_token\x18\x08 \x01(\x0b\x32\x0e.net.AuthToken\x12\x1c\n\x14\x63\x61lc_perceptual_hash\x18\n \x01(\x08\x12\x1c\n\x07storage\x18 \x03(\x0b\x32\x0b.net.OSInfo\x12\'\n\x0c\x66ullProfiles\x18! \x03(\x0b\x32\x11.net.VideoProfile\x12(\n\rfullProfiles2\x18\" \x03(\x0b\x32\x11.net.VideoProfile\x12(\n\rfullProfiles3\x18# \x03(\x0b\x32\x11.net.VideoProfile\x12.\n\x12segment_parameters\x18% \x01(\x0b\x32\x12.net.SegParameters\x12\x1a\n\x12\x46orceSessionReinit\x18& \x01(\x08\")\n\rSegParameters\x12\x0c\n\x04\x66rom\x18\x01 \x01(\x04\x12\n\n\x02to\x18\x02 \x01(\x04\"\xdf\x04\n\x0cVideoProfile\x12\x0c\n\x04name\x18\x10 \x01(\t\x12\r\n\x05width\x18\x11 \x01(\x05\x12\x0e\n\x06height\x18\x12 \x01(\x05\x12\x0f\n\x07\x62itrate\x18\x13 \x01(\x05\x12\x0b\n\x03\x66ps\x18\x14 \x01(\r\x12(\n\x06\x66ormat\x18\x15 \x01(\x0e\x32\x18.net.VideoProfile.Format\x12\x0e\n\x06\x66psDen\x18\x16 \x01(\r\x12*\n\x07profile\x18\x17 \x01(\x0e\x32\x19.net.VideoProfile.Profile\x12\x0b\n\x03gop\x18\x18 \x01(\x05\x12-\n\x07\x65ncoder\x18\x19 \x01(\x0e\x32\x1c.net.VideoProfile.VideoCodec\x12\x12\n\ncolorDepth\x18\x1a \x01(\x05\x12\x39\n\x0c\x63hromaFormat\x18\x1b \x01(\x0e\x32#.net.VideoProfile.ChromaSubsampling\x12\x0f\n\x07quality\x18\x1c \x01(\r\"\x1d\n\x06\x46ormat\x12\n\n\x06MPEGTS\x10\x00\x12\x07\n\x03MP4\x10\x01\"j\n\x07Profile\x12\x13\n\x0f\x45NCODER_DEFAULT\x10\x00\x12\x11\n\rH264_BASELINE\x10\x01\x12\r\n\tH264_MAIN\x10\x02\x12\r\n\tH264_HIGH\x10\x03\x12\x19\n\x15H264_CONSTRAINED_HIGH\x10\x04\"2\n\nVideoCodec\x12\x08\n\x04H264\x10\x00\x12\x08\n\x04H265\x10\x01\x12\x07\n\x03VP8\x10\x02\x12\x07\n\x03VP9\x10\x03\"C\n\x11\x43hromaSubsampling\x12\x0e\n\nCHROMA_420\x10\x00\x12\x0e\n\nCHROMA_422\x10\x01\x12\x0e\n\nCHROMA_444\x10\x02\"Q\n\x15TranscodedSegmentData\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x0e\n\x06pixels\x18\x02 \x01(\x03\x12\x1b\n\x13perceptual_hash_url\x18\x03 \x01(\t\"J\n\rTranscodeData\x12,\n\x08segments\x18\x01 \x03(\x0b\x32\x1a.net.TranscodedSegmentData\x12\x0b\n\x03sig\x18\x02 \x01(\x0c\"\x82\x01\n\x0fTranscodeResult\x12\x0b\n\x03seq\x18\x01 \x01(\x03\x12\x0f\n\x05\x65rror\x18\x02 \x01(\tH\x00\x12\"\n\x04\x64\x61ta\x18\x03 \x01(\x0b\x32\x12.net.TranscodeDataH\x00\x12#\n\x04info\x18\x10 \x01(\x0b\x32\x15.net.OrchestratorInfoB\x08\n\x06result\"4\n\rPaymentResult\x12#\n\x04info\x18\x10 \x01(\x0b\x32\x15.net.OrchestratorInfo\"\\\n\x0fRegisterRequest\x12\x0e\n\x06secret\x18\x01 \x01(\t\x12\x10\n\x08\x63\x61pacity\x18\x02 \x01(\x03\x12\'\n\x0c\x63\x61pabilities\x18\x03 \x01(\x0b\x32\x11.net.Capabilities\"y\n\rNotifySegment\x12\x0b\n\x03url\x18\x01 \x01(\t\x12\x1d\n\x07segData\x18\x03 \x01(\x0b\x32\x0c.net.SegData\x12\x0e\n\x06taskId\x18\x10 \x01(\x03\x12\x0e\n\x06orchId\x18\x12 \x01(\t\x12\x10\n\x08profiles\x18\x11 \x01(\x0cJ\x04\x08\x02\x10\x03J\x04\x08!\x10\"\"~\n\x17RegisterAIWorkerRequest\x12\x0e\n\x06secret\x18\x01 \x01(\t\x12\'\n\x0c\x63\x61pabilities\x18\x02 \x01(\x0b\x32\x11.net.Capabilities\x12*\n\x08hardware\x18\x03 \x03(\x0b\x32\x18.net.HardwareInformation\"2\n\tAIJobData\x12\x10\n\x08pipeline\x18\x01 \x01(\t\x12\x13\n\x0brequestData\x18\x02 \x01(\x0c\"@\n\x0bNotifyAIJob\x12!\n\tAIJobData\x18\x01 \x01(\x0b\x32\x0e.net.AIJobData\x12\x0e\n\x06taskId\x18\x02 \x01(\x03\"\xc4\x01\n\x0cTicketParams\x12\x11\n\trecipient\x18\x01 \x01(\x0c\x12\x12\n\nface_value\x18\x02 \x01(\x0c\x12\x10\n\x08win_prob\x18\x03 \x01(\x0c\x12\x1b\n\x13recipient_rand_hash\x18\x04 \x01(\x0c\x12\x0c\n\x04seed\x18\x05 \x01(\x0c\x12\x18\n\x10\x65xpiration_block\x18\x06 \x01(\x0c\x12\x36\n\x11\x65xpiration_params\x18\x07 \x01(\x0b\x32\x1b.net.TicketExpirationParams\"7\n\x12TicketSenderParams\x12\x14\n\x0csender_nonce\x18\x01 \x01(\r\x12\x0b\n\x03sig\x18\x02 \x01(\x0c\"S\n\x16TicketExpirationParams\x12\x16\n\x0e\x63reation_round\x18\x01 \x01(\x03\x12!\n\x19\x63reation_round_block_hash\x18\x02 \x01(\x0c\"\xda\x01\n\x07Payment\x12(\n\rticket_params\x18\x01 \x01(\x0b\x32\x11.net.TicketParams\x12\x0e\n\x06sender\x18\x02 \x01(\x0c\x12\x36\n\x11\x65xpiration_params\x18\x03 \x01(\x0b\x32\x1b.net.TicketExpirationParams\x12\x35\n\x14ticket_sender_params\x18\x04 \x03(\x0b\x32\x17.net.TicketSenderParams\x12&\n\x0e\x65xpected_price\x18\x05 \x01(\x0b\x32\x0e.net.PriceInfo\"s\n\x0eGPUComputeInfo\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\r\n\x05major\x18\x04 \x01(\r\x12\r\n\x05minor\x18\x05 \x01(\r\x12\x13\n\x0bmemory_free\x18\x06 \x01(\x03\x12\x14\n\x0cmemory_total\x18\x07 \x01(\x03\"\xb7\x01\n\x13HardwareInformation\x12\x10\n\x08pipeline\x18\x01 \x01(\t\x12\x10\n\x08model_id\x18\x02 \x01(\t\x12\x37\n\x08gpu_info\x18\x03 \x03(\x0b\x32%.net.HardwareInformation.GpuInfoEntry\x1a\x43\n\x0cGpuInfoEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\"\n\x05value\x18\x02 \x01(\x0b\x32\x13.net.GPUComputeInfo:\x02\x38\x01\x32\xd8\x01\n\x0cOrchestrator\x12\x42\n\x0fGetOrchestrator\x12\x18.net.OrchestratorRequest\x1a\x15.net.OrchestratorInfo\x12^\n\x15\x45ndTranscodingSession\x12!.net.EndTranscodingSessionRequest\x1a\".net.EndTranscodingSessionResponse\x12$\n\x04Ping\x12\r.net.PingPong\x1a\r.net.PingPong2P\n\x08\x41IWorker\x12\x44\n\x10RegisterAIWorker\x12\x1c.net.RegisterAIWorkerRequest\x1a\x10.net.NotifyAIJob0\x01\x32N\n\nTranscoder\x12@\n\x12RegisterTranscoder\x12\x14.net.RegisterRequest\x1a\x12.net.NotifySegment0\x01\x42\x07Z\x05./netb\x06proto3') - -_globals = globals() -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'lp_rpc_pb2', _globals) -if not _descriptor._USE_C_DESCRIPTORS: - _globals['DESCRIPTOR']._loaded_options = None - _globals['DESCRIPTOR']._serialized_options = b'Z\005./net' - _globals['_CAPABILITIES_CAPACITIESENTRY']._loaded_options = None - _globals['_CAPABILITIES_CAPACITIESENTRY']._serialized_options = b'8\001' - _globals['_CAPABILITIES_CONSTRAINTS_PERCAPABILITYENTRY']._loaded_options = None - _globals['_CAPABILITIES_CONSTRAINTS_PERCAPABILITYENTRY']._serialized_options = b'8\001' - _globals['_CAPABILITIES_CAPABILITYCONSTRAINTS_MODELSENTRY']._loaded_options = None - _globals['_CAPABILITIES_CAPABILITYCONSTRAINTS_MODELSENTRY']._serialized_options = b'8\001' - _globals['_HARDWAREINFORMATION_GPUINFOENTRY']._loaded_options = None - _globals['_HARDWAREINFORMATION_GPUINFOENTRY']._serialized_options = b'8\001' - _globals['_PINGPONG']._serialized_start=21 - _globals['_PINGPONG']._serialized_end=46 - _globals['_ENDTRANSCODINGSESSIONREQUEST']._serialized_start=48 - _globals['_ENDTRANSCODINGSESSIONREQUEST']._serialized_end=114 - _globals['_ENDTRANSCODINGSESSIONRESPONSE']._serialized_start=116 - _globals['_ENDTRANSCODINGSESSIONRESPONSE']._serialized_end=147 - _globals['_ORCHESTRATORREQUEST']._serialized_start=149 - _globals['_ORCHESTRATORREQUEST']._serialized_end=270 - _globals['_OSINFO']._serialized_start=273 - _globals['_OSINFO']._serialized_end=405 - _globals['_OSINFO_STORAGETYPE']._serialized_start=360 - _globals['_OSINFO_STORAGETYPE']._serialized_end=405 - _globals['_S3OSINFO']._serialized_start=407 - _globals['_S3OSINFO']._serialized_end=517 - _globals['_PRICEINFO']._serialized_start=519 - _globals['_PRICEINFO']._serialized_end=615 - _globals['_CAPABILITIES']._serialized_start=618 - _globals['_CAPABILITIES']._serialized_end=1347 - _globals['_CAPABILITIES_CAPACITIESENTRY']._serialized_start=798 - _globals['_CAPABILITIES_CAPACITIESENTRY']._serialized_end=847 - _globals['_CAPABILITIES_CONSTRAINTS']._serialized_start=850 - _globals['_CAPABILITIES_CONSTRAINTS']._serialized_end=1051 - _globals['_CAPABILITIES_CONSTRAINTS_PERCAPABILITYENTRY']._serialized_start=958 - _globals['_CAPABILITIES_CONSTRAINTS_PERCAPABILITYENTRY']._serialized_end=1051 - _globals['_CAPABILITIES_CAPABILITYCONSTRAINTS']._serialized_start=1054 - _globals['_CAPABILITIES_CAPABILITYCONSTRAINTS']._serialized_end=1347 - _globals['_CAPABILITIES_CAPABILITYCONSTRAINTS_MODELCONSTRAINT']._serialized_start=1148 - _globals['_CAPABILITIES_CAPABILITYCONSTRAINTS_MODELCONSTRAINT']._serialized_end=1243 - _globals['_CAPABILITIES_CAPABILITYCONSTRAINTS_MODELSENTRY']._serialized_start=1245 - _globals['_CAPABILITIES_CAPABILITYCONSTRAINTS_MODELSENTRY']._serialized_end=1347 - _globals['_ORCHESTRATORINFO']._serialized_start=1350 - _globals['_ORCHESTRATORINFO']._serialized_end=1694 - _globals['_AUTHTOKEN']._serialized_start=1696 - _globals['_AUTHTOKEN']._serialized_end=1762 - _globals['_SEGDATA']._serialized_start=1765 - _globals['_SEGDATA']._serialized_end=2208 - _globals['_SEGPARAMETERS']._serialized_start=2210 - _globals['_SEGPARAMETERS']._serialized_end=2251 - _globals['_VIDEOPROFILE']._serialized_start=2254 - _globals['_VIDEOPROFILE']._serialized_end=2861 - _globals['_VIDEOPROFILE_FORMAT']._serialized_start=2603 - _globals['_VIDEOPROFILE_FORMAT']._serialized_end=2632 - _globals['_VIDEOPROFILE_PROFILE']._serialized_start=2634 - _globals['_VIDEOPROFILE_PROFILE']._serialized_end=2740 - _globals['_VIDEOPROFILE_VIDEOCODEC']._serialized_start=2742 - _globals['_VIDEOPROFILE_VIDEOCODEC']._serialized_end=2792 - _globals['_VIDEOPROFILE_CHROMASUBSAMPLING']._serialized_start=2794 - _globals['_VIDEOPROFILE_CHROMASUBSAMPLING']._serialized_end=2861 - _globals['_TRANSCODEDSEGMENTDATA']._serialized_start=2863 - _globals['_TRANSCODEDSEGMENTDATA']._serialized_end=2944 - _globals['_TRANSCODEDATA']._serialized_start=2946 - _globals['_TRANSCODEDATA']._serialized_end=3020 - _globals['_TRANSCODERESULT']._serialized_start=3023 - _globals['_TRANSCODERESULT']._serialized_end=3153 - _globals['_PAYMENTRESULT']._serialized_start=3155 - _globals['_PAYMENTRESULT']._serialized_end=3207 - _globals['_REGISTERREQUEST']._serialized_start=3209 - _globals['_REGISTERREQUEST']._serialized_end=3301 - _globals['_NOTIFYSEGMENT']._serialized_start=3303 - _globals['_NOTIFYSEGMENT']._serialized_end=3424 - _globals['_REGISTERAIWORKERREQUEST']._serialized_start=3426 - _globals['_REGISTERAIWORKERREQUEST']._serialized_end=3552 - _globals['_AIJOBDATA']._serialized_start=3554 - _globals['_AIJOBDATA']._serialized_end=3604 - _globals['_NOTIFYAIJOB']._serialized_start=3606 - _globals['_NOTIFYAIJOB']._serialized_end=3670 - _globals['_TICKETPARAMS']._serialized_start=3673 - _globals['_TICKETPARAMS']._serialized_end=3869 - _globals['_TICKETSENDERPARAMS']._serialized_start=3871 - _globals['_TICKETSENDERPARAMS']._serialized_end=3926 - _globals['_TICKETEXPIRATIONPARAMS']._serialized_start=3928 - _globals['_TICKETEXPIRATIONPARAMS']._serialized_end=4011 - _globals['_PAYMENT']._serialized_start=4014 - _globals['_PAYMENT']._serialized_end=4232 - _globals['_GPUCOMPUTEINFO']._serialized_start=4234 - _globals['_GPUCOMPUTEINFO']._serialized_end=4349 - _globals['_HARDWAREINFORMATION']._serialized_start=4352 - _globals['_HARDWAREINFORMATION']._serialized_end=4535 - _globals['_HARDWAREINFORMATION_GPUINFOENTRY']._serialized_start=4468 - _globals['_HARDWAREINFORMATION_GPUINFOENTRY']._serialized_end=4535 - _globals['_ORCHESTRATOR']._serialized_start=4538 - _globals['_ORCHESTRATOR']._serialized_end=4754 - _globals['_AIWORKER']._serialized_start=4756 - _globals['_AIWORKER']._serialized_end=4836 - _globals['_TRANSCODER']._serialized_start=4838 - _globals['_TRANSCODER']._serialized_end=4916 -# @@protoc_insertion_point(module_scope) diff --git a/src/livepeer_gateway/lp_rpc_pb2_grpc.py b/src/livepeer_gateway/lp_rpc_pb2_grpc.py deleted file mode 100644 index cf8f297..0000000 --- a/src/livepeer_gateway/lp_rpc_pb2_grpc.py +++ /dev/null @@ -1,334 +0,0 @@ -# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! -"""Client and server classes corresponding to protobuf-defined services.""" -import grpc -import warnings - -from . import lp_rpc_pb2 as lp__rpc__pb2 -GRPC_GENERATED_VERSION = '1.76.0' -GRPC_VERSION = grpc.__version__ -_version_not_supported = False - -try: - from grpc._utilities import first_version_is_lower - _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) -except ImportError: - _version_not_supported = True - -if _version_not_supported: - raise RuntimeError( - f'The grpc package installed is at version {GRPC_VERSION},' - + ' but the generated code in lp_rpc_pb2_grpc.py depends on' - + f' grpcio>={GRPC_GENERATED_VERSION}.' - + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' - + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' - ) - - -class OrchestratorStub(object): - """RPC calls implemented by the orchestrator - """ - - def __init__(self, channel): - """Constructor. - - Args: - channel: A grpc.Channel. - """ - self.GetOrchestrator = channel.unary_unary( - '/net.Orchestrator/GetOrchestrator', - request_serializer=lp__rpc__pb2.OrchestratorRequest.SerializeToString, - response_deserializer=lp__rpc__pb2.OrchestratorInfo.FromString, - _registered_method=True) - self.EndTranscodingSession = channel.unary_unary( - '/net.Orchestrator/EndTranscodingSession', - request_serializer=lp__rpc__pb2.EndTranscodingSessionRequest.SerializeToString, - response_deserializer=lp__rpc__pb2.EndTranscodingSessionResponse.FromString, - _registered_method=True) - self.Ping = channel.unary_unary( - '/net.Orchestrator/Ping', - request_serializer=lp__rpc__pb2.PingPong.SerializeToString, - response_deserializer=lp__rpc__pb2.PingPong.FromString, - _registered_method=True) - - -class OrchestratorServicer(object): - """RPC calls implemented by the orchestrator - """ - - def GetOrchestrator(self, request, context): - """Called by the broadcaster to request transcoder info from an orchestrator. - """ - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - def EndTranscodingSession(self, request, context): - """Missing associated documentation comment in .proto file.""" - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - def Ping(self, request, context): - """Missing associated documentation comment in .proto file.""" - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - -def add_OrchestratorServicer_to_server(servicer, server): - rpc_method_handlers = { - 'GetOrchestrator': grpc.unary_unary_rpc_method_handler( - servicer.GetOrchestrator, - request_deserializer=lp__rpc__pb2.OrchestratorRequest.FromString, - response_serializer=lp__rpc__pb2.OrchestratorInfo.SerializeToString, - ), - 'EndTranscodingSession': grpc.unary_unary_rpc_method_handler( - servicer.EndTranscodingSession, - request_deserializer=lp__rpc__pb2.EndTranscodingSessionRequest.FromString, - response_serializer=lp__rpc__pb2.EndTranscodingSessionResponse.SerializeToString, - ), - 'Ping': grpc.unary_unary_rpc_method_handler( - servicer.Ping, - request_deserializer=lp__rpc__pb2.PingPong.FromString, - response_serializer=lp__rpc__pb2.PingPong.SerializeToString, - ), - } - generic_handler = grpc.method_handlers_generic_handler( - 'net.Orchestrator', rpc_method_handlers) - server.add_generic_rpc_handlers((generic_handler,)) - server.add_registered_method_handlers('net.Orchestrator', rpc_method_handlers) - - - # This class is part of an EXPERIMENTAL API. -class Orchestrator(object): - """RPC calls implemented by the orchestrator - """ - - @staticmethod - def GetOrchestrator(request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None): - return grpc.experimental.unary_unary( - request, - target, - '/net.Orchestrator/GetOrchestrator', - lp__rpc__pb2.OrchestratorRequest.SerializeToString, - lp__rpc__pb2.OrchestratorInfo.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) - - @staticmethod - def EndTranscodingSession(request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None): - return grpc.experimental.unary_unary( - request, - target, - '/net.Orchestrator/EndTranscodingSession', - lp__rpc__pb2.EndTranscodingSessionRequest.SerializeToString, - lp__rpc__pb2.EndTranscodingSessionResponse.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) - - @staticmethod - def Ping(request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None): - return grpc.experimental.unary_unary( - request, - target, - '/net.Orchestrator/Ping', - lp__rpc__pb2.PingPong.SerializeToString, - lp__rpc__pb2.PingPong.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) - - -class AIWorkerStub(object): - """Missing associated documentation comment in .proto file.""" - - def __init__(self, channel): - """Constructor. - - Args: - channel: A grpc.Channel. - """ - self.RegisterAIWorker = channel.unary_stream( - '/net.AIWorker/RegisterAIWorker', - request_serializer=lp__rpc__pb2.RegisterAIWorkerRequest.SerializeToString, - response_deserializer=lp__rpc__pb2.NotifyAIJob.FromString, - _registered_method=True) - - -class AIWorkerServicer(object): - """Missing associated documentation comment in .proto file.""" - - def RegisterAIWorker(self, request, context): - """Called by the aiworker to register to an orchestrator. The orchestrator - notifies registered aiworkers of jobs as they come in. - """ - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - -def add_AIWorkerServicer_to_server(servicer, server): - rpc_method_handlers = { - 'RegisterAIWorker': grpc.unary_stream_rpc_method_handler( - servicer.RegisterAIWorker, - request_deserializer=lp__rpc__pb2.RegisterAIWorkerRequest.FromString, - response_serializer=lp__rpc__pb2.NotifyAIJob.SerializeToString, - ), - } - generic_handler = grpc.method_handlers_generic_handler( - 'net.AIWorker', rpc_method_handlers) - server.add_generic_rpc_handlers((generic_handler,)) - server.add_registered_method_handlers('net.AIWorker', rpc_method_handlers) - - - # This class is part of an EXPERIMENTAL API. -class AIWorker(object): - """Missing associated documentation comment in .proto file.""" - - @staticmethod - def RegisterAIWorker(request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None): - return grpc.experimental.unary_stream( - request, - target, - '/net.AIWorker/RegisterAIWorker', - lp__rpc__pb2.RegisterAIWorkerRequest.SerializeToString, - lp__rpc__pb2.NotifyAIJob.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) - - -class TranscoderStub(object): - """Missing associated documentation comment in .proto file.""" - - def __init__(self, channel): - """Constructor. - - Args: - channel: A grpc.Channel. - """ - self.RegisterTranscoder = channel.unary_stream( - '/net.Transcoder/RegisterTranscoder', - request_serializer=lp__rpc__pb2.RegisterRequest.SerializeToString, - response_deserializer=lp__rpc__pb2.NotifySegment.FromString, - _registered_method=True) - - -class TranscoderServicer(object): - """Missing associated documentation comment in .proto file.""" - - def RegisterTranscoder(self, request, context): - """Called by the transcoder to register to an orchestrator. The orchestrator - notifies registered transcoders of segments as they come in. - """ - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - -def add_TranscoderServicer_to_server(servicer, server): - rpc_method_handlers = { - 'RegisterTranscoder': grpc.unary_stream_rpc_method_handler( - servicer.RegisterTranscoder, - request_deserializer=lp__rpc__pb2.RegisterRequest.FromString, - response_serializer=lp__rpc__pb2.NotifySegment.SerializeToString, - ), - } - generic_handler = grpc.method_handlers_generic_handler( - 'net.Transcoder', rpc_method_handlers) - server.add_generic_rpc_handlers((generic_handler,)) - server.add_registered_method_handlers('net.Transcoder', rpc_method_handlers) - - - # This class is part of an EXPERIMENTAL API. -class Transcoder(object): - """Missing associated documentation comment in .proto file.""" - - @staticmethod - def RegisterTranscoder(request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None): - return grpc.experimental.unary_stream( - request, - target, - '/net.Transcoder/RegisterTranscoder', - lp__rpc__pb2.RegisterRequest.SerializeToString, - lp__rpc__pb2.NotifySegment.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) diff --git a/src/livepeer_gateway/lv2v.py b/src/livepeer_gateway/lv2v.py deleted file mode 100644 index c5a87a6..0000000 --- a/src/livepeer_gateway/lv2v.py +++ /dev/null @@ -1,398 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging -import time -from dataclasses import dataclass, field -from typing import Any, Optional, Sequence - -from . import lp_rpc_pb2 -from .capabilities import CapabilityId, build_capabilities -from .channel_writer import ChannelWriter -from .control import Control, ControlConfig, ControlMode -from .errors import ( - LivepeerGatewayError, - NoOrchestratorAvailableError, - OrchestratorRejection, - SkipPaymentCycle, -) -from .events import Events -from .media_output import ( - LagPolicy, - MediaFrameCallback, - MediaOutput, - MediaPacketCallback, -) -from .media_publish import MediaPublish, MediaPublishConfig -from .http import _http_origin, post_json_sync -from .selection import orchestrator_selector -from .remote_signer import PaymentSession -from .token import parse_token -from .trickle_subscriber import TrickleSubscriber - -_LOG = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class StartJobRequest: - # The ID of the Gateway request (for logging purposes). - request_id: Optional[str] = None - # ModelId Name of the pipeline to run in the live video to video job. - model_id: Optional[str] = None - # Params Initial parameters for the pipeline. - params: Optional[dict[str, Any]] = None - # StreamId The Stream ID (for logging purposes). - stream_id: Optional[str] = None - - def to_json(self) -> dict[str, Any]: - payload: dict[str, Any] = {} - if self.request_id is not None: - payload["gateway_request_id"] = self.request_id - if self.model_id is not None: - payload["model_id"] = self.model_id - if self.params is not None: - payload["params"] = self.params - if self.stream_id is not None: - payload["stream_id"] = self.stream_id - return payload - - -@dataclass(frozen=True) -class LiveVideoToVideo: - raw: dict[str, Any] - manifest_id: Optional[str] = None - signer_url: Optional[str] = None - publish_url: Optional[str] = None - subscribe_url: Optional[str] = None - control_url: Optional[str] = None - events_url: Optional[str] = None - orchestrator_info: Optional[lp_rpc_pb2.OrchestratorInfo] = None - control: Optional[ChannelWriter] = None - events: Optional[Events] = None - _media: Optional[MediaPublish] = field(default=None, repr=False, compare=False) - _payment_session: Optional["PaymentSession"] = field(default=None, repr=False, compare=False) - _payment_task: Optional[asyncio.Task] = field(default=None, repr=False, compare=False) - - @staticmethod - def from_json( - data: dict[str, Any], - *, - signer_url: Optional[str] = None, - orchestrator_info: Optional[lp_rpc_pb2.OrchestratorInfo] = None, - payment_session: Optional["PaymentSession"] = None, - ) -> "LiveVideoToVideo": - control_url = data.get("control_url") if isinstance(data.get("control_url"), str) else None - control = Control(control_url) if control_url else None - publish_url = data.get("publish_url") if isinstance(data.get("publish_url"), str) else None - events_url = data.get("events_url") if isinstance(data.get("events_url"), str) else None - events = Events(events_url) if events_url else None - return LiveVideoToVideo( - raw=data, - signer_url=signer_url, - control_url=control_url, - events_url=events_url, - manifest_id=data.get("manifest_id") if isinstance(data.get("manifest_id"), str) else None, - publish_url=publish_url, - subscribe_url=data.get("subscribe_url") if isinstance(data.get("subscribe_url"), str) else None, - orchestrator_info=orchestrator_info, - control=control, - events=events, - _payment_session=payment_session, - ) - - def start_media(self, config: MediaPublishConfig) -> MediaPublish: - """ - Instantiate and return a MediaPublish helper for this job. - """ - if not self.publish_url: - raise LivepeerGatewayError("No publish_url present on this LiveVideoToVideo job") - if self._media is None: - media = MediaPublish(self.publish_url, config=config) - object.__setattr__(self, "_media", media) - return self._media - - def media_output( - self, - *, - start_seq: int = -2, - max_retries: int = 5, - max_segment_bytes: Optional[int] = None, - connection_close: bool = False, - chunk_size: int = 64 * 1024, - max_segments: int = 5, - on_lag: LagPolicy = LagPolicy.LATEST, - on_frame: MediaFrameCallback | None = None, - on_packet: MediaPacketCallback | None = None, - ) -> MediaOutput: - """ - Convenience helper to create a `MediaOutput` for this job. - - This uses `subscribe_url` from the job response and raises if missing. - Subscription tuning is configured once here (and stored on the returned `MediaOutput`). - """ - if not self.subscribe_url: - raise LivepeerGatewayError("No subscribe_url present on this LiveVideoToVideo job") - return MediaOutput( - self.subscribe_url, - start_seq=start_seq, - max_retries=max_retries, - max_segment_bytes=max_segment_bytes, - connection_close=connection_close, - chunk_size=chunk_size, - max_segments=max_segments, - on_lag=on_lag, - on_frame=on_frame, - on_packet=on_packet, - ) - - @property - def payment_session(self) -> Optional["PaymentSession"]: - """ - Access the PaymentSession for this job, if available. - """ - return self._payment_session - - def start_payment_sender(self) -> Optional[asyncio.Task]: - """ - Start the background payment sender if a payment session and - subscribe_url are available and the task isn't already running. - - Requires a running asyncio event loop. If called from sync code - (no running loop), logs a warning and returns ``None``. The - method is idempotent -- safe to call multiple times. - - Returns the ``asyncio.Task``, or ``None`` if payments could not - be started. - """ - if getattr(self, "_payment_task", None) is not None: - return self._payment_task - if not self._payment_session or not self.subscribe_url: - return None - try: - loop = asyncio.get_running_loop() - except RuntimeError: - _LOG.warning( - "No running event loop; per-segment payments not started. " - "Call job.start_payment_sender() from async code to enable." - ) - return None - task = loop.create_task( - _payment_sender(self.subscribe_url, self._payment_session) - ) - object.__setattr__(self, "_payment_task", task) - return task - - async def close(self) -> None: - """ - Close any nested helpers (control, media, payment sender, etc) - best-effort. - """ - tasks = [] - payment_task = getattr(self, "_payment_task", None) - if payment_task is not None and not payment_task.done(): - payment_task.cancel() - tasks.append(payment_task) - if self.control is not None: - tasks.append(self.control.close()) - if self._media is not None: - tasks.append(self._media.close()) - if not tasks: - return - - results = await asyncio.gather(*tasks, return_exceptions=True) - for result in results: - if isinstance(result, BaseException) and not isinstance( - result, asyncio.CancelledError - ): - raise result - - -async def _payment_sender( - subscribe_url: str, - session: PaymentSession, -) -> None: - """ - Send one payment per trickle output segment. - - Uses a long-lived :class:`TrickleSubscriber` with - ``connection_close=True``. Reads only headers (zero body bytes) per - segment, sends the payment, then closes the segment. The built-in - preconnect prefetches the next segment while the payment is in - flight. - - Runs until the trickle stream ends or the task is cancelled. - Payment errors are logged but do not stop the loop. - """ - last_payment_at = 0.0 - async with TrickleSubscriber( - subscribe_url, - connection_close=True, - ) as subscriber: - while True: - segment = await subscriber.next() - if segment is None: - _LOG.debug("Payment sender: stream ended for %s", subscribe_url) - return - seq = segment.seq() - try: - now = time.monotonic() - if now - last_payment_at >= 5.0: - _LOG.debug("Payment sender: sending payment for seq=%s", seq) - # TODO make async-native - await asyncio.to_thread(session.send_payment) - last_payment_at = now - except SkipPaymentCycle as e: - _LOG.debug( - "Payment sender: skipping payment for seq=%s (%s)", - seq, - e, - ) - except Exception: - _LOG.exception("Payment sender: failed for seq=%s", seq) - finally: - await segment.close() - - -def start_lv2v( - orch_url: Optional[Sequence[str] | str], - req: StartJobRequest, - *, - start_payments: bool = True, - token: Optional[str] = None, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - discovery_url: Optional[str] = None, - discovery_headers: Optional[dict[str, str]] = None, - control_config: Optional[ControlConfig] = None, - use_tofu: bool = True, - timeout: float = 5.0, -) -> LiveVideoToVideo: - """ - Start a live video-to-video job. - - Selects an orchestrator with LV2V capability and calls - POST {info.transcoder}/live-video-to-video with JSON body. - - If ``start_payments`` is true and the call happens within a running - asyncio event loop, a background task is automatically started to - send per-segment payments. Otherwise a warning is logged and - payments can be started later via ``job.start_payment_sender()``. - - Optional ``token`` can be provided as a base64-encoded JSON object. - Token values take precedence over explicit keyword arguments. - Explicit keyword arguments are used only for fields missing in the token. - - Orchestrator selection/discovery precedence (highest -> lowest): - 1) token ``orchestrators`` value - 2) explicit ``orch_url`` list - 3) token ``discovery`` value - 4) explicit ``discovery_url`` argument - 5) remote signer discovery endpoint derived from the resolved signer URL - - ``timeout`` controls only the initial HTTP POST to - ``/live-video-to-video`` after an orchestrator has been selected. - Discovery and ``GetOrchestrator`` calls use their own timeouts. - - ``use_tofu`` controls TLS mode for ``GetOrchestrator``: - - True: trust-on-first-use certificate pinning - - False: default gRPC/system CA roots - - ``control_config`` controls control-channel behavior. Use - ``ControlConfig(mode=ControlMode.DISABLED)`` to disable keepalives. - """ - if not req.model_id: - raise LivepeerGatewayError("start_lv2v requires model_id") - - token_data: Optional[dict[str, Any]] = None - if token is not None: - token_data = parse_token(token) - - resolved_orch_url = token_data.get("orchestrators") if token_data else None - if resolved_orch_url is None: - resolved_orch_url = orch_url - - resolved_signer_url = token_data.get("signer") if token_data else None - if resolved_signer_url is None: - resolved_signer_url = signer_url - - resolved_signer_headers = token_data.get("signer_headers") if token_data else None - if resolved_signer_headers is None: - resolved_signer_headers = signer_headers - - resolved_discovery_url = token_data.get("discovery") if token_data else None - if resolved_discovery_url is None: - resolved_discovery_url = discovery_url - - resolved_discovery_headers = token_data.get("discovery_headers") if token_data else None - if resolved_discovery_headers is None: - resolved_discovery_headers = discovery_headers - - capabilities = build_capabilities(CapabilityId.LIVE_VIDEO_TO_VIDEO, req.model_id) - # Orchestrator discovery precedence after token-first field resolution: - # token orchestrators -> explicit orch_url -> token discovery -> - # explicit discovery_url -> signer_url - cursor = orchestrator_selector( - resolved_orch_url, - signer_url=resolved_signer_url, - signer_headers=resolved_signer_headers, - discovery_url=resolved_discovery_url, - discovery_headers=resolved_discovery_headers, - capabilities=capabilities, - use_tofu=use_tofu, - ) - - start_rejections: list[OrchestratorRejection] = [] - while True: - try: - selected_url, info = cursor.next() - except NoOrchestratorAvailableError as e: - # No more successful OrchestratorInfo responses remain. - # Surface a single aggregated "all orchestrators failed" error. - all_rejections = list(e.rejections) + start_rejections - if all_rejections: - raise NoOrchestratorAvailableError( - f"All orchestrators failed ({len(all_rejections)} tried)", - rejections=all_rejections, - ) from None - raise - - try: - session = PaymentSession( - resolved_signer_url, - info, - signer_headers=resolved_signer_headers, - type="lv2v", - capabilities=capabilities, - use_tofu=use_tofu, - ) - p = session.get_payment() - headers: dict[str, str] = { - "Livepeer-Payment": p.payment, - "Livepeer-Segment": p.seg_creds, - } - - base = _http_origin(info.transcoder) - url = f"{base}/live-video-to-video" - data = post_json_sync(url, req.to_json(), headers=headers, timeout=timeout) - job = LiveVideoToVideo.from_json( - data, - signer_url=resolved_signer_url, - orchestrator_info=info, - payment_session=session, - ) - if not job.manifest_id: - raise LivepeerGatewayError("LiveVideoToVideo response missing manifest_id") - session.set_manifest_id(job.manifest_id) - if start_payments: - job.start_payment_sender() - mode = control_config.mode if control_config is not None else ControlMode.MESSAGE - if mode == ControlMode.MESSAGE and job.control is not None: - job.control.start_keepalive() - return job - except LivepeerGatewayError as e: - _LOG.debug( - "start_lv2v candidate failed, trying fallback if available: %s (%s)", - selected_url, - str(e), - ) - start_rejections.append(OrchestratorRejection(url=selected_url, reason=str(e))) diff --git a/src/livepeer_gateway/media_output.py b/src/livepeer_gateway/media_output.py index 4d61939..a634304 100644 --- a/src/livepeer_gateway/media_output.py +++ b/src/livepeer_gateway/media_output.py @@ -404,9 +404,11 @@ async def _iter() -> AsyncIterator[AudioDecodedMediaFrame | VideoDecodedMediaFra decoder.start() async def _feed() -> None: - async for chunk in self._iter_bytes(): - decoder.feed(chunk) - decoder.close() + try: + async for chunk in self._iter_bytes(): + decoder.feed(chunk) + finally: + decoder.close() producer_task = asyncio.create_task(_feed()) try: @@ -551,9 +553,9 @@ async def close(self, *, wait_callbacks: bool = True, timeout: float | None = 10 callback_tasks = self.callback_tasks() if callback_tasks: if wait_callbacks and (timeout is None or timeout > 0): - try: - await self.wait_callbacks(timeout=timeout) - except TimeoutError: + # Defer callback errors until after resource cleanup. + _, pending = await asyncio.wait(callback_tasks, timeout=timeout) + if pending: _LOG.debug( "Media output callbacks did not finish before shutdown timeout; cancelling" ) diff --git a/src/livepeer_gateway/orch_info.py b/src/livepeer_gateway/orch_info.py deleted file mode 100644 index bca7721..0000000 --- a/src/livepeer_gateway/orch_info.py +++ /dev/null @@ -1,358 +0,0 @@ -from __future__ import annotations - -import ipaddress -import logging -import os -import socket -import ssl -import tempfile -from dataclasses import dataclass -from typing import Optional, Tuple -from urllib.parse import urlparse - -import grpc - -from . import lp_rpc_pb2 -from . import lp_rpc_pb2_grpc -from .errors import LivepeerGatewayError -from .remote_signer import _freeze_headers, _hex_to_bytes, get_orch_info_sig - -_LOG = logging.getLogger(__name__) - -# Per-target TOFU certificate cache. -# -# Livepeer orchestrators often run with self-signed TLS certificates that are -# regenerated on process startup. In a trust-on-first-use (TOFU) model we pin -# the certificate we see on first contact for a target (host:port), then reuse -# it for subsequent gRPC calls. -# -# We intentionally use a dict (not @lru_cache) so we can evict a single target -# when we detect a restarted orchestrator presenting a new certificate. -_TOFU_CERT_CACHE: dict[str, Tuple[bytes, str]] = {} - - -@dataclass -class OrchestratorRpcError(LivepeerGatewayError): - orch_url: str - message: str - cause: Optional[BaseException] = None - - def __str__(self) -> str: - return f"Orchestrator RPC error: {self.message} (orch={self.orch_url})" - - -def create_orchestrator_stub( - orch_url: str, - *, - use_tofu: bool = True, -) -> Tuple[grpc.Channel, lp_rpc_pb2_grpc.OrchestratorStub]: - # Always use TLS. TOFU can be disabled to use gRPC's default CA trust store. - target = _parse_grpc_target(orch_url) - if use_tofu: - # "Ignore" invalid/self-signed certs by trusting the exact certificate - # the server presents (trust-on-first-use) and overriding authority. - root_pem, authority = _trust_on_first_use_root_cert_target(target) - credentials = grpc.ssl_channel_credentials(root_certificates=root_pem) - options = [ - ("grpc.ssl_target_name_override", authority), - ("grpc.default_authority", authority), - ] - else: - credentials = grpc.ssl_channel_credentials() - options = [] - channel = grpc.secure_channel(target, credentials, options=options) - stub = lp_rpc_pb2_grpc.OrchestratorStub(channel) - return channel, stub - - -def call_get_orchestrator( - stub: lp_rpc_pb2_grpc.OrchestratorStub, - request: lp_rpc_pb2.OrchestratorRequest, - orch_url: str, -) -> lp_rpc_pb2.OrchestratorInfo: - try: - return stub.GetOrchestrator(request, timeout=5.0) - except grpc.RpcError as e: - # e.details() may be None; be defensive - details = "" - try: - details = e.details() or "" - except Exception: - details = "" - - code = "" - try: - code = str(e.code()) - except Exception: - code = "UNKNOWN" - - msg = details or repr(e) - raise OrchestratorRpcError(orch_url, f"{code}: {msg}", cause=e) from None - - -def get_orch_info( - orch_url: str, - *, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - capabilities: Optional[lp_rpc_pb2.Capabilities] = None, - use_tofu: bool = True, -) -> lp_rpc_pb2.OrchestratorInfo: - """ - Fetch orchestrator info over gRPC. - - Public functional API: - get_orch_info(orch_url, signer_url=...) - Remote signer is called once per process (cached). - Always uses a secure gRPC channel (TLS). - - With TOFU enabled, it trusts the first observed server certificate per target. - - With TOFU disabled, it uses gRPC's default system CA trust roots. - """ - _LOG.debug( - "Fetching orchestrator info orch=%s signer=%s capabilities=%s use_tofu=%s", - orch_url, - signer_url or "", - "set" if capabilities is not None else "none", - str(use_tofu), - ) - try: - signer = get_orch_info_sig(signer_url, _freeze_headers(signer_headers)) - except Exception as e: - # Ensure caller sees a clean library error (no raw traceback). - raise OrchestratorRpcError( - orch_url, - f"{e.__class__.__name__}: {e}", - cause=e, - ) from None - - try: - address = _hex_to_bytes(signer.address, expected_len=20) if signer.address else b"" - sig = _hex_to_bytes(signer.sig) if signer.sig else b"" - except ValueError as e: - raise OrchestratorRpcError( - orch_url, - f"invalid signer material: {e}", - cause=e, - ) from None - - request = lp_rpc_pb2.OrchestratorRequest( - address=address, - sig=sig, - ignoreCapacityCheck=True, - ) - if capabilities is not None: - request.capabilities.CopyFrom(capabilities) - - # Retry once on certificate verification failures (TOFU mode only). - # - # Why this exists: - # - TOFU pins the cert we first observed for a target. - # - Some orchestrators regenerate self-signed certs on startup. - # - After restart, the pinned cert is stale, and gRPC reports - # CERTIFICATE_VERIFY_FAILED during the next RPC. - # - # On that specific failure we evict the cached TOFU cert for this target - # and retry once so we can probe and trust the new certificate. - target = _parse_grpc_target(orch_url) - max_attempts = 2 if use_tofu else 1 - for attempt in range(max_attempts): - _, stub = create_orchestrator_stub(orch_url, use_tofu=use_tofu) - try: - return call_get_orchestrator(stub, request, orch_url) - except OrchestratorRpcError as e: - if use_tofu and attempt == 0 and _is_cert_verify_error(e): - _LOG.info( - "Orchestrator %s TLS cert changed (likely restarted); " - "evicting cached TOFU cert and retrying once", - orch_url, - ) - _evict_tofu_cache(target) - continue - raise - - -def _split_host_port(target: str) -> Tuple[str, int]: - """ - Parse a gRPC target in the form "host:port" or "[ipv6]:port". - """ - target = target.strip() - if target.startswith("["): - # [::1]:8935 - host, rest = target[1:].split("]", 1) - if not rest.startswith(":"): - raise ValueError(f"Invalid gRPC target (missing port): {target!r}") - return host, int(rest[1:]) - - # host:port (we don't support bare host here) - if target.count(":") != 1: - raise ValueError(f"Invalid gRPC target (expected host:port): {target!r}") - host, port_s = target.split(":", 1) - return host, int(port_s) - - -def _parse_grpc_target(orch_url: str) -> str: - """ - Normalize user input into a gRPC target string suitable for grpc.secure_channel(). - - Accepts: - - "host:port" - - "[ipv6]:port" - - "https://host:port" (scheme is stripped; TLS is still used by the channel) - """ - orch_url = orch_url.strip() - # If no scheme is provided, treat it as https://... implicitly. - # Also avoids `urlparse("localhost:8935")` interpreting "localhost" as a scheme. - url = orch_url if "://" in orch_url else f"https://{orch_url}" - - parsed = urlparse(url) - if parsed.scheme != "https": - raise ValueError(f"Only https:// orchestrator URLs are supported (got {parsed.scheme!r})") - if not parsed.netloc: - raise ValueError(f"Invalid orchestrator URL: {orch_url!r}") - if parsed.path not in ("", "/") or parsed.params or parsed.query or parsed.fragment: - # gRPC targets are host:port; ignore any path-like components by rejecting explicitly. - raise ValueError(f"Orchestrator URL must not include a path/query/fragment: {orch_url!r}") - return parsed.netloc - - -def _is_ip_address(host: str) -> bool: - try: - ipaddress.ip_address(host) - return True - except ValueError: - return False - - -def _pick_cert_authority(cert: dict) -> Optional[str]: - """ - Choose an authority value that will satisfy gRPC hostname verification. - Prefers DNS SAN, then IP SAN, then Common Name. - """ - san = cert.get("subjectAltName") or [] - for typ, val in san: - if typ == "DNS" and val: - return val - for typ, val in san: - if typ in ("IP Address", "IP") and val: - return val - - # subject is a tuple of RDN tuples: ((('commonName','example.com'),), ...) - for rdn in cert.get("subject") or []: - for key, val in rdn: - if key == "commonName" and val: - return val - return None - - -def _decode_pem_cert(pem: bytes) -> dict: - """ - Decode a PEM cert into a dict containing subject / subjectAltName. - - Note: Python's `SSLSocket.getpeercert()` returns `{}` when verify_mode=CERT_NONE, - so we decode the PEM ourselves via a CPython helper. - """ - try: - decode = ssl._ssl._test_decode_cert # pyright: ignore[reportAttributeAccessIssue] - except Exception: - return {} - - tmp_path: Optional[str] = None - try: - with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f: - f.write(pem) - tmp_path = f.name - return decode(tmp_path) or {} - finally: - if tmp_path: - try: - os.unlink(tmp_path) - except OSError: - pass - - -def _is_cert_verify_error(err: BaseException) -> bool: - """ - Detect gRPC TLS failures caused by a changed orchestrator certificate. - - We are specifically interested in CERTIFICATE_VERIFY_FAILED because it - indicates our pinned TOFU cert no longer matches what the orchestrator now - presents (commonly after orchestrator restart with a regenerated self-signed - cert). - """ - return "CERTIFICATE_VERIFY_FAILED" in str(err) - - -def _fetch_tofu_root_cert_for_target(target: str) -> Tuple[bytes, str]: - """ - Fetch the server certificate via a TLS handshake with verification disabled, - then "trust" that exact certificate by using it as the root cert for gRPC. - - Returns (root_cert_pem_bytes, authority_override). - """ - host, port = _split_host_port(target) - - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - # gRPC requires HTTP/2; many gRPC-TLS servers expect ALPN "h2" in the ClientHello. - try: - ctx.set_alpn_protocols(["h2"]) - except NotImplementedError: - # Some Python/OpenSSL builds may not support ALPN; best effort. - pass - - # Only send SNI for DNS names; IP SNI can trigger "Invalid server name indication". - server_hostname = None if _is_ip_address(host) else host - - with socket.create_connection((host, port), timeout=5.0) as sock: - with ctx.wrap_socket(sock, server_hostname=server_hostname) as ssock: - der = ssock.getpeercert(binary_form=True) - - pem = ssl.DER_cert_to_PEM_cert(der).encode("ascii") - decoded = _decode_pem_cert(pem) - authority = _pick_cert_authority(decoded) or host - return pem, authority - - -def _trust_on_first_use_root_cert_target(target: str) -> Tuple[bytes, str]: - """ - Return cached TOFU trust material for a target, probing if needed. - - The cache key is the normalized gRPC target (host:port or [ipv6]:port). - """ - cached = _TOFU_CERT_CACHE.get(target) - if cached is not None: - return cached - - trust_material = _fetch_tofu_root_cert_for_target(target) - _TOFU_CERT_CACHE[target] = trust_material - return trust_material - - -def _evict_tofu_cache(target: str) -> None: - """ - Evict cached TOFU trust material for a single target. - - This is used on CERTIFICATE_VERIFY_FAILED so the next connection can - re-probe the orchestrator and trust its newly generated certificate. - """ - _TOFU_CERT_CACHE.pop(target, None) - - -def _trust_on_first_use_root_cert(orch_url: str) -> Tuple[bytes, str, str]: - """ - Wrapper that: - - accepts https://host:port or host:port - - returns (root_pem, authority, target) - - re-raises failures as OrchestratorRpcError with the *original* orch_url - """ - try: - target = _parse_grpc_target(orch_url) - root_pem, authority = _trust_on_first_use_root_cert_target(target) - return root_pem, authority, target - except Exception as e: - raise OrchestratorRpcError( - orch_url, - f"TLS trust-on-first-use probe failed: {e.__class__.__name__}: {e}", - cause=e, - ) from None diff --git a/src/livepeer_gateway/orchestrator.py b/src/livepeer_gateway/orchestrator.py deleted file mode 100644 index 0c5ae69..0000000 --- a/src/livepeer_gateway/orchestrator.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from .discovery import _append_caps, discover_orchestrators -from .errors import ( - LivepeerGatewayError, - SignerRefreshRequired, - SkipPaymentCycle, -) -from .http import ( - _extract_error_message, - _extract_error_message_from_body, - _http_error_body, - _http_origin, - _json_request_parts, - _parse_http_url, - _raise_http_json_error, - _truncate, - get_json_sync, - post_json_sync, - request_json_sync, -) - -# Compatibility aliases for the original synchronous helpers. -request_json = request_json_sync -post_json = post_json_sync -get_json = get_json_sync - -__all__ = [ - "LivepeerGatewayError", - "SignerRefreshRequired", - "SkipPaymentCycle", - "_append_caps", - "_extract_error_message", - "_extract_error_message_from_body", - "_http_error_body", - "_http_origin", - "_json_request_parts", - "_parse_http_url", - "_raise_http_json_error", - "_truncate", - "discover_orchestrators", - "get_json", - "get_json_sync", - "post_json", - "post_json_sync", - "request_json", - "request_json_sync", -] diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index dd4554f..1c027bf 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -1,18 +1,10 @@ from __future__ import annotations import asyncio -import base64 -import json import logging -import re -import ssl from dataclasses import dataclass, replace -from functools import lru_cache from typing import Any, Optional -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen -from . import lp_rpc_pb2 from .async_cache import async_lru_cache from .errors import ( LivepeerGatewayError, @@ -23,7 +15,7 @@ ) _LOG = logging.getLogger(__name__) -# Must stay under the signer's opening payment: 10s per-second, 60s pixel. +# Must stay under the signer's opening payment of 10 seconds. PAYMENT_INTERVAL_S = 3.0 @dataclass(frozen=True) @@ -62,31 +54,14 @@ def __str__(self) -> str: return f"Remote signer error: {self.message} (url={self.signer_url})" -_HEX_RE = re.compile(r"^(0x)?[0-9a-fA-F]*$") - - def _freeze_headers( headers: Optional[dict[str, str]], ) -> Optional[frozenset[tuple[str, str]]]: - """Convert a headers dict to a frozenset for use with @lru_cache.""" + """Convert a headers dict to a hashable cache key.""" if headers is None: return None return frozenset(headers.items()) -def _hex_to_bytes(s: str, *, expected_len: Optional[int] = None) -> bytes: - s = s.strip() - if not _HEX_RE.match(s): - raise ValueError(f"Not a hex string: {s!r}") - if s.startswith(("0x", "0X")): - s = s[2:] - if len(s) % 2 == 1: - # allow odd-length hex (pad left) - s = "0" + s - b = bytes.fromhex(s) - if expected_len is not None and len(b) != expected_len: - raise ValueError(f"Expected {expected_len} bytes, got {len(b)} bytes") - return b - def _signer_material_from_json( data: dict[str, Any], @@ -117,90 +92,13 @@ def _signer_material_from_json( return SignerMaterial(address=address, sig=sig) -@lru_cache(maxsize=None) -def get_orch_info_sig( - signer_url: str, - # frozenset instead of dict because @lru_cache requires hashable arguments. - _signer_headers: Optional[frozenset[tuple[str, str]]] = None, -) -> SignerMaterial: - """ - Fetch signer material exactly once per (signer_url, headers) combination - for the lifetime of the process. Subsequent calls return cached data. - """ - from .http import _extract_error_message, _http_origin, post_json_sync as post_json - - # check for offchain mode - if not signer_url: - return SignerMaterial(address=None, sig=None) - - # Accept either a base URL or a full URL that includes /sign-orchestrator-info. - # Normalize to an https:// origin and append the expected path. - signer_url = f"{_http_origin(signer_url)}/sign-orchestrator-info" - headers = dict(_signer_headers) if _signer_headers else None - - try: - # Some signers accept/expect POST with an empty JSON object. - data = post_json(signer_url, {}, headers=headers, timeout=5.0) - - signer = _signer_material_from_json(data, signer_url) - - except LivepeerGatewayError as e: - if isinstance(e, RemoteSignerError): - raise - - # post_json wraps the underlying exception as __cause__; convert back into - # a signer-specific error message. - cause = e.__cause__ or e - - if isinstance(cause, HTTPError): - body = _extract_error_message(cause) - body_part = f"; body={body!r}" if body else "" - raise RemoteSignerError( - signer_url, - f"HTTP {cause.code} from signer{body_part}", - cause=cause, - ) from None - - if isinstance(cause, ConnectionRefusedError): - raise RemoteSignerError( - signer_url, - "connection refused (is the signer running? is the host/port correct?)", - cause=cause, - ) from None - - if isinstance(cause, URLError): - raise RemoteSignerError( - signer_url, - f"failed to reach signer: {getattr(cause, 'reason', cause)}", - cause=cause, - ) from None - - if isinstance(cause, json.JSONDecodeError): - raise RemoteSignerError( - signer_url, - f"signer did not return valid JSON: {cause}", - cause=cause, - ) from None - - raise RemoteSignerError( - signer_url, - f"unexpected error: {cause.__class__.__name__}: {cause}", - cause=cause if isinstance(cause, BaseException) else e, - ) from None - - return signer - - @async_lru_cache(maxsize=128) async def get_signer_info( signer_url: str, # frozenset instead of dict because cache keys require hashable arguments. _signer_headers: frozenset[tuple[str, str]] | None = None, ) -> SignerMaterial: - """ - Async-native version of get_orch_info_sig for callers that should not block - the event loop or use gRPC. - """ + """Fetch and cache the signer identity used for Live Runner payments.""" from .http import _http_origin, post_json if not signer_url: @@ -362,163 +260,3 @@ async def _refresh_payment_params(self) -> None: self._challenge, payment_params=payment_params, ) - - -class PaymentSession: - def __init__( - self, - signer_url: Optional[str], - info: lp_rpc_pb2.OrchestratorInfo, - *, - signer_headers: Optional[dict[str, str]] = None, - type: str, - app: str | None = None, - capabilities: Optional[lp_rpc_pb2.Capabilities] = None, - use_tofu: bool = True, - max_refresh_retries: int = 3, - ) -> None: - self._signer_url = signer_url - self._signer_headers = signer_headers - self._info = info - self._type = type - self._app = app - self._manifest_id: Optional[str] = None - self._capabilities = capabilities - self._use_tofu = use_tofu - self._max_refresh_retries = max(0, int(max_refresh_retries)) - self._state: Optional[dict[str, str]] = None - - def set_manifest_id(self, manifest_id: str) -> None: - if not isinstance(manifest_id, str) or not manifest_id.strip(): - raise PaymentError("manifest_id must be a non-empty string") - self._manifest_id = manifest_id.strip() - - def get_payment(self) -> GetPaymentResponse: - """ - Generate a payment via the remote signer. - - Handles signer state round-tripping internally. - On HTTP 480, refreshes OrchestratorInfo and retries - (up to max_refresh_retries). - Returns payment + seg_creds for use as HTTP headers. - """ - - # Offchain mode: still send the expected headers, but with empty content. - if not self._signer_url: - seg = lp_rpc_pb2.SegData() - if not self._info.HasField("auth_token"): - raise PaymentError( - "Orchestrator did not provide an auth token." - ) - seg.auth_token.CopyFrom(self._info.auth_token) - seg = base64.b64encode(seg.SerializeToString()).decode("ascii") - return GetPaymentResponse(seg_creds=seg, payment="") - - def _payment_request() -> GetPaymentResponse: - from .http import _http_origin, post_json_sync as post_json - - base = _http_origin(self._signer_url) - url = f"{base}/generate-live-payment" - - pb = self._info.SerializeToString() - orch_b64 = base64.b64encode(pb).decode("ascii") - payload: dict[str, Any] = { - "orchestrator": orch_b64, - "type": self._type, - } - if self._app: - payload["app"] = self._app - if self._capabilities is not None: - payload["capabilities"] = base64.b64encode( - self._capabilities.SerializeToString() - ).decode("ascii") - if self._manifest_id is not None: - payload["ManifestID"] = self._manifest_id - if self._state is not None: - payload["state"] = self._state - - data = post_json(url, payload, headers=self._signer_headers) - payment = data.get("payment") - if not isinstance(payment, str) or not payment: - raise PaymentError( - f"GetPayment error: missing/invalid 'payment' in response (url={url})" - ) - - seg_creds = data.get("segCreds") - if seg_creds is not None and not isinstance(seg_creds, str): - raise PaymentError( - f"GetPayment error: invalid 'segCreds' in response (url={url})" - ) - - state = data.get("state") - if not isinstance(state, dict): - raise PaymentError( - f"Remote signer response missing 'state' object (url={url})" - ) - - self._state = state - return GetPaymentResponse(payment=payment, seg_creds=seg_creds) - - attempts = 0 - while True: - try: - return _payment_request() - except SignerRefreshRequired as e: - if attempts >= self._max_refresh_retries: - raise PaymentError( - f"Signer refresh required after {attempts} retries: {e}" - ) from e - if not self._info.transcoder: - raise PaymentError( - "OrchestratorInfo missing transcoder URL for refresh" - ) - from .orch_info import get_orch_info - - self._info = get_orch_info( - self._info.transcoder, - signer_url=self._signer_url, - signer_headers=self._signer_headers, - capabilities=self._capabilities, - use_tofu=self._use_tofu, - ) - attempts += 1 - - def send_payment(self) -> None: - """ - Generate a payment (via get_payment) and forward it - to the orchestrator via POST {orch}/payment. - """ - from .http import _extract_error_message, _http_origin - - p = self.get_payment() - if not self._info.transcoder: - raise PaymentError("OrchestratorInfo missing transcoder URL for payment") - base = _http_origin(self._info.transcoder) - url = f"{base}/payment" - headers = { - "Livepeer-Payment": p.payment, - "Livepeer-Segment": p.seg_creds or "", - } - req = Request(url, data=b"", headers=headers, method="POST") - ssl_ctx = ssl._create_unverified_context() - try: - with urlopen(req, timeout=5.0, context=ssl_ctx) as resp: - resp.read() - except HTTPError as e: - body = _extract_error_message(e) - body_part = f"; body={body!r}" if body else "" - raise PaymentError( - f"HTTP payment error: HTTP {e.code} from endpoint (url={url}){body_part}" - ) from e - except ConnectionRefusedError as e: - raise PaymentError( - f"HTTP payment error: connection refused (is the server running? is the host/port correct?) (url={url})" - ) from e - except URLError as e: - raise PaymentError( - f"HTTP payment error: failed to reach endpoint: {getattr(e, 'reason', e)} (url={url})" - ) from e - except Exception as e: - raise PaymentError( - f"HTTP payment error: unexpected error: {e.__class__.__name__}: {e} (url={url})" - ) from e diff --git a/src/livepeer_gateway/scope.py b/src/livepeer_gateway/scope.py deleted file mode 100644 index 76cd979..0000000 --- a/src/livepeer_gateway/scope.py +++ /dev/null @@ -1,143 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Any, Optional, Sequence - -from .errors import LivepeerGatewayError, NoRunnerAvailableError, RunnerRejection -from .http import post_json -from .lv2v import LiveVideoToVideo, StartJobRequest -from .selection import runner_selector -from .token import parse_token - -_SCOPE_RUNNER_APP = "live-video-to-video/scope" -_LOG = logging.getLogger(__name__) - - -async def start_scope( - orch_url: Optional[Sequence[str] | str], - req: StartJobRequest, - *, - token: Optional[str] = None, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - discovery_url: Optional[str] = None, - discovery_headers: Optional[dict[str, str]] = None, - timeout: float = 5.0, -) -> LiveVideoToVideo: - """ - Start a Scope job through a live runner. - - Scope is treated as a single-shot live runner app. The request body is sent - to a discovered ``live-video-to-video/scope`` runner and any paid runner - challenge is handled by the live-runner payment flow. - - Optional ``token`` can be provided as a base64-encoded JSON object. - Token values take precedence over explicit keyword arguments. - Explicit keyword arguments are used only for fields missing in the token. - - Runner discovery precedence (highest -> lowest): - 1) token ``orchestrators`` value, converted by appending ``/discovery`` - 2) explicit ``orch_url`` value, converted by appending ``/discovery`` - 3) token ``discovery`` value - 4) explicit ``discovery_url`` argument - 5) remote signer discovery endpoint derived from the resolved signer URL - - """ - token_data: Optional[dict[str, Any]] = None - if token is not None: - token_data = parse_token(token) - - resolved_orch_url = token_data.get("orchestrators") if token_data else None - if resolved_orch_url is None: - resolved_orch_url = orch_url - - resolved_signer_url = token_data.get("signer") if token_data else None - if resolved_signer_url is None: - resolved_signer_url = signer_url - - resolved_signer_headers = token_data.get("signer_headers") if token_data else None - if resolved_signer_headers is None: - resolved_signer_headers = signer_headers - - resolved_discovery_url = token_data.get("discovery") if token_data else None - if resolved_discovery_url is None: - resolved_discovery_url = discovery_url - - resolved_discovery_headers = token_data.get("discovery_headers") if token_data else None - if resolved_discovery_headers is None: - resolved_discovery_headers = discovery_headers - - body = req.to_json() - return await _start_scope_with_runner( - body=body, - signer_url=resolved_signer_url, - signer_headers=resolved_signer_headers, - discovery_url=resolved_discovery_url, - discovery_headers=resolved_discovery_headers, - orch_url=resolved_orch_url, - timeout=timeout, - ) - - -async def _start_scope_with_runner( - *, - body: dict[str, Any], - signer_url: str | None, - signer_headers: dict[str, str] | None, - discovery_url: str | None, - discovery_headers: dict[str, str] | None, - orch_url: Sequence[str] | str | None, - timeout: float, -): - cursor = await runner_selector( - body=body, - signer_url=signer_url, - signer_headers=signer_headers, - orchestrators=orch_url, - discovery_url=discovery_url, - discovery_headers=discovery_headers, - app=_SCOPE_RUNNER_APP, - timeout=timeout, - ) - - while True: - try: - result = await cursor.next() - except NoRunnerAvailableError as e: - for rejection in e.rejections: - _LOG.info("scope runner rejected: %s: %s", rejection.url, rejection.reason) - raise - - try: - data = result.data - if not _is_serverless_runner(result.runner): - app_url = data.get("app_url") - if not isinstance(app_url, str) or not app_url.strip(): - raise LivepeerGatewayError("Scope runner response missing app_url") - data = await post_json( - f"{app_url.strip().rstrip('/')}/scope", - body, - timeout=timeout, - ) - - job = LiveVideoToVideo.from_json( - data, - signer_url=signer_url, - payment_session=result.payment_session, - ) - if not job.manifest_id: - raise LivepeerGatewayError("Scope response missing manifest_id") - return job - except Exception as e: - reason = str(e) - runner_url = result.runner_url.strip() - if not runner_url and result.runner is not None: - runner_url = result.runner.url - _LOG.debug("scope runner startup failed: %s (%s)", runner_url, reason) - cursor.rejections.append(RunnerRejection(url=runner_url, reason=reason)) - - -def _is_serverless_runner(runner: object) -> bool: - raw = getattr(runner, "raw", None) - version = raw.get("version") if isinstance(raw, dict) else None - return isinstance(version, str) and version.startswith("serverless") diff --git a/src/livepeer_gateway/selection.py b/src/livepeer_gateway/selection.py index 9a5d47e..588c8ec 100644 --- a/src/livepeer_gateway/selection.py +++ b/src/livepeer_gateway/selection.py @@ -1,22 +1,17 @@ from __future__ import annotations import logging -from concurrent.futures import ThreadPoolExecutor, as_completed from collections.abc import Sequence -from typing import Any, Optional +from typing import Any -from . import lp_rpc_pb2 from .discovery import ( FilterValue, discover_orchestrator_runners, - discover_orchestrators, discover_runners, ) from .errors import ( LivepeerGatewayError, - NoOrchestratorAvailableError, NoRunnerAvailableError, - OrchestratorRejection, RunnerRejection, ) from .live_runner import ( @@ -26,135 +21,9 @@ _live_runner_price_info_from_json, call_runner, ) -from .orch_info import get_orch_info _LOG = logging.getLogger(__name__) -_BATCH_SIZE = 5 - - -class SelectionCursor: - """ - Stateful selector that advances through orchestrators in batches. - - For each batch, all ``get_orch_info`` calls run in parallel and successful - responses are cached in arrival order. Consumers pop cached successes one at - a time and only trigger the next batch once the current cache is exhausted. - """ - - def __init__( - self, - orch_list: Sequence[str], - *, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - capabilities: Optional[lp_rpc_pb2.Capabilities] = None, - use_tofu: bool = True, - ) -> None: - self._orch_list = list(orch_list) - self._signer_url = signer_url - self._signer_headers = signer_headers - self._capabilities = capabilities - self._use_tofu = use_tofu - self._batch_start = 0 - self._pending_successes: list[tuple[str, lp_rpc_pb2.OrchestratorInfo]] = [] - self.rejections: list[OrchestratorRejection] = [] - - def next(self) -> tuple[str, lp_rpc_pb2.OrchestratorInfo]: - while True: - if self._pending_successes: - selected = self._pending_successes.pop(0) - _LOG.debug("select_orchestrator selected: %s", selected[0]) - return selected - - if self._batch_start >= len(self._orch_list): - _LOG.debug( - "select_orchestrator failed: all %d orchestrators rejected", - len(self._orch_list), - ) - raise NoOrchestratorAvailableError( - f"All orchestrators failed ({len(self.rejections)} tried)", - rejections=list(self.rejections), - ) - - self._populate_next_batch_successes() - - def _populate_next_batch_successes(self) -> None: - batch = self._orch_list[self._batch_start : self._batch_start + _BATCH_SIZE] - batch_start = self._batch_start - self._batch_start += _BATCH_SIZE - _LOG.debug( - "select_orchestrator trying batch %d-%d: %s", - batch_start, - batch_start + len(batch) - 1, - batch, - ) - - with ThreadPoolExecutor(max_workers=len(batch)) as executor: - futures = { - executor.submit( - get_orch_info, - url, - signer_url=self._signer_url, - signer_headers=self._signer_headers, - capabilities=self._capabilities, - use_tofu=self._use_tofu, - ): url - for url in batch - } - - batch_successes: list[tuple[str, lp_rpc_pb2.OrchestratorInfo]] = [] - for future in as_completed(futures): - url = futures[future] - try: - info = future.result() - except Exception as e: - reason = str(e) - _LOG.debug( - "select_orchestrator candidate failed: %s (%s)", - url, - reason, - ) - self.rejections.append(OrchestratorRejection(url=url, reason=reason)) - continue - batch_successes.append((url, info)) - - self._pending_successes.extend(batch_successes) - if not batch_successes: - _LOG.debug("select_orchestrator batch exhausted, trying next batch") - - -def orchestrator_selector( - orchestrators: Optional[Sequence[str] | str] = None, - *, - signer_url: Optional[str] = None, - signer_headers: Optional[dict[str, str]] = None, - discovery_url: Optional[str] = None, - discovery_headers: Optional[dict[str, str]] = None, - capabilities: Optional[lp_rpc_pb2.Capabilities] = None, - use_tofu: bool = True, -) -> SelectionCursor: - orch_list = discover_orchestrators( - orchestrators, - signer_url=signer_url, - signer_headers=signer_headers, - discovery_url=discovery_url, - discovery_headers=discovery_headers, - capabilities=capabilities, - ) - - if not orch_list: - _LOG.debug("select_orchestrator failed: empty orchestrator list") - raise NoOrchestratorAvailableError("No orchestrators available to select") - - return SelectionCursor( - orch_list, - signer_url=signer_url, - signer_headers=signer_headers, - capabilities=capabilities, - use_tofu=use_tofu, - ) - class RunnerSelectionCursor: """ diff --git a/tests/test_control_keepalive.py b/tests/test_control_keepalive.py index 83eb6a6..8d9d429 100644 --- a/tests/test_control_keepalive.py +++ b/tests/test_control_keepalive.py @@ -1,13 +1,9 @@ from __future__ import annotations import asyncio -from types import SimpleNamespace from unittest import mock -import pytest - from livepeer_gateway import control as control_mod -from livepeer_gateway import lv2v as lv2v_mod from livepeer_gateway.trickle_publisher import ( TricklePublisherTerminalError, TrickleSegmentWriteError, @@ -32,27 +28,6 @@ async def __call__(self, delay: float) -> None: await self.permits.get() -class _Cursor: - def next(self): - return ("http://orch", SimpleNamespace(transcoder="http://orch")) - - -class _Payment: - payment = "payment-token" - seg_creds = "segment-token" - - -class _PaymentSession: - def __init__(self, *_args: object, **_kwargs: object) -> None: - self.manifest_id: str | None = None - - def get_payment(self) -> _Payment: - return _Payment() - - def set_manifest_id(self, manifest_id: str) -> None: - self.manifest_id = manifest_id - - class TestControlKeepalive: async def test_start_keepalive_sends_periodic_message(self) -> None: publisher = _FakePublisher() @@ -138,117 +113,3 @@ async def test_keepalive_stops_on_terminal_error(self) -> None: assert task.done() assert publisher.closed - - -class TestStartLv2VKeepaliveWiring: - def _start_with_job( - self, - *, - control: object, - start_payments: bool = True, - ) -> tuple[object, object]: - job = SimpleNamespace( - manifest_id="manifest", - control=control, - start_payment_sender=mock.Mock(), - ) - with ( - mock.patch.object( - lv2v_mod, - "build_capabilities", - return_value=object(), - ), - mock.patch.object( - lv2v_mod, - "orchestrator_selector", - return_value=_Cursor(), - ), - mock.patch.object(lv2v_mod, "PaymentSession", _PaymentSession), - mock.patch.object( - lv2v_mod, - "post_json_sync", - return_value={"manifest_id": "manifest"}, - ), - mock.patch.object( - lv2v_mod.LiveVideoToVideo, - "from_json", - return_value=job, - ), - ): - result = lv2v_mod.start_lv2v( - "http://orch", - lv2v_mod.StartJobRequest(model_id="noop"), - start_payments=start_payments, - ) - return result, job - - def test_start_lv2v_starts_control_keepalive_when_control_present(self) -> None: - control = mock.Mock() - result, job = self._start_with_job(control=control) - - assert result is job - job.start_payment_sender.assert_called_once_with() - control.start_keepalive.assert_called_once_with() - - def test_start_lv2v_can_skip_payment_loop_start(self) -> None: - control = mock.Mock() - result, job = self._start_with_job( - control=control, - start_payments=False, - ) - - assert result is job - job.start_payment_sender.assert_not_called() - control.start_keepalive.assert_called_once_with() - - def test_start_lv2v_skips_keepalive_when_control_missing(self) -> None: - result, job = self._start_with_job(control=None) - - assert result is job - job.start_payment_sender.assert_called_once_with() - - @pytest.mark.parametrize( - "mode", - [control_mod.ControlMode.DISABLED, control_mod.ControlMode.TIME], - ) - def test_start_lv2v_skips_keepalive_for_disabled_and_time_modes( - self, mode: control_mod.ControlMode - ) -> None: - with ( - mock.patch.object( - control_mod.Control, - "start_keepalive", - autospec=True, - ) as start_keepalive, - mock.patch.object( - lv2v_mod, - "build_capabilities", - return_value=object(), - ), - mock.patch.object( - lv2v_mod, - "orchestrator_selector", - return_value=_Cursor(), - ), - mock.patch.object( - lv2v_mod, - "PaymentSession", - _PaymentSession, - ), - mock.patch.object( - lv2v_mod, - "post_json_sync", - return_value={ - "manifest_id": "manifest", - "control_url": "http://orch/ai/trickle/manifest-control", - }, - ), - ): - result = lv2v_mod.start_lv2v( - "http://orch", - lv2v_mod.StartJobRequest(model_id="noop"), - control_config=control_mod.ControlConfig(mode=mode), - ) - - assert result.control is not None - start_keepalive.assert_not_called() diff --git a/tests/test_echo_example.py b/tests/test_echo_example.py new file mode 100644 index 0000000..291a7b7 --- /dev/null +++ b/tests/test_echo_example.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from examples.echo import client +from livepeer_gateway.media_output import MediaOutput + + +@pytest.fixture +async def media_run(monkeypatch): + names = ("publish", "display", "blur", "in", "out") + release = {name: asyncio.Event() for name in names} + started = {name: asyncio.Event() for name in names} + stopped = {name: asyncio.Event() for name in names} + tasks = {} + resources = [] + received = [] + run = SimpleNamespace( + release=release, + stopped=stopped, + tasks=tasks, + resources=resources, + received=received, + consumer_error=None, + ) + + async def wait(name): + tasks[name] = asyncio.current_task() + started[name].set() + try: + await release[name].wait() + finally: + stopped[name].set() + + def make_output(url, **kwargs): + async def frames(): + await wait(url) + yield SimpleNamespace(kind="video", frame=url, pts_time=1.0) + + stream = MediaOutput(url, **kwargs) + stream.frames = frames + segment = SimpleNamespace(close=AsyncMock()) + subscriber = SimpleNamespace(close=AsyncMock(), get_stats=lambda: None) + stream._segments = [segment] + stream._sub = subscriber + resources.extend([segment, subscriber]) + return stream + + def update(name, frame, pts_time): + if name == "output" and run.consumer_error is not None: + raise run.consumer_error + received.append(name) + + async def publish(*args, **kwargs): + await wait("publish") + + async def blur(*args): + await wait("blur") + + monkeypatch.setattr(client, "MediaOutput", make_output) + monkeypatch.setattr(client, "_publish_video", publish) + monkeypatch.setattr(client, "_sweep_blur", blur) + viewer = SimpleNamespace(run=lambda: wait("display"), update=update) + run.task = asyncio.create_task( + client._run_media( + SimpleNamespace(blur=True), "in", "out", "app", viewer, None, False + ) + ) + try: + async with asyncio.timeout(5): + await asyncio.gather(*(event.wait() for event in started.values())) + yield run + finally: + for event in release.values(): + event.set() + run.task.cancel() + await asyncio.gather(run.task, return_exceptions=True) + # Clean up even if an assertion exposes a leak in the client. + for task in tasks.values(): + task.cancel() + await asyncio.gather(*tasks.values(), return_exceptions=True) + + +def assert_cleaned_up(run): + assert all(task.done() for task in run.tasks.values()) + for resource in run.resources: + resource.close.assert_awaited_once() + + +async def test_eof_drains_both_outputs_before_closing_display(media_run): + run = media_run + async with asyncio.timeout(5): + run.release["publish"].set() + await run.stopped["blur"].wait() + assert not run.task.done() + assert not run.stopped["display"].is_set() + + run.release["out"].set() + await run.tasks["out"] + assert not run.task.done() + assert not run.stopped["display"].is_set() + + run.release["in"].set() + await run.task + + assert run.received == ["output", "loopback"] + assert_cleaned_up(run) + + +@pytest.mark.parametrize("after_eof", [False, True], ids=["publishing", "draining"]) +async def test_consumer_failure_propagates_after_cleanup(media_run, after_eof): + run = media_run + error = OSError("output consumer failed") + run.consumer_error = error + async with asyncio.timeout(5): + if after_eof: + run.release["publish"].set() + await run.stopped["blur"].wait() + run.release["in"].set() + run.release["out"].set() + with pytest.raises(OSError) as raised: + await run.task + + assert raised.value is error + assert_cleaned_up(run) + if not after_eof: + assert run.tasks["publish"].cancelled() + assert run.tasks["in"].cancelled() + + +async def test_cancellation_stops_publishing_consumers_and_display(media_run): + run = media_run + run.task.cancel() + async with asyncio.timeout(5): + with pytest.raises(asyncio.CancelledError): + await run.task + + assert all(task.cancelled() for task in run.tasks.values()) + assert_cleaned_up(run) diff --git a/tests/test_errors.py b/tests/test_errors.py deleted file mode 100644 index da34da7..0000000 --- a/tests/test_errors.py +++ /dev/null @@ -1,30 +0,0 @@ -from livepeer_gateway.errors import ( - NoOrchestratorAvailableError, - OrchestratorRejection, -) - - -def test_no_orchestrator_available_error_without_rejections() -> None: - error = NoOrchestratorAvailableError("No orchestrators available to select") - - assert str(error) == "No orchestrators available to select" - - -def test_no_orchestrator_available_error_includes_rejections() -> None: - error = NoOrchestratorAvailableError( - "All orchestrators failed (2 tried)", - rejections=[ - OrchestratorRejection( - url="https://orch-a.example.com", reason="connection refused" - ), - OrchestratorRejection( - url="https://orch-b.example.com", reason="request timed out" - ), - ], - ) - - assert str(error) == ( - "All orchestrators failed (2 tried): " - "https://orch-a.example.com: connection refused; " - "https://orch-b.example.com: request timed out" - ) diff --git a/tests/test_live_payment_session.py b/tests/test_live_payment_session.py index 35afbb5..e96af45 100644 --- a/tests/test_live_payment_session.py +++ b/tests/test_live_payment_session.py @@ -5,7 +5,6 @@ import pytest -from livepeer_gateway import lp_rpc_pb2 from livepeer_gateway.errors import ( LivepeerHTTPError, SignerRefreshRequired, @@ -13,7 +12,6 @@ from livepeer_gateway.remote_signer import ( LivePaymentChallenge, LivePaymentSession, - PaymentSession, get_signer_info, ) @@ -29,70 +27,6 @@ def _challenge(*, payment_params: str = "opaque") -> LivePaymentChallenge: ) -class TestPaymentSession: - def test_get_payment_round_trips_state_without_cross_session_leak(self) -> None: - calls: list[tuple[str, dict[str, object], dict[str, str] | None]] = [] - request_counts: dict[str, int] = {} - - def _post_json( - url: str, - payload: dict[str, object], - *, - headers: dict[str, str] | None = None, - timeout: float = 5.0, - ) -> dict[str, object]: - del timeout - calls.append((url, dict(payload), headers)) - manifest_id = payload["ManifestID"] - assert isinstance(manifest_id, str) - request_counts[manifest_id] = request_counts.get(manifest_id, 0) + 1 - sequence = request_counts[manifest_id] - return { - "payment": f"payment-{manifest_id}-{sequence}", - "segCreds": f"segment-{manifest_id}-{sequence}", - "state": {"session": manifest_id, "sequence": str(sequence)}, - } - - info = lp_rpc_pb2.OrchestratorInfo(transcoder="https://orch.example.com") - first_session = PaymentSession( - "https://signer.example.com", - info, - signer_headers={"Authorization": "token"}, - type="lv2v", - app="live-video-to-video/scope", - ) - first_session.set_manifest_id("first") - second_session = PaymentSession( - "https://signer.example.com", - info, - signer_headers={"Authorization": "token"}, - type="lv2v", - ) - second_session.set_manifest_id("second") - - with mock.patch( - "livepeer_gateway.http.post_json_sync", side_effect=_post_json - ): - first_payment = first_session.get_payment() - second_payment = second_session.get_payment() - first_session.get_payment() - second_session.get_payment() - - assert first_payment.payment == "payment-first-1" - assert second_payment.seg_creds == "segment-second-1" - assert [call[0] for call in calls] == [ - "https://signer.example.com/generate-live-payment" - ] * 4 - assert all(call[2] == {"Authorization": "token"} for call in calls) - assert calls[0][1]["app"] == "live-video-to-video/scope" - assert "app" not in calls[1][1] - assert "state" not in calls[0][1] - assert "state" not in calls[1][1] - assert calls[2][1]["app"] == "live-video-to-video/scope" - assert calls[2][1]["state"] == {"session": "first", "sequence": "1"} - assert calls[3][1]["state"] == {"session": "second", "sequence": "1"} - - class TestLivePaymentSession: @pytest.fixture(autouse=True) def clear_signer_info_cache(self): @@ -102,7 +36,7 @@ def clear_signer_info_cache(self): async def test_none_signer_exits_early(self) -> None: session = LivePaymentSession( None, - type="lv2v", + type="live", challenge=_challenge(), ) @@ -115,7 +49,7 @@ async def test_none_signer_exits_early(self) -> None: async def test_send_payment_reuses_empty_post_helper(self) -> None: session = LivePaymentSession( "https://signer.example.com", - type="lv2v", + type="live", challenge=_challenge(), ) @@ -141,7 +75,7 @@ async def test_send_payment_reuses_empty_post_helper(self) -> None: async def test_send_payment_preserves_typed_http_error(self) -> None: session = LivePaymentSession( "https://signer.example.com", - type="lv2v", + type="live", challenge=_challenge(), ) @@ -191,13 +125,13 @@ async def _post_json( session = LivePaymentSession( "https://signer.example.com", signer_headers={"Authorization": "token"}, - type="lv2v", + type="live", challenge=_challenge(payment_params="opaque-payment-params"), - app="live-video-to-video/scope", + app="livepeer/app", max_price={ "price": 10.12, "currency": "wei", - "unit": "720p-pixel-seconds", + "unit": "seconds", }, ) first = await session.get_payment() @@ -208,17 +142,17 @@ async def _post_json( assert calls[0][0] == "https://signer.example.com/generate-live-payment" assert calls[0][1] == { "orchestrator": "opaque-payment-params", - "type": "lv2v", + "type": "live", "ManifestID": "manifest-1", - "app": "live-video-to-video/scope", + "app": "livepeer/app", "maxPrice": { "price": 10.12, "currency": "wei", - "unit": "720p-pixel-seconds", + "unit": "seconds", }, } assert calls[0][2] == {"Authorization": "token"} - assert calls[1][1]["app"] == "live-video-to-video/scope" + assert calls[1][1]["app"] == "livepeer/app" assert calls[1][1]["maxPrice"] == calls[0][1]["maxPrice"] assert calls[1][1]["state"] == {"state": "one"} @@ -234,15 +168,12 @@ async def _post_json( ) -> dict[str, object]: del headers, timeout calls.append((url, payload)) - raise SignerRefreshRequired( - "refresh", - orchestrator_url="https://orch.example.com", - ) + raise SignerRefreshRequired("refresh") with mock.patch("livepeer_gateway.http.post_json", side_effect=_post_json): session = LivePaymentSession( "https://signer.example.com", - type="lv2v", + type="live", challenge=_challenge(payment_params="old-payment-params"), ) with pytest.raises(SignerRefreshRequired): @@ -253,7 +184,7 @@ async def _post_json( "https://signer.example.com/generate-live-payment", { "orchestrator": "old-payment-params", - "type": "lv2v", + "type": "live", "ManifestID": "manifest-1", }, ) @@ -304,12 +235,12 @@ async def _post_json( with mock.patch("livepeer_gateway.http.post_json", side_effect=_post_json): session = LivePaymentSession( "https://signer.example.com", - type="lv2v", + type="live", challenge=_challenge(payment_params="old-payment-params"), max_price={ "price": 10.12, "currency": "wei", - "unit": "720p-pixel-seconds", + "unit": "seconds", }, ) first_payment = await session.get_payment() @@ -326,7 +257,7 @@ async def _post_json( { "price": 10.12, "currency": "wei", - "unit": "720p-pixel-seconds", + "unit": "seconds", } ] * 3 assert calls[1][1]["state"] == {"state": "one"} diff --git a/tests/test_live_runner.py b/tests/test_live_runner.py index 3cd2033..3d542fd 100644 --- a/tests/test_live_runner.py +++ b/tests/test_live_runner.py @@ -88,8 +88,6 @@ def test_parse_go_duration(self) -> None: [ ("hour", "live"), ("seconds", "live"), - ("720p", "lv2v"), - ("720p-pixel-seconds", "lv2v"), ("fixed", "fixed"), ], ) @@ -105,20 +103,28 @@ def test_runner_payment_type_uses_explicit_and_discovered_units( mode="single-shot", orchestrator_url="https://service.example.com", raw={}, - price_info=LiveRunnerPriceInfo(10, "usd", "fixed"), + price_info=LiveRunnerPriceInfo(10, "usd", f" {unit.upper()} "), ) - assert live_runner._runner_payment_type(runner, " FIXED ") == "fixed" + assert live_runner._runner_payment_type(runner) == expected + assert live_runner._runner_payment_type(runner, unit) == expected with pytest.raises( LivepeerGatewayError, match="payment_unit conflicts with runner price metadata", ): - live_runner._runner_payment_type(runner, "hour") + live_runner._runner_payment_type( + runner, "hour" if unit == "fixed" else "fixed" + ) + + def test_runner_payment_type_defaults_to_live(self) -> None: + assert live_runner._runner_payment_type(None) == "live" + with pytest.raises(LivepeerGatewayError): + live_runner._runner_payment_type(None, "bogus") @pytest.mark.parametrize( ("price", "unit", "expected_price"), [ (100, "seconds", 101.2), - ("2.5", "720p-pixel-seconds", 2.53), + ("2.5", "seconds", 2.53), (250, "fixed", 253.0), ], ) @@ -376,76 +382,6 @@ def _request_body( } assert sig_mock.call_count == 1 - async def test_paid_scope_runner_uses_lv2v_payment_type(self) -> None: - sessions: list[dict[str, object]] = [] - runner_url = "https://service.example.com/apps/scope/session" - runner = LiveRunnerInstance( - url=runner_url, - app="live-video-to-video/scope", - runner_id="scope-runner", - mode="single-shot", - orchestrator_url="https://service.example.com", - raw={}, - price_info=LiveRunnerPriceInfo( - "2.5", "wei", "720p-pixel-seconds" - ), - ) - - class _PaymentSession: - def __init__(self, signer_url: str, **kwargs: object) -> None: - sessions.append({"signer_url": signer_url, **kwargs}) - - async def get_payment(self) -> object: - return SimpleNamespace(payment="payment-b64", seg_creds="seg-b64") - - def _request_body( - url: str, - *, - method: str | None = None, - payload: dict[str, object] | None = None, - headers: dict[str, str] | None = None, - timeout: float, - ) -> tuple[bytes, str]: - del method, payload, timeout - if headers and "Livepeer-Payment" in headers: - return _json_data({"session_id": "session-1"}) - raise LivepeerHTTPError( - 402, url, _payment_challenge_body("manifest-scope"), "payment required" - ) - - with ( - mock.patch.object(live_runner, "_request_body", side_effect=_request_body), - mock.patch.object(live_runner, "LivePaymentSession", _PaymentSession), - mock.patch.object( - live_runner, - "get_signer_info", - new_callable=mock.AsyncMock, - return_value=SimpleNamespace( - address="opaque-payer", sig="opaque-signature" - ), - ), - ): - result = await call_runner( - runner=runner, - signer_url="https://signer.example.com", - ) - - assert result.session_id == "manifest-scope" - assert sessions == [ - { - "signer_url": "https://signer.example.com", - "signer_headers": None, - "type": "lv2v", - "app": "live-video-to-video/scope", - "challenge": _payment_challenge("manifest-scope"), - "max_price": { - "price": 2.53, - "currency": "wei", - "unit": "720p-pixel-seconds", - }, - } - ] - async def test_paid_fixed_runner_retries_with_fresh_payment_and_no_renewal_session( self, ) -> None: diff --git a/tests/test_start_scope.py b/tests/test_start_scope.py deleted file mode 100644 index cc874ea..0000000 --- a/tests/test_start_scope.py +++ /dev/null @@ -1,542 +0,0 @@ -from __future__ import annotations - -import base64 -import json -from types import SimpleNamespace -from unittest import mock - -import pytest - -from livepeer_gateway import lv2v as lv2v_mod -from livepeer_gateway import scope as scope_mod -from livepeer_gateway.errors import ( - LivepeerHTTPError, - NoRunnerAvailableError, - RunnerRejection, -) -from livepeer_gateway.live_runner import LiveRunnerCallResult, LiveRunnerInstance - - -class TestStartScopeRunner: - async def _run_start_scope( - self, - *, - req: lv2v_mod.StartJobRequest | None = None, - job_control: object | None = None, - payment_session: object | None = None, - result_data: dict[str, object] | None = None, - job_manifest_id: str | None = "manifest", - orch_url: object = None, - discovery_url: str | None = "https://discovery.example.com", - token: str | None = None, - runner_version: object = "serverless-1.0.0", - ) -> tuple[object, mock.Mock, mock.Mock]: - raw: dict[str, object] = {} - if runner_version is not None: - raw["version"] = runner_version - runner = LiveRunnerInstance( - url="https://runner.example.com/app", - app="live-video-to-video/scope", - runner_id="runner-1", - mode="", - orchestrator_url="https://orch.example.com", - raw=raw, - ) - - class _Cursor: - async def next(self) -> LiveRunnerCallResult: - return LiveRunnerCallResult( - result_data or {"manifest_id": "manifest"}, - runner_url="https://runner.example.com/app", - runner=runner, - payment_session=payment_session, - ) - - runner_selector_mock = mock.AsyncMock(return_value=_Cursor()) - job = SimpleNamespace( - manifest_id=job_manifest_id, - control=job_control, - start_payment_sender=mock.Mock(), - ) - from_json_mock = mock.Mock(return_value=job) - - with mock.patch.object(scope_mod, "runner_selector", runner_selector_mock): - with mock.patch.object( - lv2v_mod.LiveVideoToVideo, "from_json", from_json_mock - ): - start_req = req or lv2v_mod.StartJobRequest(model_id="noop") - result = await scope_mod.start_scope( - orch_url, - start_req, - discovery_url=discovery_url, - token=token, - signer_url="https://signer.example.com", - signer_headers={"Authorization": "token"}, - timeout=9.0, - ) - - return result, runner_selector_mock, from_json_mock - - async def test_start_scope_selects_scope_runner_and_parses_result(self) -> None: - payment_session = object() - control = mock.Mock() - result, runner_selector_mock, from_json_mock = await self._run_start_scope( - job_control=control, - payment_session=payment_session, - ) - - assert result is not None - runner_selector_mock.assert_called_once() - assert ( - runner_selector_mock.call_args.kwargs["app"] == "live-video-to-video/scope" - ) - assert runner_selector_mock.call_args.kwargs["body"] == {"model_id": "noop"} - assert ( - runner_selector_mock.call_args.kwargs["discovery_url"] - == "https://discovery.example.com" - ) - from_json_mock.assert_called_once_with( - {"manifest_id": "manifest"}, - signer_url="https://signer.example.com", - payment_session=payment_session, - ) - result.start_payment_sender.assert_not_called() - control.start_keepalive.assert_not_called() - - async def test_start_scope_non_serverless_posts_to_app_scope_with_helper( - self, - ) -> None: - payment_session = object() - post_json_mock = mock.AsyncMock( - return_value={"manifest_id": "manifest-from-scope"} - ) - with mock.patch.object(scope_mod, "post_json", post_json_mock): - ( - _result, - _runner_selector_mock, - from_json_mock, - ) = await self._run_start_scope( - result_data={ - "session_id": "session-1", - "app_url": "https://orch.example.com/apps/runner-1/session/session-1/app/", - }, - payment_session=payment_session, - runner_version="1.2.3", - ) - - post_json_mock.assert_awaited_once_with( - "https://orch.example.com/apps/runner-1/session/session-1/app/scope", - {"model_id": "noop"}, - timeout=9.0, - ) - from_json_mock.assert_called_once_with( - {"manifest_id": "manifest-from-scope"}, - signer_url="https://signer.example.com", - payment_session=payment_session, - ) - - @pytest.mark.parametrize("runner_version", [None, 123]) - async def test_start_scope_missing_or_non_string_runner_version_uses_app_scope( - self, runner_version: object - ) -> None: - post_json_mock = mock.AsyncMock( - return_value={"manifest_id": "manifest-from-scope"} - ) - with mock.patch.object(scope_mod, "post_json", post_json_mock): - await self._run_start_scope( - result_data={ - "session_id": "session-1", - "app_url": "https://orch.example.com/app", - }, - runner_version=runner_version, - ) - - post_json_mock.assert_awaited_once_with( - "https://orch.example.com/app/scope", - {"model_id": "noop"}, - timeout=9.0, - ) - - async def test_start_scope_non_serverless_requires_app_url(self) -> None: - runner = LiveRunnerInstance( - url="https://runner.example.com/session", - app="live-video-to-video/scope", - runner_id="runner-1", - mode="", - orchestrator_url="https://orch.example.com", - raw={"version": "1.2.3"}, - ) - - class _Cursor: - def __init__(self) -> None: - self.rejections: list[RunnerRejection] = [] - self.calls = 0 - - async def next(self) -> LiveRunnerCallResult: - self.calls += 1 - if self.calls == 1: - return LiveRunnerCallResult( - {"session_id": "session-1"}, - runner_url=runner.url, - runner=runner, - ) - raise NoRunnerAvailableError( - "All runners failed (1 tried)", - rejections=list(self.rejections), - ) - - cursor = _Cursor() - with mock.patch.object( - scope_mod, "runner_selector", mock.AsyncMock(return_value=cursor) - ): - with pytest.raises(NoRunnerAvailableError) as raised: - await scope_mod.start_scope( - None, - lv2v_mod.StartJobRequest(), - discovery_url="https://discovery.example.com", - ) - - assert len(raised.value.rejections) == 1 - assert raised.value.rejections[0].url == runner.url - assert "missing app_url" in raised.value.rejections[0].reason - - async def test_start_scope_retries_next_runner_when_app_scope_post_fails( - self, - ) -> None: - runners = [ - LiveRunnerInstance( - url="https://runner-a.example.com/session", - app="live-video-to-video/scope", - runner_id="runner-a", - mode="", - orchestrator_url="https://orch-a.example.com", - raw={"version": "1.2.3"}, - ), - LiveRunnerInstance( - url="https://runner-b.example.com/session", - app="live-video-to-video/scope", - runner_id="runner-b", - mode="", - orchestrator_url="https://orch-b.example.com", - raw={"version": "1.2.3"}, - ), - ] - - class _Cursor: - def __init__(self) -> None: - self.rejections: list[RunnerRejection] = [] - self.calls = 0 - - async def next(self) -> LiveRunnerCallResult: - if self.calls >= len(runners): - raise NoRunnerAvailableError( - f"All runners failed ({len(self.rejections)} tried)", - rejections=list(self.rejections), - ) - runner = runners[self.calls] - self.calls += 1 - return LiveRunnerCallResult( - { - "session_id": f"session-{self.calls}", - "app_url": f"https://orch-{self.calls}.example.com/app", - }, - runner_url=runner.url, - runner=runner, - payment_session=f"payment-{self.calls}", - ) - - cursor = _Cursor() - post_json_mock = mock.AsyncMock( - side_effect=[ - LivepeerHTTPError(502, "https://orch-1.example.com/app/scope", "bad"), - {"manifest_id": "manifest-from-second-runner"}, - ] - ) - job = SimpleNamespace( - manifest_id="manifest-from-second-runner", - control=None, - start_payment_sender=mock.Mock(), - ) - from_json_mock = mock.Mock(return_value=job) - - with ( - mock.patch.object( - scope_mod, "runner_selector", mock.AsyncMock(return_value=cursor) - ), - mock.patch.object(scope_mod, "post_json", post_json_mock), - mock.patch.object(lv2v_mod.LiveVideoToVideo, "from_json", from_json_mock), - ): - result = await scope_mod.start_scope( - None, - lv2v_mod.StartJobRequest(model_id="noop"), - discovery_url="https://discovery.example.com", - signer_url="https://signer.example.com", - ) - - assert result is job - assert cursor.calls == 2 - assert len(cursor.rejections) == 1 - assert cursor.rejections[0].url == "https://runner-a.example.com/session" - assert "HTTP 502" in cursor.rejections[0].reason - assert [call.args[0] for call in post_json_mock.await_args_list] == [ - "https://orch-1.example.com/app/scope", - "https://orch-2.example.com/app/scope", - ] - from_json_mock.assert_called_once_with( - {"manifest_id": "manifest-from-second-runner"}, - signer_url="https://signer.example.com", - payment_session="payment-2", - ) - - async def test_start_scope_aggregates_startup_failures(self) -> None: - runners = [ - LiveRunnerInstance( - url="https://runner-a.example.com/session", - app="live-video-to-video/scope", - runner_id="runner-a", - mode="", - orchestrator_url="https://orch-a.example.com", - raw={"version": "1.2.3"}, - ), - LiveRunnerInstance( - url="https://runner-b.example.com/session", - app="live-video-to-video/scope", - runner_id="runner-b", - mode="", - orchestrator_url="https://orch-b.example.com", - raw={"version": "serverless-1.0.0"}, - ), - ] - - class _Cursor: - def __init__(self) -> None: - self.rejections: list[RunnerRejection] = [] - self.calls = 0 - - async def next(self) -> LiveRunnerCallResult: - if self.calls >= len(runners): - raise NoRunnerAvailableError( - f"All runners failed ({len(self.rejections)} tried)", - rejections=list(self.rejections), - ) - runner = runners[self.calls] - self.calls += 1 - return LiveRunnerCallResult( - {"session_id": f"session-{self.calls}"}, - runner_url=runner.url, - runner=runner, - ) - - cursor = _Cursor() - missing_manifest_job = SimpleNamespace( - manifest_id=None, - control=None, - start_payment_sender=mock.Mock(), - ) - - with ( - mock.patch.object( - scope_mod, "runner_selector", mock.AsyncMock(return_value=cursor) - ), - mock.patch.object( - lv2v_mod.LiveVideoToVideo, - "from_json", - mock.Mock(return_value=missing_manifest_job), - ), - mock.patch.object(scope_mod._LOG, "info") as log_mock, - ): - with pytest.raises(NoRunnerAvailableError) as raised: - await scope_mod.start_scope( - None, - lv2v_mod.StartJobRequest(), - discovery_url="https://discovery.example.com", - ) - - assert [rejection.url for rejection in raised.value.rejections] == [ - "https://runner-a.example.com/session", - "https://runner-b.example.com/session", - ] - assert "missing app_url" in raised.value.rejections[0].reason - assert "missing manifest_id" in raised.value.rejections[1].reason - assert log_mock.call_count == 2 - - @pytest.mark.parametrize( - ("model_id", "expected_body"), - [ - (None, {}), - ("custom-scope", {"model_id": "custom-scope"}), - ], - ids=["missing", "explicit"], - ) - async def test_start_scope_handles_missing_and_explicit_model_id( - self, model_id: str | None, expected_body: dict[str, str] - ) -> None: - ( - _result, - runner_selector_mock, - _from_json_mock, - ) = await self._run_start_scope( - req=lv2v_mod.StartJobRequest(model_id=model_id), - job_control=mock.Mock(), - ) - - assert runner_selector_mock.call_args.kwargs["body"] == expected_body - - async def test_start_scope_derives_discovery_from_orch_url(self) -> None: - _result, runner_selector_mock, _from_json_mock = await self._run_start_scope( - orch_url="http://orch.example.com:8935/base", - discovery_url=None, - ) - - assert ( - runner_selector_mock.call_args.kwargs["orchestrators"] - == "http://orch.example.com:8935/base" - ) - assert runner_selector_mock.call_args.kwargs["discovery_url"] is None - - async def test_start_scope_passes_multiple_orch_urls_to_runner_selector( - self, - ) -> None: - class _Cursor: - async def next(self) -> LiveRunnerCallResult: - runner = LiveRunnerInstance( - url="https://runner-b.example.com/app", - app="live-video-to-video/scope", - runner_id="runner-b", - mode="", - orchestrator_url="https://orch-b.example.com", - raw={"version": "serverless-1.0.0"}, - ) - return LiveRunnerCallResult( - {"manifest_id": "manifest"}, - runner_url="https://runner-b.example.com/app", - runner=runner, - ) - - runner_selector_mock = mock.AsyncMock(return_value=_Cursor()) - job = SimpleNamespace( - manifest_id="manifest", - control=None, - start_payment_sender=mock.Mock(), - ) - - with mock.patch.object(scope_mod, "runner_selector", runner_selector_mock): - with mock.patch.object( - lv2v_mod.LiveVideoToVideo, "from_json", mock.Mock(return_value=job) - ): - await scope_mod.start_scope( - ["https://orch-a.example.com", "https://orch-b.example.com"], - lv2v_mod.StartJobRequest(), - discovery_url=None, - ) - - runner_selector_mock.assert_called_once() - assert runner_selector_mock.call_args.kwargs["orchestrators"] == [ - "https://orch-a.example.com", - "https://orch-b.example.com", - ] - assert runner_selector_mock.call_args.kwargs["discovery_url"] is None - - async def test_start_scope_uses_orch_url_before_token_discovery(self) -> None: - token = base64.b64encode( - json.dumps({"discovery": "https://token.example.com/discovery"}).encode( - "utf-8" - ) - ).decode("utf-8") - - _result, runner_selector_mock, _from_json_mock = await self._run_start_scope( - orch_url="https://orch.example.com", - discovery_url="https://explicit.example.com/discovery", - token=token, - ) - - assert ( - runner_selector_mock.call_args.kwargs["orchestrators"] - == "https://orch.example.com" - ) - assert ( - runner_selector_mock.call_args.kwargs["discovery_url"] - == "https://token.example.com/discovery" - ) - - async def test_start_scope_propagates_runner_rejections(self) -> None: - class _Cursor: - async def next(self) -> LiveRunnerCallResult: - raise NoRunnerAvailableError( - "All runners failed (1 tried)", - rejections=[ - RunnerRejection( - url="https://runner.example.com/app", reason="capacity" - ) - ], - ) - - with mock.patch.object( - scope_mod, "runner_selector", mock.AsyncMock(return_value=_Cursor()) - ): - with mock.patch.object(scope_mod._LOG, "info") as log_mock: - with pytest.raises(NoRunnerAvailableError) as raised: - await scope_mod.start_scope( - None, - lv2v_mod.StartJobRequest(), - discovery_url="https://discovery.example.com", - ) - - assert raised.value.rejections[0].reason == "capacity" - log_mock.assert_called_once_with( - "scope runner rejected: %s: %s", - "https://runner.example.com/app", - "capacity", - ) - - async def test_start_scope_rejects_missing_manifest_id(self) -> None: - runner = LiveRunnerInstance( - url="https://runner.example.com/app", - app="live-video-to-video/scope", - runner_id="runner-1", - mode="", - orchestrator_url="https://orch.example.com", - raw={"version": "serverless-1.0.0"}, - ) - - class _Cursor: - def __init__(self) -> None: - self.rejections: list[RunnerRejection] = [] - self.calls = 0 - - async def next(self) -> LiveRunnerCallResult: - self.calls += 1 - if self.calls == 1: - return LiveRunnerCallResult( - {"publish_url": "https://example.com/in"}, - runner_url=runner.url, - runner=runner, - ) - raise NoRunnerAvailableError( - "All runners failed (1 tried)", - rejections=list(self.rejections), - ) - - cursor = _Cursor() - job = SimpleNamespace( - manifest_id=None, - control=None, - start_payment_sender=mock.Mock(), - ) - with ( - mock.patch.object( - scope_mod, "runner_selector", mock.AsyncMock(return_value=cursor) - ), - mock.patch.object( - lv2v_mod.LiveVideoToVideo, "from_json", mock.Mock(return_value=job) - ), - ): - with pytest.raises(NoRunnerAvailableError) as raised: - await scope_mod.start_scope( - None, - lv2v_mod.StartJobRequest(), - discovery_url="https://discovery.example.com", - ) - - assert len(raised.value.rejections) == 1 - assert "missing manifest_id" in raised.value.rejections[0].reason diff --git a/tests/test_stats_pull.py b/tests/test_stats_pull.py index 19e57b0..dd9e3a6 100644 --- a/tests/test_stats_pull.py +++ b/tests/test_stats_pull.py @@ -14,11 +14,9 @@ trickle_publisher_mod = importlib.import_module("livepeer_gateway.trickle_publisher") trickle_subscriber_mod = importlib.import_module("livepeer_gateway.trickle_subscriber") media_decode_mod = importlib.import_module("livepeer_gateway.media_decode") -lv2v_mod = importlib.import_module("livepeer_gateway.lv2v") MediaOutput = media_output_mod.MediaOutput MediaOutputStats = media_output_mod.MediaOutputStats -LiveVideoToVideo = lv2v_mod.LiveVideoToVideo MediaPublish = media_publish_mod.MediaPublish MediaPublishConfig = media_publish_mod.MediaPublishConfig MediaPublishStats = media_publish_mod.MediaPublishStats @@ -374,20 +372,6 @@ def test_media_output_stats_include_optional_subscriber(self) -> None: assert "decoder" in payload assert "subscriber" in payload - def test_live_video_to_video_media_output_passes_callbacks_through(self) -> None: - def _on_frame(_frame) -> None: - return None - - def _on_packet(_packet) -> None: - return None - - job = LiveVideoToVideo(raw={}, subscribe_url="http://example.test/trickle") - - media_output = job.media_output(on_frame=_on_frame, on_packet=_on_packet) - - assert media_output.on_frame is _on_frame - assert media_output.on_packet is _on_packet - def test_media_output_bytes_accepts_video_mpegts_content_type(self) -> None: media_output = self._make_media_output_for_segments( _FakeSegment("video/mp2t", [b"video-bytes"]) @@ -804,7 +788,8 @@ async def _run() -> list[bytes]: assert asyncio.run(_run()) == [b"payload"] - def test_media_output_close_timeout_zero_cancels_callback_immediately(self) -> None: + @pytest.mark.parametrize("timeout", [0, 0.01]) + def test_media_output_close_timeout_cancels_callback(self, timeout: float) -> None: async def _run() -> tuple[bool, bool]: started = asyncio.Event() release = asyncio.Event() @@ -819,7 +804,7 @@ async def _on_bytes(_chunk) -> None: ) await asyncio.wait_for(started.wait(), timeout=1.0) task = media_output.callback_tasks()[0] - await media_output.close(timeout=0) + await media_output.close(timeout=timeout) return task.cancelled(), release.is_set() cancelled, released = asyncio.run(_run()) @@ -845,15 +830,20 @@ def _on_bytes(_chunk) -> None: callback_called.set() raise RuntimeError("bytes callback boom") - media_output = self._make_media_output_for_segments( - _FakeSegment("video/mp2t", [b"payload"]), - on_bytes=_on_bytes, + segment = _FakeSegment("video/mp2t", [b"payload"]) + segment.close = mock.AsyncMock() + subscriber = mock.Mock( + next=mock.AsyncMock(return_value=segment), close=mock.AsyncMock() ) + media_output = MediaOutput("http://example.test/trickle", on_bytes=_on_bytes) + media_output._sub = subscriber await asyncio.wait_for(callback_called.wait(), timeout=1.0) with pytest.raises(RuntimeError, match="bytes callback boom"): await media_output.wait_callbacks(timeout=1.0) with pytest.raises(RuntimeError, match="bytes callback boom"): await media_output.close() + segment.close.assert_awaited_once() + subscriber.close.assert_awaited_once() asyncio.run(_run()) diff --git a/uv.lock b/uv.lock index d941737..4253fbc 100644 --- a/uv.lock +++ b/uv.lock @@ -328,90 +328,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] -[[package]] -name = "grpcio" -version = "1.76.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, - { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, - { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, - { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, - { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, - { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, - { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, - { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, - { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, - { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, - { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, - { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, -] - -[[package]] -name = "grpcio-tools" -version = "1.76.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "grpcio" }, - { name = "protobuf" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a0/77/17d60d636ccd86a0db0eccc24d02967bbc3eea86b9db7324b04507ebaa40/grpcio_tools-1.76.0.tar.gz", hash = "sha256:ce80169b5e6adf3e8302f3ebb6cb0c3a9f08089133abca4b76ad67f751f5ad88", size = 5390807, upload-time = "2025-10-21T16:26:55.416Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/ca/a931c1439cabfe305c9afd07e233150cd0565aa062c20d1ee412ed188852/grpcio_tools-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:4ad555b8647de1ebaffb25170249f89057721ffb74f7da96834a07b4855bb46a", size = 2546852, upload-time = "2025-10-21T16:25:15.024Z" }, - { url = "https://files.pythonhosted.org/packages/4c/07/935cfbb7dccd602723482a86d43fbd992f91e9867bca0056a1e9f348473e/grpcio_tools-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:243af7c8fc7ff22a40a42eb8e0f6f66963c1920b75aae2a2ec503a9c3c8b31c1", size = 5841777, upload-time = "2025-10-21T16:25:17.425Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/8fcb5acebdccb647e0fa3f002576480459f6cf81e79692d7b3c4d6e29605/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8207b890f423142cc0025d041fb058f7286318df6a049565c27869d73534228b", size = 2594004, upload-time = "2025-10-21T16:25:19.809Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ea/64838e8113b7bfd4842b15c815a7354cb63242fdce9d6648d894b5d50897/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3dafa34c2626a6691d103877e8a145f54c34cf6530975f695b396ed2fc5c98f8", size = 2905563, upload-time = "2025-10-21T16:25:21.889Z" }, - { url = "https://files.pythonhosted.org/packages/a6/d6/53798827d821098219e58518b6db52161ce4985620850aa74ce3795da8a7/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:30f1d2dda6ece285b3d9084e94f66fa721ebdba14ae76b2bc4c581c8a166535c", size = 2656936, upload-time = "2025-10-21T16:25:24.369Z" }, - { url = "https://files.pythonhosted.org/packages/89/a3/d9c1cefc46a790eec520fe4e70e87279abb01a58b1a3b74cf93f62b824a2/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a889af059dc6dbb82d7b417aa581601316e364fe12eb54c1b8d95311ea50916d", size = 3109811, upload-time = "2025-10-21T16:25:26.711Z" }, - { url = "https://files.pythonhosted.org/packages/50/75/5997752644b73b5d59377d333a51c8a916606df077f5a487853e37dca289/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c3f2c3c44c56eb5d479ab178f0174595d0a974c37dade442f05bb73dfec02f31", size = 3658786, upload-time = "2025-10-21T16:25:28.819Z" }, - { url = "https://files.pythonhosted.org/packages/84/47/dcf8380df4bd7931ffba32fc6adc2de635b6569ca27fdec7121733797062/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:479ce02dff684046f909a487d452a83a96b4231f7c70a3b218a075d54e951f56", size = 3325144, upload-time = "2025-10-21T16:25:30.863Z" }, - { url = "https://files.pythonhosted.org/packages/04/88/ea3e5fdb874d8c2d04488e4b9d05056537fba70915593f0c283ac77df188/grpcio_tools-1.76.0-cp312-cp312-win32.whl", hash = "sha256:9ba4bb539936642a44418b38ee6c3e8823c037699e2cb282bd8a44d76a4be833", size = 993523, upload-time = "2025-10-21T16:25:32.594Z" }, - { url = "https://files.pythonhosted.org/packages/de/b1/ce7d59d147675ec191a55816be46bc47a343b5ff07279eef5817c09cc53e/grpcio_tools-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:0cd489016766b05f9ed8a6b6596004b62c57d323f49593eac84add032a6d43f7", size = 1158493, upload-time = "2025-10-21T16:25:34.5Z" }, - { url = "https://files.pythonhosted.org/packages/13/01/b16fe73f129df49811d886dc99d3813a33cf4d1c6e101252b81c895e929f/grpcio_tools-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ff48969f81858397ef33a36b326f2dbe2053a48b254593785707845db73c8f44", size = 2546312, upload-time = "2025-10-21T16:25:37.138Z" }, - { url = "https://files.pythonhosted.org/packages/25/17/2594c5feb76bb0b25bfbf91ec1075b276e1b2325e4bc7ea649a7b5dbf353/grpcio_tools-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa2f030fd0ef17926026ee8e2b700e388d3439155d145c568fa6b32693277613", size = 5839627, upload-time = "2025-10-21T16:25:40.082Z" }, - { url = "https://files.pythonhosted.org/packages/c7/c6/097b1aa26fbf72fb3cdb30138a2788529e4f10d8759de730a83f5c06726e/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bacbf3c54f88c38de8e28f8d9b97c90b76b105fb9ddef05d2c50df01b32b92af", size = 2592817, upload-time = "2025-10-21T16:25:42.301Z" }, - { url = "https://files.pythonhosted.org/packages/03/78/d1d985b48592a674509a85438c1a3d4c36304ddfc99d1b05d27233b51062/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0d4e4afe9a0e3c24fad2f1af45f98cf8700b2bfc4d790795756ba035d2ea7bdc", size = 2905186, upload-time = "2025-10-21T16:25:44.395Z" }, - { url = "https://files.pythonhosted.org/packages/b9/0e/770afbb47f0b5f594b93a7b46a95b892abda5eebe60efb511e96cee52170/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fbbd4e1fc5af98001ceef5e780e8c10921d94941c3809238081e73818ef707f1", size = 2656188, upload-time = "2025-10-21T16:25:46.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2b/017c2fcf4c5d3cf00cf7d5ce21eb88521de0d89bdcf26538ad2862ec6d07/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b05efe5a59883ab8292d596657273a60e0c3e4f5a9723c32feb9fc3a06f2f3ef", size = 3109141, upload-time = "2025-10-21T16:25:49.137Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5f/2495f88e3d50c6f2c2da2752bad4fa3a30c52ece6c9d8b0c636cd8b1430b/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:be483b90e62b7892eb71fa1fc49750bee5b2ee35b5ec99dd2b32bed4bedb5d71", size = 3657892, upload-time = "2025-10-21T16:25:52.362Z" }, - { url = "https://files.pythonhosted.org/packages/5e/1d/c4f39d31b19d9baf35d900bf3f969ce1c842f63a8560c8003ed2e5474760/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:630cd7fd3e8a63e20703a7ad816979073c2253e591b5422583c27cae2570de73", size = 3324778, upload-time = "2025-10-21T16:25:54.629Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b6/35ee3a6e4af85a93da28428f81f4b29bcb36f6986b486ad71910fcc02e25/grpcio_tools-1.76.0-cp313-cp313-win32.whl", hash = "sha256:eb2567280f9f6da5444043f0e84d8408c7a10df9ba3201026b30e40ef3814736", size = 993084, upload-time = "2025-10-21T16:25:56.52Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7a/5bd72344d86ee860e5920c9a7553cfe3bc7b1fce79f18c00ac2497f5799f/grpcio_tools-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:0071b1c0bd0f5f9d292dca4efab32c92725d418e57f9c60acdc33c0172af8b53", size = 1158151, upload-time = "2025-10-21T16:25:58.468Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c0/aa20eebe8f3553b7851643e9c88d237c3a6ca30ade646897e25dbb27be99/grpcio_tools-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:c53c5719ef2a435997755abde3826ba4087174bd432aa721d8fac781fcea79e4", size = 2546297, upload-time = "2025-10-21T16:26:01.258Z" }, - { url = "https://files.pythonhosted.org/packages/d9/98/6af702804934443c1d0d4d27d21b990d92d22ddd1b6bec6b056558cbbffa/grpcio_tools-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e3db1300d7282264639eeee7243f5de7e6a7c0283f8bf05d66c0315b7b0f0b36", size = 5839804, upload-time = "2025-10-21T16:26:05.495Z" }, - { url = "https://files.pythonhosted.org/packages/ea/8d/7725fa7b134ef8405ffe0a37c96eeb626e5af15d70e1bdac4f8f1abf842e/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b018a4b7455a7e8c16d0fdb3655a6ba6c9536da6de6c5d4f11b6bb73378165b", size = 2593922, upload-time = "2025-10-21T16:26:07.563Z" }, - { url = "https://files.pythonhosted.org/packages/de/ff/5b6b5012c79fa72f9107dc13f7226d9ce7e059ea639fd8c779e0dd284386/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ec6e4de3866e47cfde56607b1fae83ecc5aa546e06dec53de11f88063f4b5275", size = 2905327, upload-time = "2025-10-21T16:26:09.668Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/2691d369ea462cd6b6c92544122885ca01f7fa5ac75dee023e975e675858/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b8da4d828883913f1852bdd67383713ae5c11842f6c70f93f31893eab530aead", size = 2656214, upload-time = "2025-10-21T16:26:11.773Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e7/3f8856e6ec3dd492336a91572993344966f237b0e3819fbe96437b19d313/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c120c2cf4443121800e7f9bcfe2e94519fa25f3bb0b9882359dd3b252c78a7b", size = 3109889, upload-time = "2025-10-21T16:26:15.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e4/ce5248072e47db276dc7e069e93978dcde490c959788ce7cce8081d0bfdc/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8b7df5591d699cd9076065f1f15049e9c3597e0771bea51c8c97790caf5e4197", size = 3657939, upload-time = "2025-10-21T16:26:17.34Z" }, - { url = "https://files.pythonhosted.org/packages/f6/df/81ff88af93c52135e425cd5ec9fe8b186169c7d5f9e0409bdf2bbedc3919/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a25048c5f984d33e3f5b6ad7618e98736542461213ade1bd6f2fcfe8ce804e3d", size = 3324752, upload-time = "2025-10-21T16:26:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/35/3d/f6b83044afbf6522254a3b509515a00fed16a819c87731a478dbdd1d35c1/grpcio_tools-1.76.0-cp314-cp314-win32.whl", hash = "sha256:4b77ce6b6c17869858cfe14681ad09ed3a8a80e960e96035de1fd87f78158740", size = 1015578, upload-time = "2025-10-21T16:26:22.517Z" }, - { url = "https://files.pythonhosted.org/packages/95/4d/31236cddb7ffb09ba4a49f4f56d2608fec3bbb21c7a0a975d93bca7cd22e/grpcio_tools-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:2ccd2c8d041351cc29d0fc4a84529b11ee35494a700b535c1f820b642f2a72fc", size = 1190242, upload-time = "2025-10-21T16:26:25.296Z" }, -] - [[package]] name = "idna" version = "3.11" @@ -437,17 +353,12 @@ source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "av" }, - { name = "grpcio" }, - { name = "protobuf" }, ] [package.optional-dependencies] -dev = [ - { name = "grpcio-tools" }, -] examples = [ { name = "numpy" }, - { name = "opencv-python-headless" }, + { name = "opencv-python" }, ] [package.dev-dependencies] @@ -464,13 +375,10 @@ test = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.9.0" }, { name = "av", specifier = ">=11.0.0" }, - { name = "grpcio", specifier = ">=1.76.0" }, - { name = "grpcio-tools", marker = "extra == 'dev'", specifier = ">=1.76.0" }, { name = "numpy", marker = "extra == 'examples'", specifier = ">=2.2.6" }, - { name = "opencv-python-headless", marker = "extra == 'examples'", specifier = ">=4.13.0.90" }, - { name = "protobuf", specifier = ">=6.31.1" }, + { name = "opencv-python", marker = "extra == 'examples'", specifier = ">=4.13.0.90" }, ] -provides-extras = ["dev", "examples"] +provides-extras = ["examples"] [package.metadata.requires-dev] lint = [{ name = "ruff", specifier = "==0.16.1" }] @@ -641,21 +549,22 @@ wheels = [ ] [[package]] -name = "opencv-python-headless" -version = "4.13.0.92" +name = "opencv-python" +version = "5.0.0.93" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" }, - { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, - { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, - { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" }, - { url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" }, + { url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443, upload-time = "2026-07-02T05:50:25.466Z" }, + { url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755, upload-time = "2026-07-02T05:51:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064, upload-time = "2026-07-02T06:53:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711, upload-time = "2026-07-02T06:54:13.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576, upload-time = "2026-07-02T06:54:33.781Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032, upload-time = "2026-07-02T06:55:03.415Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734, upload-time = "2026-07-02T05:49:57.704Z" }, + { url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" }, ] [[package]] @@ -760,21 +669,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] -[[package]] -name = "protobuf" -version = "6.33.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/44/e49ecff446afeec9d1a66d6bbf9adc21e3c7cea7803a920ca3773379d4f6/protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4", size = 444296, upload-time = "2025-12-06T00:17:53.311Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/91/1e3a34881a88697a7354ffd177e8746e97a722e5e8db101544b47e84afb1/protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d", size = 425603, upload-time = "2025-12-06T00:17:41.114Z" }, - { url = "https://files.pythonhosted.org/packages/64/20/4d50191997e917ae13ad0a235c8b42d8c1ab9c3e6fd455ca16d416944355/protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4", size = 436930, upload-time = "2025-12-06T00:17:43.278Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ca/7e485da88ba45c920fb3f50ae78de29ab925d9e54ef0de678306abfbb497/protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43", size = 427621, upload-time = "2025-12-06T00:17:44.445Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4f/f743761e41d3b2b2566748eb76bbff2b43e14d5fcab694f494a16458b05f/protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e", size = 324460, upload-time = "2025-12-06T00:17:45.678Z" }, - { url = "https://files.pythonhosted.org/packages/b1/fa/26468d00a92824020f6f2090d827078c09c9c587e34cbfd2d0c7911221f8/protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872", size = 339168, upload-time = "2025-12-06T00:17:46.813Z" }, - { url = "https://files.pythonhosted.org/packages/56/13/333b8f421738f149d4fe5e49553bc2a2ab75235486259f689b4b91f96cec/protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f", size = 323270, upload-time = "2025-12-06T00:17:48.253Z" }, - { url = "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", size = 170501, upload-time = "2025-12-06T00:17:52.211Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -852,15 +746,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, ] -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0"