-
Notifications
You must be signed in to change notification settings - Fork 3k
feat: add Runway Characters avatar plugin #5355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a1e3c79
d91e487
5f6161a
25ebe61
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| # LiveKit Runway Avatar Agent | ||
|
|
||
| This example demonstrates how to create an animated avatar using [Runway Characters](https://dev.runwayml.com/). | ||
|
|
||
| ## Usage | ||
|
|
||
| - Update the environment: | ||
|
|
||
| ```bash | ||
| # Runway Config | ||
| export RUNWAYML_API_SECRET="..." | ||
| export RUNWAY_AVATAR_PRESET_ID="..." # or RUNWAY_AVATAR_ID for a custom avatar | ||
|
|
||
| # Google config (or other models, tts, stt) | ||
| export GOOGLE_API_KEY="..." | ||
|
|
||
| # LiveKit config | ||
| export LIVEKIT_API_KEY="..." | ||
| export LIVEKIT_API_SECRET="..." | ||
| export LIVEKIT_URL="..." | ||
| ``` | ||
|
|
||
| - Start the agent worker: | ||
|
|
||
| ```bash | ||
| python examples/avatar_agents/runway/agent_worker.py dev | ||
| ``` | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import logging | ||
| import os | ||
|
|
||
| from dotenv import load_dotenv | ||
|
|
||
| from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli | ||
| from livekit.plugins import google, runway | ||
|
|
||
| logger = logging.getLogger("runway-avatar-example") | ||
| logger.setLevel(logging.INFO) | ||
|
|
||
| load_dotenv() | ||
|
|
||
| server = AgentServer() | ||
|
|
||
|
|
||
| @server.rtc_session() | ||
| async def entrypoint(ctx: JobContext): | ||
| session = AgentSession( | ||
| llm=google.realtime.RealtimeModel(voice="kore"), | ||
| resume_false_interruption=False, | ||
| ) | ||
|
|
||
| avatar_id = os.getenv("RUNWAY_AVATAR_ID") | ||
| preset_id = os.getenv("RUNWAY_AVATAR_PRESET_ID") | ||
| runway_avatar = runway.AvatarSession(avatar_id=avatar_id, preset_id=preset_id) | ||
| await runway_avatar.start(session, room=ctx.room) | ||
|
|
||
| await session.start( | ||
| agent=Agent(instructions="Talk to me!"), | ||
| room=ctx.room, | ||
| ) | ||
|
|
||
| session.generate_reply(instructions="say hello to the user") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| cli.run_app(server) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| # livekit-plugins-runway | ||
|
|
||
| [LiveKit Agents](https://docs.livekit.io/agents/) plugin for [Runway Characters](https://dev.runwayml.com/) avatar integration. | ||
|
|
||
| Your LiveKit agent owns the full conversational AI pipeline (STT, LLM, TTS). Runway provides the visual layer — audio in, avatar video out. | ||
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| pip install livekit-plugins-runway | ||
| ``` | ||
|
|
||
| ## Usage | ||
|
|
||
| ```python | ||
| from livekit.agents import AgentSession, Agent, RoomOutputOptions | ||
| from livekit.plugins import runway | ||
|
|
||
| async def entrypoint(ctx): | ||
| session = AgentSession() | ||
|
|
||
| avatar = runway.AvatarSession( | ||
| avatar_id="your-custom-avatar-id", | ||
| # api_key defaults to RUNWAYML_API_SECRET env var | ||
| ) | ||
| await avatar.start(session, room=ctx.room) | ||
|
|
||
| await session.start( | ||
| agent=Agent(instructions="Talk to me!"), | ||
| room=ctx.room, | ||
| room_output_options=RoomOutputOptions(audio_enabled=False), | ||
| ) | ||
| ``` | ||
|
|
||
| ### Using a preset avatar | ||
|
|
||
| ```python | ||
| avatar = runway.AvatarSession( | ||
| preset_id="runway-preset-slug", | ||
| ) | ||
| ``` | ||
|
|
||
| ### With a session duration limit | ||
|
|
||
| ```python | ||
| avatar = runway.AvatarSession( | ||
| avatar_id="your-custom-avatar-id", | ||
| max_duration=300, | ||
| ) | ||
| ``` | ||
|
|
||
| ## Configuration | ||
|
|
||
| | Parameter | Env var | Description | | ||
| |-----------|---------|-------------| | ||
| | `api_key` | `RUNWAYML_API_SECRET` | Runway API key | | ||
| | `api_url` | `RUNWAYML_BASE_URL` | API base URL (default: `https://api.dev.runwayml.com`) | | ||
| | `avatar_id` | — | Custom avatar ID (mutually exclusive with `preset_id`) | | ||
| | `preset_id` | — | Preset avatar slug (mutually exclusive with `avatar_id`) | | ||
| | `max_duration` | — | Maximum session duration in seconds | | ||
|
|
||
| LiveKit credentials (`LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`) are read from environment variables or can be passed to `avatar.start()`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| # Copyright 2025 LiveKit, Inc. | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Runway Characters avatar plugin for LiveKit Agents""" | ||
|
|
||
| from .avatar import AvatarSession, RunwayException | ||
| from .version import __version__ | ||
|
|
||
| __all__ = [ | ||
| "RunwayException", | ||
| "AvatarSession", | ||
| "__version__", | ||
| ] | ||
|
|
||
| from livekit.agents import Plugin | ||
|
|
||
| from .log import logger | ||
|
|
||
|
|
||
| class RunwayPlugin(Plugin): | ||
| def __init__(self) -> None: | ||
| super().__init__(__name__, __version__, __package__, logger) | ||
|
|
||
|
|
||
| Plugin.register_plugin(RunwayPlugin()) |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,179 @@ | ||||||||||
| from __future__ import annotations | ||||||||||
|
|
||||||||||
| import asyncio | ||||||||||
| import os | ||||||||||
|
|
||||||||||
| import aiohttp | ||||||||||
|
|
||||||||||
| from livekit import api, rtc | ||||||||||
| from livekit.agents import ( | ||||||||||
| DEFAULT_API_CONNECT_OPTIONS, | ||||||||||
| NOT_GIVEN, | ||||||||||
| AgentSession, | ||||||||||
| APIConnectionError, | ||||||||||
| APIConnectOptions, | ||||||||||
| APIStatusError, | ||||||||||
| NotGivenOr, | ||||||||||
| get_job_context, | ||||||||||
| utils, | ||||||||||
| ) | ||||||||||
| from livekit.agents.voice.avatar import DataStreamAudioOutput | ||||||||||
| from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF | ||||||||||
|
|
||||||||||
| from .log import logger | ||||||||||
|
|
||||||||||
| DEFAULT_API_URL = "https://api.dev.runwayml.com" | ||||||||||
| API_VERSION = "2024-11-06" | ||||||||||
| SAMPLE_RATE = 16000 | ||||||||||
| _AVATAR_AGENT_IDENTITY = "runway-avatar-agent" | ||||||||||
| _AVATAR_AGENT_NAME = "runway-avatar-agent" | ||||||||||
|
|
||||||||||
|
|
||||||||||
| class RunwayException(Exception): | ||||||||||
| """Exception for Runway errors""" | ||||||||||
|
|
||||||||||
|
|
||||||||||
| class AvatarSession: | ||||||||||
| """A Runway Characters avatar session. | ||||||||||
|
|
||||||||||
| Creates a realtime session backed by Runway's avatar inference pipeline. | ||||||||||
| The customer's LiveKit agent owns the conversational AI stack (STT, LLM, TTS); | ||||||||||
| Runway provides the visual layer — audio in, avatar video out. | ||||||||||
| """ | ||||||||||
|
|
||||||||||
| def __init__( | ||||||||||
| self, | ||||||||||
| *, | ||||||||||
| avatar_id: NotGivenOr[str | None] = NOT_GIVEN, | ||||||||||
| preset_id: NotGivenOr[str | None] = NOT_GIVEN, | ||||||||||
| max_duration: NotGivenOr[int] = NOT_GIVEN, | ||||||||||
| api_url: NotGivenOr[str] = NOT_GIVEN, | ||||||||||
| api_key: NotGivenOr[str] = NOT_GIVEN, | ||||||||||
| avatar_participant_identity: NotGivenOr[str] = NOT_GIVEN, | ||||||||||
| avatar_participant_name: NotGivenOr[str] = NOT_GIVEN, | ||||||||||
| conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, | ||||||||||
| ) -> None: | ||||||||||
| if not avatar_id and not preset_id: | ||||||||||
| raise RunwayException("Either avatar_id or preset_id must be provided") | ||||||||||
| if avatar_id and preset_id: | ||||||||||
| raise RunwayException("Provide avatar_id or preset_id, not both") | ||||||||||
|
|
||||||||||
| if avatar_id: | ||||||||||
| self._avatar: dict[str, str] = {"type": "custom", "avatarId": str(avatar_id)} | ||||||||||
| else: | ||||||||||
| self._avatar = {"type": "runway-preset", "presetId": str(preset_id)} | ||||||||||
| self._max_duration = max_duration | ||||||||||
|
|
||||||||||
| self._api_url = api_url or os.getenv("RUNWAYML_BASE_URL", DEFAULT_API_URL) | ||||||||||
| self._api_key = api_key or os.getenv("RUNWAYML_API_SECRET") | ||||||||||
| if self._api_key is None: | ||||||||||
| raise RunwayException( | ||||||||||
| "api_key must be set either by passing it to AvatarSession or " | ||||||||||
| "by setting the RUNWAYML_API_SECRET environment variable" | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| self._avatar_participant_identity = avatar_participant_identity or _AVATAR_AGENT_IDENTITY | ||||||||||
| self._avatar_participant_name = avatar_participant_name or _AVATAR_AGENT_NAME | ||||||||||
| self._http_session: aiohttp.ClientSession | None = None | ||||||||||
| self._conn_options = conn_options | ||||||||||
|
|
||||||||||
| def _ensure_http_session(self) -> aiohttp.ClientSession: | ||||||||||
| if self._http_session is None: | ||||||||||
| self._http_session = utils.http_context.http_session() | ||||||||||
| return self._http_session | ||||||||||
|
|
||||||||||
| async def start( | ||||||||||
| self, | ||||||||||
| agent_session: AgentSession, | ||||||||||
| room: rtc.Room, | ||||||||||
| *, | ||||||||||
| livekit_url: NotGivenOr[str] = NOT_GIVEN, | ||||||||||
| livekit_api_key: NotGivenOr[str] = NOT_GIVEN, | ||||||||||
| livekit_api_secret: NotGivenOr[str] = NOT_GIVEN, | ||||||||||
| ) -> None: | ||||||||||
| livekit_url = livekit_url or (os.getenv("LIVEKIT_URL") or NOT_GIVEN) | ||||||||||
| livekit_api_key = livekit_api_key or (os.getenv("LIVEKIT_API_KEY") or NOT_GIVEN) | ||||||||||
| livekit_api_secret = livekit_api_secret or (os.getenv("LIVEKIT_API_SECRET") or NOT_GIVEN) | ||||||||||
| if not livekit_url or not livekit_api_key or not livekit_api_secret: | ||||||||||
| raise RunwayException( | ||||||||||
| "livekit_url, livekit_api_key, and livekit_api_secret must be set " | ||||||||||
| "by arguments or environment variables" | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| job_ctx = get_job_context() | ||||||||||
| self._local_participant_identity = job_ctx.local_participant_identity | ||||||||||
|
|
||||||||||
| livekit_token = ( | ||||||||||
| api.AccessToken(api_key=livekit_api_key, api_secret=livekit_api_secret) | ||||||||||
| .with_kind("agent") | ||||||||||
| .with_identity(self._avatar_participant_identity) | ||||||||||
| .with_name(self._avatar_participant_name) | ||||||||||
| .with_grants(api.VideoGrants(room_join=True, room=room.name)) | ||||||||||
| .with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: self._local_participant_identity}) | ||||||||||
| .to_jwt() | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| logger.debug("starting Runway avatar session") | ||||||||||
| await self._create_session(livekit_url, livekit_token, room.name) | ||||||||||
|
|
||||||||||
| agent_session.output.audio = DataStreamAudioOutput( | ||||||||||
| room=room, | ||||||||||
| destination_identity=self._avatar_participant_identity, | ||||||||||
| wait_remote_track=rtc.TrackKind.KIND_VIDEO, | ||||||||||
| sample_rate=SAMPLE_RATE, | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| async def _create_session(self, livekit_url: str, livekit_token: str, room_name: str) -> None: | ||||||||||
| assert self._api_key is not None | ||||||||||
| assert isinstance(self._api_url, str) | ||||||||||
|
|
||||||||||
| body: dict[str, object] = { | ||||||||||
| "model": "gwm1_avatars", | ||||||||||
| "avatar": self._avatar, | ||||||||||
| "livekit": { | ||||||||||
| "url": livekit_url, | ||||||||||
| "token": livekit_token, | ||||||||||
| "roomName": room_name, | ||||||||||
| "agentIdentity": self._local_participant_identity, | ||||||||||
| }, | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if self._max_duration: | ||||||||||
| body["maxDuration"] = self._max_duration | ||||||||||
|
Comment on lines
+141
to
+142
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Truthiness check on On line 141,
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||
|
|
||||||||||
| for attempt in range(self._conn_options.max_retry): | ||||||||||
| try: | ||||||||||
| async with self._ensure_http_session().post( | ||||||||||
| f"{self._api_url}/v1/realtime_sessions", | ||||||||||
| headers={ | ||||||||||
| "Authorization": f"Bearer {self._api_key}", | ||||||||||
| "X-Runway-Version": API_VERSION, | ||||||||||
| }, | ||||||||||
| json=body, | ||||||||||
| timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout), | ||||||||||
| ) as response: | ||||||||||
| if not response.ok: | ||||||||||
| text = await response.text() | ||||||||||
| raise APIStatusError( | ||||||||||
| "Runway API returned an error", | ||||||||||
| status_code=response.status, | ||||||||||
| body=text, | ||||||||||
| ) | ||||||||||
| return | ||||||||||
|
|
||||||||||
| except Exception as error: | ||||||||||
| if isinstance(error, APIStatusError): | ||||||||||
| raise | ||||||||||
|
|
||||||||||
| if isinstance(error, APIConnectionError): | ||||||||||
| logger.warning( | ||||||||||
| "failed to call Runway avatar API", | ||||||||||
| extra={"error": str(error)}, | ||||||||||
| ) | ||||||||||
| else: | ||||||||||
| logger.exception("failed to call Runway avatar API") | ||||||||||
|
|
||||||||||
| if attempt < self._conn_options.max_retry - 1: | ||||||||||
| await asyncio.sleep(self._conn_options.retry_interval) | ||||||||||
|
|
||||||||||
| raise APIConnectionError("Failed to start Runway Avatar Session after all retries") | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import logging | ||
|
|
||
| logger = logging.getLogger("livekit.plugins.runway") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| __version__ = "1.5.2" |
Uh oh!
There was an error while loading. Please reload this page.