Skip to content

Commit c8381c4

Browse files
rickstaaclaude
andcommitted
fix(live): address PR review feedback on @pipeline decorator
- Fix __qualname__ on function wrapper for correct importlib resolution - Add module docstring with lifecycle hook reference to create.py - Improve _build_pipeline docstring documenting hook detection - Normalize VideoOutput request_id to prevent silent frame drops - Make prepare_models async-capable via _invoke for sync/async support - Replace MiDaS example with pure-torch edge detection (no external deps) - Update docs to use EdgeDetect example throughout Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 378275f commit c8381c4

7 files changed

Lines changed: 223 additions & 277 deletions

File tree

docs/custom-pipeline.md

Lines changed: 69 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Creating a Custom Live Pipeline
1+
# Creating a Custom Pipeline
22

33
This guide explains how to create a custom pipeline for the AI Runner from a **separate repository**. We'll use [scope-runner](https://github.com/livepeer/scope-runner) as a reference implementation.
44

@@ -9,7 +9,7 @@ This guide explains how to create a custom pipeline for the AI Runner from a **s
99
A custom pipeline is a Python package that:
1010

1111
1. Extends the `ai-runner[realtime]` library as a dependency (or `ai-runner[batch]` for a batch pipeline)
12-
2. Implements the [`Pipeline`](../runner/src/runner/live/pipelines/interface.py#L46) interface for frame processing
12+
2. Uses the [`@pipeline`](../runner/src/runner/live/pipelines/create.py) decorator to define frame processing logic
1313
3. Optionally defines custom parameters extending [`BaseParams`](../runner/src/runner/live/pipelines/interface.py#L10)
1414
4. Provides a `prepare_models()` classmethod for model download/compilation
1515
5. Ships as a Docker image, ideally extending `livepeer/ai-runner:live-base`
@@ -76,11 +76,7 @@ touch src/my_pipeline/pipeline/params.py
7676

7777
## Step 2: Implement the Pipeline
7878

79-
You have two options: the `@pipeline` decorator (recommended) or the raw `Pipeline` interface (full control over frame queuing, batching, and threading).
80-
81-
### Option A: `@pipeline` Decorator (Recommended)
82-
83-
The `@pipeline` decorator handles frame queues, lifecycle, threading, and parameter validation automatically.
79+
Use the `@pipeline` decorator to define your pipeline. The decorator handles frame queues, lifecycle management, parameter validation, and threading automatically.
8480

8581
**Function form** — simplest possible pipeline:
8682

@@ -96,143 +92,68 @@ async def green_shift(frame: VideoFrame, params: BaseParams) -> torch.Tensor:
9692
tensor = frame.tensor.clone()
9793
tensor[:, :, :, 1] = torch.clamp(tensor[:, :, :, 1] + 0.3, -1.0, 1.0)
9894
return tensor
99-
100-
GreenShiftPipeline = green_shift
10195
```
10296

103-
**Class form** — for model loading, state, and mid-stream parameter updates:
97+
**Class form** — for state, device setup, and mid-stream parameter updates:
10498

10599
```python
106100
# src/my_pipeline/pipeline/pipeline.py
107101
import logging
108102
import torch
103+
import torch.nn.functional as F
109104
from pydantic import Field
110105
from runner.live.pipelines import pipeline, BaseParams
111106
from runner.live.trickle import VideoFrame
112107

113-
class DepthParams(BaseParams):
114-
colormap: bool = Field(default=True, description="Apply colormap to depth output.")
115-
near_clip: float = Field(default=0.0, ge=0.0, le=1.0)
116-
far_clip: float = Field(default=1.0, ge=0.0, le=1.0)
108+
class EdgeParams(BaseParams):
109+
threshold: float = Field(default=0.1, ge=0.0, le=1.0, description="Edge threshold.")
110+
colorize: bool = Field(default=False, description="Colorize edges by direction.")
117111

118-
@pipeline(name="depth-midas", params=DepthParams)
119-
class DepthMidas:
112+
@pipeline(name="edge-detect", params=EdgeParams)
113+
class EdgeDetect:
120114

121115
def on_ready(self, **params):
122-
# Load model and set up device
116+
self._threshold = params.get("threshold", 0.1)
123117
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
124-
self.model = torch.hub.load("intel-isl/MiDaS", "MiDaS_small", pretrained=True)
125-
self.model.to(self.device).eval()
126-
self.transforms = torch.hub.load("intel-isl/MiDaS", "transforms").small_transform
127-
128-
def transform(self, frame: VideoFrame, params: DepthParams) -> torch.Tensor:
129-
# Run inference and return output tensor
130-
tensor = frame.tensor[0]
131-
h, w = tensor.shape[:2]
132-
img = ((tensor + 1.0) / 2.0 * 255).byte().cpu().numpy()
133-
input_batch = self.transforms(img).to(self.device)
134-
with torch.no_grad():
135-
depth = self.model(input_batch)
136-
depth = torch.nn.functional.interpolate(
137-
depth.unsqueeze(1), size=(h, w), mode="bilinear", align_corners=False
138-
).squeeze()
139-
depth = (depth - depth.min()) / (depth.max() - depth.min() + 1e-8)
140-
out = depth.unsqueeze(-1).expand(-1, -1, 3)
141-
return (out * 2.0 - 1.0).unsqueeze(0).to(frame.tensor.device)
118+
self.sobel_x = torch.tensor(
119+
[[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=torch.float32, device=self.device
120+
).view(1, 1, 3, 3)
121+
self.sobel_y = torch.tensor(
122+
[[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=torch.float32, device=self.device
123+
).view(1, 1, 3, 3)
124+
125+
def transform(self, frame: VideoFrame, params: EdgeParams) -> torch.Tensor:
126+
tensor = frame.tensor.to(self.device)
127+
gray = tensor.mean(dim=-1, keepdim=True).permute(0, 3, 1, 2)
128+
edges_x = F.conv2d(gray, self.sobel_x, padding=1)
129+
edges_y = F.conv2d(gray, self.sobel_y, padding=1)
130+
magnitude = torch.sqrt(edges_x ** 2 + edges_y ** 2)
131+
magnitude = magnitude / (magnitude.max() + 1e-8)
132+
edges = (magnitude > self._threshold).float()
133+
out = edges.expand(-1, 3, -1, -1).permute(0, 2, 3, 1)
134+
return (out * 2.0 - 1.0)
142135

143136
def on_update(self, **params):
144-
# Handle mid-stream parameter changes
145-
logging.info(f"Params updated: {params}")
137+
self._threshold = params.get("threshold", 0.1)
138+
logging.info(f"Edge threshold updated: {self._threshold}")
146139

147140
def on_stop(self):
148-
logging.info("DepthMidas stopped")
149-
150-
@classmethod
151-
def prepare_models(cls):
152-
# Download model weights at build time
153-
torch.hub.load("intel-isl/MiDaS", "MiDaS_small", pretrained=True)
154-
155-
DepthMidasPipeline = DepthMidas
141+
logging.info("EdgeDetect stopped")
156142
```
157143

158-
**Decorator class methods:**
159-
160-
| Method | Required | When it runs |
161-
|---|---|---|
162-
| `transform(self, frame, params)` | Yes | Every frame |
163-
| `on_ready(self, **params)` | No | Once on startup |
164-
| `on_update(self, **params)` | No | When params change mid-stream |
165-
| `on_stop(self)` | No | On shutdown |
166-
| `prepare_models(cls)` | No | At build time (model download) |
167-
168-
Both `async def` and `def` work. Sync functions automatically run in a thread pool.
169-
170-
See [`examples/pipelines/`](../examples/pipelines/) for complete working examples.
144+
**Lifecycle methods:**
171145

172-
### Option B: Raw `Pipeline` Interface
146+
| Method | Required | When it runs | What to do here |
147+
|---|---|---|---|
148+
| `prepare_models(cls)` | No | **Build time** | Download weights, compile TensorRT engines |
149+
| `on_ready(self, **params)` | No | **Process startup** | Load model from disk to GPU |
150+
| `transform(self, frame, params)` | Yes | **Every frame** | Run inference, return tensor |
151+
| `on_update(self, **params)` | No | **Mid-stream** | Handle param changes |
152+
| `on_stop(self)` | No | **Shutdown** | Release resources |
173153

174-
For advanced use cases where you need full control over frame queuing, custom batching, or complex threading. Here's the same depth-midas pipeline implemented with the raw interface:
154+
Both `async def` and `def` work for all methods. Sync functions automatically run in a thread pool.
175155

176-
```python
177-
# src/my_pipeline/pipeline/pipeline.py
178-
import asyncio
179-
import logging
180-
import torch
181-
from runner.live.pipelines import Pipeline
182-
from runner.live.trickle import VideoFrame, VideoOutput
183-
184-
class DepthMidasPipeline(Pipeline):
185-
def __init__(self):
186-
self.frame_queue: asyncio.Queue[VideoOutput] = asyncio.Queue()
187-
188-
async def initialize(self, **params):
189-
# Load model (use asyncio.to_thread for blocking operations)
190-
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
191-
self.model = await asyncio.to_thread(
192-
torch.hub.load, "intel-isl/MiDaS", "MiDaS_small", pretrained=True
193-
)
194-
self.model.to(self.device).eval()
195-
self.transforms = torch.hub.load("intel-isl/MiDaS", "transforms").small_transform
196-
197-
async def put_video_frame(self, frame: VideoFrame, request_id: str):
198-
# Run inference in thread pool to avoid blocking the event loop
199-
result = await asyncio.to_thread(self._run_inference, frame)
200-
await self.frame_queue.put(
201-
VideoOutput(frame, request_id).replace_tensor(result)
202-
)
203-
204-
def _run_inference(self, frame: VideoFrame) -> torch.Tensor:
205-
tensor = frame.tensor[0]
206-
h, w = tensor.shape[:2]
207-
img = ((tensor + 1.0) / 2.0 * 255).byte().cpu().numpy()
208-
input_batch = self.transforms(img).to(self.device)
209-
with torch.no_grad():
210-
depth = self.model(input_batch)
211-
depth = torch.nn.functional.interpolate(
212-
depth.unsqueeze(1), size=(h, w), mode="bilinear", align_corners=False
213-
).squeeze()
214-
depth = (depth - depth.min()) / (depth.max() - depth.min() + 1e-8)
215-
out = depth.unsqueeze(-1).expand(-1, -1, 3)
216-
return (out * 2.0 - 1.0).unsqueeze(0).to(frame.tensor.device)
217-
218-
async def get_processed_video_frame(self) -> VideoOutput:
219-
return await self.frame_queue.get()
220-
221-
async def update_params(self, **params):
222-
# Handle mid-stream parameter changes
223-
# Return asyncio.create_task(...) for slow reloads (shows loading overlay)
224-
logging.info(f"Updating params: {params}")
225-
226-
async def stop(self):
227-
logging.info("Stopping pipeline")
228-
229-
@classmethod
230-
def prepare_models(cls):
231-
# Download model weights at build time
232-
torch.hub.load("intel-isl/MiDaS", "MiDaS_small", pretrained=True)
233-
```
234-
235-
For another real-world example, see [scope-runner's pipeline](https://github.com/daydreamlive/scope-runner/blob/dec9ecf7e306892df9cfae21759c23fdf15b0510/src/scope_runner/pipeline/pipeline.py#L22).
156+
See [`examples/live-video-to-video/`](../examples/live-video-to-video/) for complete working examples.
236157

237158
### Define Parameters (Optional)
238159

@@ -321,9 +242,16 @@ CMD ["uv", "run", "--frozen", "my-pipeline"]
321242

322243
## Step 5: Implement Model Preparation
323244

324-
The `prepare_models()` classmethod is called when running with the `PREPARE_MODELS=1` environment variable (or `--prepare-models` flag). It is set automatically by `dl_checkpoints.sh` during operator setup.
245+
The `prepare_models()` classmethod runs at **build time** when an operator sets up their node, not when a stream or request arrives. It is triggered by the `PREPARE_MODELS=1` environment variable (or `--prepare-models` flag), and is called automatically by `dl_checkpoints.sh` during operator setup.
246+
247+
This is the right place for any expensive one-time work:
325248

326-
Example implementation (in your `pipeline.py`):
249+
- **Downloading model weights** from HuggingFace, Google Drive, etc.
250+
- **Compiling TensorRT engines** for optimized GPU inference
251+
- **Converting model formats** (e.g., ONNX export, quantization)
252+
- **Warming up caches** or generating lookup tables
253+
254+
Unlike runtime (where `HF_HUB_OFFLINE=1` prevents accidental downloads), `prepare_models` runs with full network access so you can fetch weights from HuggingFace, Google Drive, or other sources.
327255

328256
```python
329257
@classmethod
@@ -344,12 +272,26 @@ def prepare_models(cls):
344272
local_dir_use_symlinks=False,
345273
)
346274

347-
# Compile TensorRT engines if needed
348-
# This is where you'd run expensive one-time operations
275+
# Optional: compile TensorRT engine for faster inference
276+
# import torch_tensorrt
277+
# model = torch.load(models_dir / "my-model" / "model.pt")
278+
# trt_model = torch_tensorrt.compile(model, inputs=[...])
279+
# torch.save(trt_model, models_dir / "my-model" / "model_trt.pt")
349280

350281
logging.info("Model preparation complete")
351282
```
352283

284+
Then in `on_ready`, just load the pre-downloaded (and optionally pre-compiled) model from disk:
285+
286+
```python
287+
def on_ready(self, **params):
288+
"""Load model from disk to GPU. Should be fast (seconds, not minutes)."""
289+
models_dir = Path(os.environ.get("MODEL_DIR", "/models")) / "MyPipeline--models"
290+
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
291+
self.model = torch.load(models_dir / "my-model" / "model.pt", map_location=self.device)
292+
self.model.eval()
293+
```
294+
353295
---
354296

355297
## Step 6: Integration with Livepeer Infrastructure
@@ -492,8 +434,7 @@ The orchestrator will route requests to your local runner at `http://localhost:8
492434

493435
### Async Operations
494436

495-
- Use `asyncio.to_thread()` for blocking/CPU-bound operations
496-
- Never block the event loop in `put_video_frame` or `get_processed_video_frame`
437+
- Both `async def` and `def` work — the `@pipeline` decorator automatically runs sync methods in a thread pool so they won't block the event loop
497438

498439
### Error Handling
499440

@@ -502,8 +443,8 @@ The orchestrator will route requests to your local runner at `http://localhost:8
502443

503444
### Parameter Updates
504445

505-
- Return nothing from `update_params()` for instant updates
506-
- Return an `asyncio.Task` for updates that will take a long time, normally a "pipeline reload". The runtime shows loading overlay while the reload is running.
446+
- Return nothing from `on_update()` for instant updates
447+
- For slow reloads, the runtime shows a loading overlay while the update is running
507448

508449
---
509450

@@ -519,19 +460,6 @@ The orchestrator will route requests to your local runner at `http://localhost:8
519460

520461
---
521462

522-
## Publishing to the Marketplace
523-
524-
Publish your pipeline to make it discoverable on the marketplace. The CLI extracts the parameter schema from your `@pipeline` decorator and registers it automatically.
525-
526-
```bash
527-
livepeer publish examples/pipelines/depth_midas.py
528-
livepeer teardown depth-midas
529-
livepeer list
530-
livepeer inspect examples/pipelines/depth_midas.py
531-
```
532-
533-
---
534-
535463
## Reference Implementation
536464

537465
For a complete working example, see [scope-runner](https://github.com/livepeer/scope-runner).

0 commit comments

Comments
 (0)