Transcoding-with-captions — a Livepeer capability, and notes on getting it paid

Updated: now running on go-livepeer v0.9.1 + livepeer-gateway v1.0.0. Three of the problems in my original write-up no longer exist — details below.

I’ve been building a live-runner capability and it’s working end to end on mainnet, paid. Sharing the code and, more usefully, the write-up of everything between an off-chain demo and a redeemed winning ticket.

What it does

Takes one live A/V stream and returns transcoded renditions and live captions from the same session. One upload, decoded once, feeding both the encoder ladder and speech-to-text. Both halves are configurable and either can be switched off — "captions": false serves a plain transcoding customer at plain transcoding cost, and "renditions": [] gives you captions alone.

The framing I find interesting: this is an add-on to transcoding an orchestrator already does, not a new service anyone has to believe in. Same input, captions as the differentiator.

Captions arrive as interim previews from a small model, superseded by finals from a larger one, and share the input stream’s PTS timeline — so a client holding playback slightly behind capture can put a subtitle on exactly the frames it describes.

Where it runs

A Radxa Rock 5B (RK3588, 8W, ARM64) at home, on a domestic connection. Four renditions plus captions, zero dropped frames. Some numbers, since I couldn’t find ARM figures anywhere:

• whisper base int8: 5.14x realtime — and faster on 2 threads than 4, it’s memory-bandwidth bound
• whisper tiny: 11.97x
• both x264 encodes fit on the little A55 cores at ~3x realtime, so the missing Rockchip hardware encoder didn’t matter

It’s actually paid

Not a testnet demo. Live sessions paid in PM tickets, winning tickets redeemed on Arbitrum, fees credited to the orchestrator’s pendingFees. A 60s session produced 8 winning tickets, redeemed for a few pence of gas.

The orchestrator’s key never touches the box: the node runs on a disposable key with -ethOrchAddr pointing at the cold address. requireValidWinningTicket has no msg.sender check, so redemption pays _ticket.recipient regardless of who submits it — a compromised node key can only waste its own gas.

What v0.9.1 + SDK v1.0.0 fixed

My original post documented five walls. Three are now gone, and I’ve deleted the workarounds rather than shipping them:

• registration price shape — register_runner now takes price/currency/unit directly
• billing unit (lv2v vs live, which produced numTickets 6088030703 exceeds maximum of 100) — the SDK derives the payment type from price_info.unit
• sessions dying at ~15s — reserve_session answers the 402 challenge and then keeps the session funded in the background, which is what metered pricing needs

I verified a 60s paid session on the new stack with no client-side payment code at all. Thanks to @j0sh and @rickstaa#4008 (session-scoped payment URL) was the piece that closed it, and it landed the same day I was working around its absence.

What’s still worth knowing

More renditions do not earn more. Revenue is seconds × price_per_second. A bigger frame or a longer ladder only costs you CPU. This is the most common wrong assumption about this rail.
numTickets must stay under 100 — hardcoded in server/remote_signer.go — and the initial reservation uses a 10s preload, so (10 × price_per_second) / ticketEV ≤ 100. At the default ticketEV that caps price around $1.36/hour. Raise ticketEV to raise the ceiling.
Expected wins = spend ÷ faceValue. ticketEV cancels out; it only sets how many tickets carry that spend.
• The fee recipient must be an active transcoder — updateTranscoderWithFees requires it, so a fresh orchestrator identity must be bonded into the active set before it can collect anything.
• Winning tickets are redeemed late by design — held until their params expire so the sender can’t reuse them. Before debugging a “stuck” ticket, compare paramsExpirationBlock in the ticketQueue table against RoundsManager.blockNum(). Mine were simply 23 L1 blocks early.
-ethPassword is not a path, despite the help text. Give it a filename and the filename becomes the password.

Caveats, honestly

• The BYOC rail is written but never exercised. Registration works (/capability/register, Authorization: <orchSecret>) and the streaming routes are live under /ai/stream/…, but no SDK — mine or upstream — has a client for BYOC streaming, so driving it means hand-rolling a signed job. Unproven.
• Known upstream issue where an orchestrator can drop a stream (#4026, #4004). The capability re-subscribes rather than ending the session, but clients should expect a session to end unexpectedly.
• Capacity 1 on an 8W SBC. This is a proof, not a service to depend on.

How to get it

On Radicle rather than GitHub — peer-to-peer git, no forge in the middle:

curl -sSf https://radicle.xyz/install | sh
rad clone rad:zVjqknCK5c7TWDqeVaTuAKWmHgxZ

A normal git repo with normal history. Public domain (Unlicense). deploy/PAYMENTS.md is the part most likely to save someone a day.

What would help

1. Let’s agree on a capability name — and I’d like to convene that rather than just claim one. Discovery matches capability strings exactly: no prefixes, no wildcards. So transcoding-with-captions/whisper — a name I invented — is only reachable by a client that already knows that exact string. If two of us ship near-identical capabilities under different names we fragment supply for nothing. Worth settling: does the model belong in the name or in metadata? Is pipeline/model right for add-ons to transcoding rather than standalone pipelines? Does anyone keep a list of known names? I’m not attached to mine.

2. A GPU orchestrator willing to run it for an afternoon. Everything above is CPU-only on ARM. The GPU container target is written from the CUDA docs and has never been run. It needs nothing exotic — whisper small int8 wants ~1–2GB VRAM, so anything from a GTX 1660 up. An hour of someone’s time and there’d be a verified GPU path.

3. Someone to own the client side. The capability is done; what’s missing is a gateway that requests it and a player that renders the captions. Captions are plain SSE with start/end on the input PTS timeline — see the client contract in deploy/README.md.

In return, deploy/PAYMENTS.md documents a working configuration with the source line explaining each constraint. Happy for any of it to land in the official docs.

And the next thing I want to build is live-translated subtitles — “Marie avait un petit agneau” — on the same seam and the same session. The caption backend is already pluggable, so it’s an extension rather than a new bet.

2 Likes