|
18 | 18 |
|
19 | 19 | import argparse |
20 | 20 | import asyncio |
21 | | -import json |
22 | 21 | import logging |
23 | | -from contextlib import suppress |
| 22 | +from contextlib import asynccontextmanager, suppress |
24 | 23 | from dataclasses import dataclass |
25 | | -from typing import Any |
| 24 | +from typing import Any, Literal |
26 | 25 |
|
27 | 26 | import av |
28 | 27 | import numpy as np |
29 | | -from aiohttp import web |
| 28 | +import uvicorn |
| 29 | +from fastapi import FastAPI, HTTPException, Request |
| 30 | +from pydantic import BaseModel, Field |
30 | 31 |
|
31 | 32 | from livepeer_gateway.live_runner import register_runner |
32 | 33 | from livepeer_gateway.media_decode import AudioDecodedMediaFrame, VideoDecodedMediaFrame |
|
42 | 43 |
|
43 | 44 | DEFAULT_HOST = "127.0.0.1" |
44 | 45 | DEFAULT_PORT = 8989 |
45 | | -MODES = frozenset({"echo", "gray", "invert", "blur", "robot"}) |
46 | 46 | # "robot" multiplies each sample by a sine at this frequency; the sample count is |
47 | 47 | # unchanged, so audio stays in sync with video. |
48 | 48 | ROBOT_HZ = 220.0 |
@@ -104,23 +104,35 @@ def _parse_args() -> argparse.Namespace: |
104 | 104 | return parser.parse_args() |
105 | 105 |
|
106 | 106 |
|
107 | | -def _session_id(request: web.Request) -> str: |
| 107 | +def _session_id(request: Request) -> str: |
108 | 108 | session_id = request.headers.get("Livepeer-Session-Id", "").strip() |
109 | 109 | if not session_id: |
110 | | - raise web.HTTPBadRequest(text="missing Livepeer-Session-Id header") |
| 110 | + raise HTTPException( |
| 111 | + status_code=400, detail="missing Livepeer-Session-Id header" |
| 112 | + ) |
111 | 113 | return session_id |
112 | 114 |
|
113 | 115 |
|
114 | | -def _parse_mode(payload: dict[str, Any]) -> ModeState: |
115 | | - mode = str(payload.get("mode", "echo")).strip().lower() |
116 | | - if mode not in MODES: |
117 | | - raise web.HTTPBadRequest(text=f"mode must be one of {sorted(MODES)}") |
118 | | - radius = payload.get("radius", 7) |
119 | | - try: |
120 | | - radius_int = int(radius) |
121 | | - except (TypeError, ValueError) as exc: |
122 | | - raise web.HTTPBadRequest(text="radius must be an integer") from exc |
123 | | - return ModeState(mode=mode, radius=max(1, min(99, radius_int))) |
| 116 | +class EchoRequest(BaseModel): |
| 117 | + mode: Literal["echo", "gray", "invert", "blur", "robot"] = "echo" |
| 118 | + # The client sweeps 0..100; clamped rather than rejected, as before. |
| 119 | + radius: int = Field(7, description="Blur strength; blur mode only.") |
| 120 | + audio: bool = Field(False, description="Publish an audio track (robot needs one).") |
| 121 | + |
| 122 | + |
| 123 | +class UpdateRequest(BaseModel): |
| 124 | + mode: Literal["echo", "gray", "invert", "blur", "robot"] = "echo" |
| 125 | + radius: int = 7 |
| 126 | + |
| 127 | + |
| 128 | +class SessionResponse(BaseModel): |
| 129 | + session: str |
| 130 | + in_: str = Field(..., alias="in") |
| 131 | + out: str |
| 132 | + mode: str |
| 133 | + radius: int | None = None |
| 134 | + |
| 135 | + model_config = {"populate_by_name": True} |
124 | 136 |
|
125 | 137 |
|
126 | 138 | def _odd_kernel(radius: int) -> int: |
@@ -181,119 +193,109 @@ def _transform_frame( |
181 | 193 | return out |
182 | 194 |
|
183 | 195 |
|
184 | | -async def _handle_echo(request: web.Request) -> web.Response: |
185 | | - global state |
186 | | - session_id = _session_id(request) |
187 | | - |
188 | | - if state is not None: |
189 | | - if state.session_id != session_id: |
190 | | - raise web.HTTPConflict(text="echo runner already has an active session") |
191 | | - return web.json_response(state.to_json()) |
192 | | - |
193 | | - # Pass the request so the SDK opens channels using the orchestrator's |
194 | | - # Session-Control header, whose URLs are reachable from the runner's network. |
195 | | - channels = await request.app["registration"].create_trickle_channels( # Livepeer: 2 |
196 | | - request, |
197 | | - [ |
198 | | - {"name": "in", "mime_type": "video/mp2t"}, |
199 | | - {"name": "out", "mime_type": "video/mp2t"}, |
200 | | - ], |
201 | | - ) |
202 | | - by_name = {channel["name"]: channel for channel in channels} |
203 | | - if "in" not in by_name or "out" not in by_name: |
204 | | - raise web.HTTPInternalServerError( |
205 | | - text="orchestrator did not return in/out channels" |
| 196 | +def build_app(args: argparse.Namespace) -> FastAPI: |
| 197 | + @asynccontextmanager |
| 198 | + async def _lifespan(app: FastAPI): |
| 199 | + app.state.registration = await register_runner( # Livepeer: 1 |
| 200 | + args.orchestrator, |
| 201 | + secret=args.orchSecret, |
| 202 | + runner_url=args.runner_url, |
| 203 | + app="livepeer-example/echo", |
| 204 | + mode="persistent", |
| 205 | + price=args.price, |
| 206 | + ) |
| 207 | + log.info( |
| 208 | + "registered runner_id=%s orchestrator=%s", |
| 209 | + app.state.registration.runner_id, |
| 210 | + app.state.registration.orchestrator_url, |
| 211 | + ) |
| 212 | + yield |
| 213 | + await _close_pipeline() |
| 214 | + with suppress(Exception): |
| 215 | + await app.state.registration.close() # Livepeer: 3 |
| 216 | + |
| 217 | + app = FastAPI(title="livepeer-example/echo", version="0.1.0", lifespan=_lifespan) |
| 218 | + |
| 219 | + @app.post("/echo", response_model=SessionResponse, response_model_by_alias=True) |
| 220 | + async def echo(body: EchoRequest, request: Request) -> dict[str, Any]: |
| 221 | + global state |
| 222 | + session_id = _session_id(request) |
| 223 | + |
| 224 | + if state is not None: |
| 225 | + if state.session_id != session_id: |
| 226 | + raise HTTPException(409, "echo runner already has an active session") |
| 227 | + return state.to_json() |
| 228 | + |
| 229 | + # Pass the request so the SDK opens channels using the orchestrator's |
| 230 | + # Session-Control header, whose URLs are reachable from the runner's network. |
| 231 | + channels = ( |
| 232 | + await request.app.state.registration.create_trickle_channels( # Livepeer: 2 |
| 233 | + request, |
| 234 | + [ |
| 235 | + {"name": "in", "mime_type": "video/mp2t"}, |
| 236 | + {"name": "out", "mime_type": "video/mp2t"}, |
| 237 | + ], |
| 238 | + ) |
| 239 | + ) |
| 240 | + by_name = {channel["name"]: channel for channel in channels} |
| 241 | + if "in" not in by_name or "out" not in by_name: |
| 242 | + raise HTTPException(500, "orchestrator did not return in/out channels") |
| 243 | + |
| 244 | + mode = ModeState(mode=body.mode, radius=max(1, min(99, body.radius))) |
| 245 | + # Tracks are declared upfront and the container waits for a first frame on |
| 246 | + # each, so only declare audio when the client says it is sending some. |
| 247 | + tracks: list[VideoOutputConfig | AudioOutputConfig] = [VideoOutputConfig()] |
| 248 | + if body.audio: |
| 249 | + tracks.append(AudioOutputConfig()) |
| 250 | + # internal_url: runner-reachable address (same as public url on a shared net). |
| 251 | + publisher = MediaPublish( |
| 252 | + by_name["out"].get("internal_url", by_name["out"]["url"]), |
| 253 | + config=MediaPublishConfig(tracks=tracks), |
206 | 254 | ) |
207 | 255 |
|
208 | | - # for production apps, handle errors |
209 | | - payload = json.loads(await request.read()) |
210 | | - mode = _parse_mode(payload) |
211 | | - # Tracks are declared upfront and the container waits for a first frame on each, |
212 | | - # so only declare audio when the client says it is sending some. |
213 | | - tracks: list[VideoOutputConfig | AudioOutputConfig] = [VideoOutputConfig()] |
214 | | - send_audio = payload.get("audio", False) |
215 | | - if not isinstance(send_audio, bool): |
216 | | - raise web.HTTPBadRequest(text="audio must be a boolean") |
217 | | - if send_audio: |
218 | | - tracks.append(AudioOutputConfig()) |
219 | | - # internal_url: runner-reachable address (same as the public url on a shared net). |
220 | | - publisher = MediaPublish( |
221 | | - by_name["out"].get("internal_url", by_name["out"]["url"]), |
222 | | - config=MediaPublishConfig(tracks=tracks), |
223 | | - ) |
224 | | - |
225 | | - async def _on_frame(decoded) -> None: |
226 | | - frame = _transform_frame(decoded, mode) |
227 | | - if frame is not None: |
228 | | - await publisher.write_frame(frame) |
229 | | - |
230 | | - output = MediaOutput( |
231 | | - by_name["in"].get("internal_url", by_name["in"]["url"]), on_frame=_on_frame |
232 | | - ) |
233 | | - |
234 | | - # Hand public channel urls to the client, so it can send/receive media. |
235 | | - state = EchoSession( |
236 | | - session_id=session_id, |
237 | | - in_url=by_name["in"]["url"], |
238 | | - out_url=by_name["out"]["url"], |
239 | | - mode=mode, |
240 | | - output=output, |
241 | | - publisher=publisher, |
242 | | - ) |
243 | | - for task in output.callback_tasks(): |
244 | | - task.add_done_callback(lambda _task: asyncio.create_task(_close_pipeline())) |
245 | | - log.info("started echo session %s", session_id) |
246 | | - return web.json_response(state.to_json()) |
| 256 | + async def _on_frame(decoded) -> None: |
| 257 | + frame = _transform_frame(decoded, mode) |
| 258 | + if frame is not None: |
| 259 | + await publisher.write_frame(frame) |
247 | 260 |
|
| 261 | + output = MediaOutput( |
| 262 | + by_name["in"].get("internal_url", by_name["in"]["url"]), on_frame=_on_frame |
| 263 | + ) |
248 | 264 |
|
249 | | -async def _handle_update(request: web.Request) -> web.Response: |
250 | | - session_id = _session_id(request) |
251 | | - if state is None: |
252 | | - raise web.HTTPNotFound(text="echo session not started") |
253 | | - if state.session_id != session_id: |
254 | | - raise web.HTTPConflict(text="echo runner has a different active session") |
| 265 | + # Hand public channel urls to the client, so it can send/receive media. |
| 266 | + state = EchoSession( |
| 267 | + session_id=session_id, |
| 268 | + in_url=by_name["in"]["url"], |
| 269 | + out_url=by_name["out"]["url"], |
| 270 | + mode=mode, |
| 271 | + output=output, |
| 272 | + publisher=publisher, |
| 273 | + ) |
| 274 | + for task in output.callback_tasks(): |
| 275 | + task.add_done_callback(lambda _t: asyncio.create_task(_close_pipeline())) |
| 276 | + log.info("started echo session %s", session_id) |
| 277 | + return state.to_json() |
| 278 | + |
| 279 | + @app.post("/update", response_model=SessionResponse, response_model_by_alias=True) |
| 280 | + async def update(body: UpdateRequest, request: Request) -> dict[str, Any]: |
| 281 | + session_id = _session_id(request) |
| 282 | + if state is None: |
| 283 | + raise HTTPException(404, "echo session not started") |
| 284 | + if state.session_id != session_id: |
| 285 | + raise HTTPException(409, "echo runner has a different active session") |
| 286 | + state.mode.mode = body.mode |
| 287 | + state.mode.radius = max(1, min(99, body.radius)) |
| 288 | + return state.to_json() |
255 | 289 |
|
256 | | - # for production apps, handle errors |
257 | | - mode = _parse_mode(json.loads(await request.read())) |
258 | | - state.mode.mode = mode.mode |
259 | | - state.mode.radius = mode.radius |
260 | | - return web.json_response(state.to_json()) |
| 290 | + return app |
261 | 291 |
|
262 | 292 |
|
263 | 293 | def main() -> None: |
264 | 294 | logging.basicConfig( |
265 | 295 | level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" |
266 | 296 | ) |
267 | 297 | args = _parse_args() |
268 | | - |
269 | | - async def _on_startup(app: web.Application) -> None: |
270 | | - app["registration"] = await register_runner( # Livepeer: 1 |
271 | | - args.orchestrator, |
272 | | - secret=args.orchSecret, |
273 | | - runner_url=args.runner_url, |
274 | | - app="livepeer-example/echo", |
275 | | - mode="persistent", # realtime trickle streaming is a held-open session |
276 | | - # Metered: the session is billed per second of wall-clock for as long |
277 | | - # as the client holds it, which is what a live stream costs. |
278 | | - price=args.price, # USD per hour |
279 | | - ) |
280 | | - log.info( |
281 | | - "registered runner_id=%s orchestrator=%s", |
282 | | - app["registration"].runner_id, |
283 | | - app["registration"].orchestrator_url, |
284 | | - ) |
285 | | - |
286 | | - async def _on_cleanup(app: web.Application) -> None: |
287 | | - await _close_pipeline() |
288 | | - with suppress(Exception): |
289 | | - await app["registration"].close() # Livepeer: 3 |
290 | | - |
291 | | - app = web.Application() |
292 | | - app.router.add_post("/echo", _handle_echo) |
293 | | - app.router.add_post("/update", _handle_update) |
294 | | - app.on_startup.append(_on_startup) |
295 | | - app.on_cleanup.append(_on_cleanup) |
296 | | - web.run_app(app, host=args.host, port=DEFAULT_PORT) |
| 298 | + uvicorn.run(build_app(args), host=args.host, port=DEFAULT_PORT, access_log=False) |
297 | 299 |
|
298 | 300 |
|
299 | 301 | if __name__ == "__main__": |
|
0 commit comments