[eric] v1.0.20: add common MCP integrations, mcp OAuth, one-click connect UX, onboarding tools step, ui/ux improvements

This commit is contained in:
ciregenz
2026-03-29 16:33:36 -07:00
parent b75c6f8fe2
commit 0616b0ecbc
78 changed files with 11565 additions and 185 deletions
+15
View File
@@ -207,6 +207,21 @@ class AgentManager:
sections.append("\n".join(lines))
# Add awareness of tools that are installed but not yet connected
not_connected = [
t for t in all_tools
if t.mcp_config and t.enabled
and t.auth_type in ("oauth2", "env_vars")
and t.auth_status != "connected"
]
if not_connected:
nc_lines = [
"Tools installed but not yet connected (user needs to authorize in Settings → Tools):"
]
for t in not_connected:
nc_lines.append(f" - {t.name}")
sections.append("\n".join(nc_lines))
if not sections:
return None
return (
+1 -1
View File
@@ -15,7 +15,7 @@ from backend.apps.analytics.collector import init as init_collector, shutdown as
logger = logging.getLogger(__name__)
APP_VERSION = "1.0.19"
APP_VERSION = "1.0.20"
_heartbeat_task: asyncio.Task | None = None
+1 -1
View File
@@ -38,7 +38,7 @@ class AppSettings(BaseModel):
custom_providers: list["CustomProvider"] = Field(default_factory=list)
# Dashboard / UI preferences
auto_select_mode_on_new_agent: bool = False
expand_new_chats_in_dashboard: bool = False
expand_new_chats_in_dashboard: bool = True
auto_reveal_sub_agents: bool = True
dev_mode: bool = False
# Subscription tokens (from CLI tools — alternative to API keys)
+3
View File
@@ -65,6 +65,7 @@ class ToolDefinition(BaseModel):
credentials: dict[str, str] = Field(default_factory=dict)
auth_type: str = "none"
auth_status: str = "none"
oauth_provider: Optional[str] = None
oauth_tokens: dict[str, Any] = Field(default_factory=dict)
tool_permissions: dict[str, Any] = Field(default_factory=dict)
connected_account_email: Optional[str] = None
@@ -79,6 +80,7 @@ class ToolCreate(BaseModel):
credentials: dict[str, str] = Field(default_factory=dict)
auth_type: str = "none"
auth_status: str = "none"
oauth_provider: Optional[str] = None
class ToolUpdate(BaseModel):
@@ -89,6 +91,7 @@ class ToolUpdate(BaseModel):
credentials: Optional[dict[str, str]] = None
auth_type: Optional[str] = None
auth_status: Optional[str] = None
oauth_provider: Optional[str] = None
oauth_tokens: Optional[dict[str, Any]] = None
tool_permissions: Optional[dict[str, Any]] = None
connected_account_email: Optional[str] = None
+416 -122
View File
@@ -1,11 +1,15 @@
import asyncio
import base64
import hashlib
import json
import os
import re
import logging
import secrets
import shutil
import time
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any, Optional
from urllib.parse import urlencode
@@ -26,6 +30,26 @@ _DEFAULT_GOOGLE_CLIENT_SECRET = "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"
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", _DEFAULT_GOOGLE_CLIENT_ID)
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET)
# Default GitHub OAuth credentials for the OpenSwarm project.
os.environ.setdefault("GITHUB_OAUTH_CLIENT_ID", "Ov23liDcwNJaKMjXY2jI")
os.environ.setdefault("GITHUB_OAUTH_CLIENT_SECRET", "b25fe39409896aad3fd5155f032e9868440002f8")
# Default Slack OAuth credentials (requires HTTPS redirect — not yet functional)
os.environ.setdefault("SLACK_CLIENT_ID", "10795695056323.10799999254534")
os.environ.setdefault("SLACK_CLIENT_SECRET", "d3a85a286bb0205157d7e4963502a91d")
# Default Figma OAuth credentials for the OpenSwarm project.
os.environ.setdefault("FIGMA_CLIENT_ID", "q6WduT7UuPaO6lM88v6ddN")
os.environ.setdefault("FIGMA_CLIENT_SECRET", "dhNZdbEuyEWC15cKLwWpqTclyOSplD")
# Default Airtable OAuth credentials for the OpenSwarm project.
os.environ.setdefault("AIRTABLE_CLIENT_ID", "0699038b-a3a4-46b2-8fa6-690eb76fadfa")
os.environ.setdefault("AIRTABLE_CLIENT_SECRET", "187fa83c8bab8ebcd11b8f226d75e7a1f14a8174ac0494463c1a53e66a3036d0")
# Default HubSpot MCP Auth App credentials for the OpenSwarm project.
os.environ.setdefault("HUBSPOT_CLIENT_ID", "6f4a1d4c-6a2f-4336-9b65-2cd84e218ff6")
os.environ.setdefault("HUBSPOT_CLIENT_SECRET", "5747b5de-0800-4c35-a2da-e0655ee7ea37")
from backend.config.paths import BACKEND_DIR, DATA_ROOT, TOOLS_DIR as DATA_DIR, BUILTIN_PERMISSIONS_PATH as BUILTIN_PERMS_PATH
load_dotenv(os.path.join(BACKEND_DIR, ".env"))
@@ -53,7 +77,179 @@ GOOGLE_SCOPES = [
"https://www.googleapis.com/auth/contacts.readonly",
]
# ---------------------------------------------------------------------------
# Multi-provider OAuth registry
# ---------------------------------------------------------------------------
@dataclass
class OAuthProvider:
auth_url: str
token_url: str
scopes: list[str]
userinfo_url: str | None
userinfo_field: str # JSON field for display name/email
client_id_env: str
client_secret_env: str
token_env_mapping: dict[str, str] # oauth_tokens key -> MCP env var name
extra_auth_params: dict[str, str] = field(default_factory=dict)
revoke_url: str | None = None
# For providers where token response nests the access_token differently
token_response_path: str | None = None # e.g. "authed_user.access_token" for Slack
# Token exchange auth method: "form" (default), "basic" (Basic Auth header), "basic_json" (Basic Auth + JSON body)
token_auth_method: str = "form"
# Whether PKCE is required
pkce_required: bool = False
# Custom transform for env var value (e.g., wrapping token in JSON for Notion)
env_value_transform: str | None = None # e.g., "notion_headers"
# Extra token response fields to extract (e.g., Slack team_id)
extra_token_fields: dict[str, str] = field(default_factory=dict) # response_path -> env_var
OAUTH_PROVIDERS: dict[str, OAuthProvider] = {
"google": OAuthProvider(
auth_url=GOOGLE_AUTH_URL,
token_url=GOOGLE_TOKEN_URL,
scopes=GOOGLE_SCOPES,
userinfo_url=GOOGLE_USERINFO_URL,
userinfo_field="email",
client_id_env="GOOGLE_OAUTH_CLIENT_ID",
client_secret_env="GOOGLE_OAUTH_CLIENT_SECRET",
token_env_mapping={
"access_token": "OAUTH_ACCESS_TOKEN",
"refresh_token": "GOOGLE_WORKSPACE_REFRESH_TOKEN",
"_client_id": "GOOGLE_WORKSPACE_CLIENT_ID",
"_client_secret": "GOOGLE_WORKSPACE_CLIENT_SECRET",
},
extra_auth_params={"access_type": "offline", "prompt": "consent"},
),
"github": OAuthProvider(
auth_url="https://github.com/login/oauth/authorize",
token_url="https://github.com/login/oauth/access_token",
scopes=["repo", "read:user", "user:email"],
userinfo_url="https://api.github.com/user",
userinfo_field="login",
client_id_env="GITHUB_OAUTH_CLIENT_ID",
client_secret_env="GITHUB_OAUTH_CLIENT_SECRET",
token_env_mapping={
"access_token": "GITHUB_PERSONAL_ACCESS_TOKEN",
},
),
"slack": OAuthProvider(
auth_url="https://slack.com/oauth/v2/authorize",
token_url="https://slack.com/api/oauth.v2.access",
scopes=[
"channels:read", "channels:history", "chat:write",
"groups:read", "groups:history", "im:read", "im:history",
"mpim:read", "mpim:history", "users:read", "users:read.email",
"team:read", "reactions:read", "reactions:write",
"files:read", "files:write",
],
userinfo_url="https://slack.com/api/auth.test",
userinfo_field="user",
client_id_env="SLACK_CLIENT_ID",
client_secret_env="SLACK_CLIENT_SECRET",
token_env_mapping={
"access_token": "SLACK_BOT_TOKEN",
},
extra_token_fields={"team.id": "SLACK_TEAM_ID"},
),
"notion": OAuthProvider(
auth_url="https://api.notion.com/v1/oauth/authorize",
token_url="https://api.notion.com/v1/oauth/token",
scopes=[], # Notion doesn't use scopes in the auth URL
userinfo_url=None,
userinfo_field="owner",
client_id_env="NOTION_OAUTH_CLIENT_ID",
client_secret_env="NOTION_OAUTH_CLIENT_SECRET",
token_env_mapping={
"access_token": "OPENAPI_MCP_HEADERS",
},
extra_auth_params={"owner": "user"},
token_auth_method="basic_json",
env_value_transform="notion_headers",
),
"spotify": OAuthProvider(
auth_url="https://accounts.spotify.com/authorize",
token_url="https://accounts.spotify.com/api/token",
scopes=[
"user-read-playback-state", "user-modify-playback-state",
"user-read-currently-playing", "playlist-read-private",
"playlist-modify-public", "playlist-modify-private",
"user-library-read", "user-library-modify",
"user-read-recently-played", "user-top-read",
],
userinfo_url="https://api.spotify.com/v1/me",
userinfo_field="display_name",
client_id_env="SPOTIFY_CLIENT_ID",
client_secret_env="SPOTIFY_CLIENT_SECRET",
token_env_mapping={
"access_token": "SPOTIFY_ACCESS_TOKEN",
"refresh_token": "SPOTIFY_REFRESH_TOKEN",
"_client_id": "SPOTIFY_CLIENT_ID",
"_client_secret": "SPOTIFY_CLIENT_SECRET",
},
token_auth_method="basic",
),
"figma": OAuthProvider(
auth_url="https://www.figma.com/oauth",
token_url="https://api.figma.com/v1/oauth/token",
scopes=["current_user:read", "file_content:read", "file_metadata:read", "file_comments:read", "file_comments:write", "file_versions:read", "file_variables:read"],
userinfo_url="https://api.figma.com/v1/me",
userinfo_field="email",
client_id_env="FIGMA_CLIENT_ID",
client_secret_env="FIGMA_CLIENT_SECRET",
token_env_mapping={
"access_token": "FIGMA_API_KEY",
},
),
"airtable": OAuthProvider(
auth_url="https://airtable.com/oauth2/v1/authorize",
token_url="https://airtable.com/oauth2/v1/token",
scopes=[
"data.records:read", "data.records:write",
"data.recordComments:read", "data.recordComments:write",
"schema.bases:read", "schema.bases:write",
"user.email:read", "webhook:manage",
],
userinfo_url="https://api.airtable.com/v0/meta/whoami",
userinfo_field="email",
client_id_env="AIRTABLE_CLIENT_ID",
client_secret_env="AIRTABLE_CLIENT_SECRET",
token_env_mapping={
"access_token": "AIRTABLE_API_KEY",
},
pkce_required=True,
token_auth_method="basic",
),
"hubspot": OAuthProvider(
auth_url="https://mcp-na2.hubspot.com/oauth/authorize/user",
token_url="https://api.hubapi.com/oauth/v1/token",
scopes=[], # MCP Auth Apps have preconfigured scopes
userinfo_url=None, # HubSpot userinfo requires token-in-path, handle separately
userinfo_field="user",
client_id_env="HUBSPOT_CLIENT_ID",
client_secret_env="HUBSPOT_CLIENT_SECRET",
token_env_mapping={
"access_token": "PRIVATE_APP_ACCESS_TOKEN",
"refresh_token": "HUBSPOT_REFRESH_TOKEN",
},
pkce_required=True,
),
}
def _resolve_oauth_provider(tool: ToolDefinition) -> OAuthProvider:
"""Resolve the OAuth provider for a tool, defaulting to Google for backward compat."""
key = tool.oauth_provider or "google"
provider = OAUTH_PROVIDERS.get(key)
if not provider:
raise HTTPException(status_code=400, detail=f"Unknown OAuth provider: {key}")
return provider
_pending_oauth: dict[str, str] = {}
_pending_pkce: dict[str, str] = {} # state -> code_verifier (for PKCE flows)
def _load_all() -> list[ToolDefinition]:
@@ -122,49 +318,108 @@ async def list_tools():
@tools_lib.router.get("/oauth/callback")
async def oauth_callback(code: str = Query(...), state: str = Query("")):
# Backward compat: old state was just tool_id, new state is "provider:tool_id"
tool_id = _pending_oauth.pop(state, None)
if not tool_id:
# Try legacy format (state = tool_id directly)
tool_id = _pending_oauth.pop(state.split(":")[-1] if ":" in state else state, None)
if not tool_id:
return HTMLResponse("<html><body><h2>Invalid OAuth state</h2></body></html>", status_code=400)
tool = _load(tool_id)
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
provider = _resolve_oauth_provider(tool)
client_id = os.environ.get(provider.client_id_env, "")
client_secret = os.environ.get(provider.client_secret_env, "")
_port = os.environ.get("OPENSWARM_PORT", "8324")
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
# Build token exchange request based on provider's auth method
token_data: dict[str, str] = {
"code": code,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
}
headers: dict[str, str] = {}
if provider.token_auth_method == "basic":
# Spotify, etc: Basic Auth header, credentials in form body
creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
headers["Authorization"] = f"Basic {creds}"
elif provider.token_auth_method == "basic_json":
# Notion: Basic Auth header, JSON body
creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
headers["Authorization"] = f"Basic {creds}"
headers["Content-Type"] = "application/json"
else:
# Default: credentials as form data fields
token_data["client_id"] = client_id
token_data["client_secret"] = client_secret
# GitHub requires Accept: application/json
if (tool.oauth_provider or "google") == "github":
headers["Accept"] = "application/json"
# PKCE: include code_verifier if we stored one
code_verifier = _pending_pkce.pop(state, None)
if code_verifier:
token_data["code_verifier"] = code_verifier
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(GOOGLE_TOKEN_URL, data={
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
})
if provider.token_auth_method == "basic_json":
resp = await client.post(provider.token_url, json=token_data, headers=headers)
else:
resp = await client.post(provider.token_url, data=token_data, headers=headers)
if resp.status_code != 200:
logger.warning(f"OAuth token exchange failed: {resp.text}")
return HTMLResponse(f"<html><body><h2>Token exchange failed</h2><pre>{resp.text}</pre></body></html>", status_code=400)
tokens = resp.json()
# Extract access_token, handling nested responses (e.g., Slack)
access_token = tokens.get("access_token", "")
if provider.token_response_path and not access_token:
# Walk nested path like "authed_user.access_token"
obj = tokens
for part in provider.token_response_path.split("."):
obj = obj.get(part, {}) if isinstance(obj, dict) else ""
if isinstance(obj, str) and obj:
access_token = obj
tool.oauth_tokens = {
"access_token": access_token,
"refresh_token": tokens.get("refresh_token", ""),
"token_expiry": time.time() + tokens.get("expires_in", 3600),
}
# Extract extra fields (e.g., Slack team_id)
for response_path, env_var in provider.extra_token_fields.items():
obj: Any = tokens
for part in response_path.split("."):
obj = obj.get(part, "") if isinstance(obj, dict) else ""
if obj:
tool.oauth_tokens[env_var] = str(obj)
tool.auth_status = "connected"
if access_token:
if access_token and provider.userinfo_url:
try:
async with httpx.AsyncClient(timeout=10.0) as info_client:
info_resp = await info_client.get(
GOOGLE_USERINFO_URL,
provider.userinfo_url,
headers={"Authorization": f"Bearer {access_token}"},
)
if info_resp.status_code == 200:
tool.connected_account_email = info_resp.json().get("email")
tool.connected_account_email = info_resp.json().get(provider.userinfo_field)
except Exception as e:
logger.warning(f"Failed to fetch Google userinfo: {e}")
logger.warning(f"Failed to fetch userinfo for {tool.oauth_provider or 'google'}: {e}")
# Notion: extract workspace name from token response
if (tool.oauth_provider or "google") == "notion" and not tool.connected_account_email:
workspace_name = tokens.get("workspace_name")
if workspace_name:
tool.connected_account_email = workspace_name
_save(tool)
@@ -195,84 +450,17 @@ async def create_tool(body: ToolCreate):
credentials=body.credentials,
auth_type=body.auth_type,
auth_status=body.auth_status,
oauth_provider=body.oauth_provider,
)
_save(tool)
return {"ok": True, "tool": tool.model_dump()}
_XBIRD_CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "xbird")
_XBIRD_CONFIG_PATH = os.path.join(_XBIRD_CONFIG_DIR, "config.json")
async def _fetch_twitter_screen_name(auth_token: str, ct0: str) -> str | None:
"""Fetch the logged-in Twitter/X screen name using session cookies."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
"https://api.twitter.com/1.1/account/verify_credentials.json",
headers={"x-csrf-token": ct0},
cookies={"auth_token": auth_token, "ct0": ct0},
)
if resp.status_code == 200:
screen_name = resp.json().get("screen_name")
return f"@{screen_name}" if screen_name else None
except Exception as e:
logger.warning("Failed to fetch Twitter screen name: %s", e)
return None
def _sync_external_config(tool: ToolDefinition):
"""Write credentials to external config files for tools that need them.
xbird reads auth from ~/.config/xbird/config.json rather than env vars,
so we sync credentials there when the user connects via the UI.
"""
if tool.name == "xbird" and tool.credentials:
auth_token = tool.credentials.get("TWITTER_AUTH_TOKEN", "")
ct0 = tool.credentials.get("TWITTER_CT0", "")
if auth_token and ct0:
os.makedirs(_XBIRD_CONFIG_DIR, exist_ok=True)
config = {}
if os.path.exists(_XBIRD_CONFIG_PATH):
try:
with open(_XBIRD_CONFIG_PATH) as f:
config = json.load(f)
except Exception:
pass
config["auth_token"] = auth_token
config["ct0"] = ct0
with open(_XBIRD_CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
os.chmod(_XBIRD_CONFIG_PATH, 0o600)
logger.info("Synced xbird credentials to %s", _XBIRD_CONFIG_PATH)
elif tool.name == "xbird" and not tool.credentials:
if os.path.exists(_XBIRD_CONFIG_PATH):
try:
with open(_XBIRD_CONFIG_PATH) as f:
config = json.load(f)
config.pop("auth_token", None)
config.pop("ct0", None)
with open(_XBIRD_CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
logger.info("Cleared xbird credentials from %s", _XBIRD_CONFIG_PATH)
except Exception:
pass
@tools_lib.router.put("/{tool_id}")
async def update_tool(tool_id: str, body: ToolUpdate):
tool = _load(tool_id)
for k, v in body.model_dump(exclude_none=True).items():
setattr(tool, k, v)
_sync_external_config(tool)
if tool.name == "xbird" and tool.auth_status == "connected" and tool.credentials:
auth_token = tool.credentials.get("TWITTER_AUTH_TOKEN", "")
ct0 = tool.credentials.get("TWITTER_CT0", "")
if auth_token and ct0:
screen_name = await _fetch_twitter_screen_name(auth_token, ct0)
if screen_name:
tool.connected_account_email = screen_name
_save(tool)
return {"ok": True, "tool": tool.model_dump()}
@@ -390,15 +578,37 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
headers["Authorization"] = f"Bearer {tool.oauth_tokens['access_token']}"
else:
env = config.setdefault("env", {})
env["OAUTH_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
if tool.oauth_tokens.get("refresh_token"):
env["GOOGLE_WORKSPACE_REFRESH_TOKEN"] = tool.oauth_tokens["refresh_token"]
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
if client_id:
env["GOOGLE_WORKSPACE_CLIENT_ID"] = client_id
if client_secret:
env["GOOGLE_WORKSPACE_CLIENT_SECRET"] = client_secret
provider_key = tool.oauth_provider or "google"
provider = OAUTH_PROVIDERS.get(provider_key)
if provider:
for token_field, env_var in provider.token_env_mapping.items():
if token_field.startswith("_client_id"):
val = os.environ.get(provider.client_id_env, "")
elif token_field.startswith("_client_secret"):
val = os.environ.get(provider.client_secret_env, "")
else:
val = tool.oauth_tokens.get(token_field, "")
if val:
# Apply value transforms (e.g., Notion needs JSON headers)
if provider.env_value_transform == "notion_headers" and token_field == "access_token":
val = json.dumps({
"Authorization": f"Bearer {val}",
"Notion-Version": "2022-06-28",
})
env[env_var] = val
# Inject extra token fields (e.g., Slack SLACK_TEAM_ID)
for _, env_var in provider.extra_token_fields.items():
val = tool.oauth_tokens.get(env_var, "")
if val:
env[env_var] = val
# Figma: inject token as CLI arg (it doesn't read env vars)
if provider_key == "figma" and tool.oauth_tokens.get("access_token"):
args = config.get("args", [])
if "--figma-api-key" not in args:
config["args"] = args + ["--figma-api-key", tool.oauth_tokens["access_token"]]
else:
# Fallback: inject generic access token
env["OAUTH_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
if config.get("type") == "stdio":
if config.get("command"):
@@ -455,22 +665,86 @@ _SERVICE_RULES: list[tuple[list[str], str, str]] = [
(["contact"], "Contacts", "Google"),
(["script", "deployment", "version", "trigger"], "Apps Script", "Google"),
(["search_custom", "search_engine"], "Search", "Google"),
# Reddit (before Twitter so "search_reddit" etc. don't mis-match)
# Reddit
(["subreddit"], "Subreddits", "Reddit"),
(["search_reddit"], "Search", "Reddit"),
(["post_detail"], "Posts", "Reddit"),
(["user_analysis"], "Users", "Reddit"),
(["reddit_explain"], "Reference", "Reddit"),
# Twitter / X
(["tweet", "thread", "reply", "replies", "quote", "retweet", "article"], "Tweets", "Twitter"),
(["timeline", "home", "news", "trending"], "Timeline", "Twitter"),
(["follower", "following", "follow", "unfollow"], "Network", "Twitter"),
(["like", "unlike", "bookmark"], "Engagement", "Twitter"),
(["mention"], "Mentions", "Twitter"),
(["user", "profile"], "Users", "Twitter"),
(["media", "upload", "image", "video"], "Media", "Twitter"),
(["search"], "Search", "Twitter"),
(["list", "list_member"], "Lists", "Twitter"),
# Sequential Thinking
(["sequentialthinking", "thinking"], "Thinking", "Sequential Thinking"),
# Memory (knowledge graph)
(["create_entities", "create_relations", "add_observations", "delete_entities",
"delete_observations", "delete_relations", "read_graph", "search_nodes",
"open_nodes"], "Knowledge Graph", "Memory"),
# Filesystem
(["read_file", "read_multiple_files", "write_file", "edit_file",
"create_directory", "list_directory", "directory_tree", "move_file",
"search_files", "get_file_info", "list_allowed_directories"], "Files", "Filesystem"),
# Playwright
(["browser_navigate", "browser_screenshot", "browser_click", "browser_fill",
"browser_select", "browser_hover", "browser_evaluate", "browser_console",
"browser_tab", "browser_close", "browser_resize", "browser_snapshot",
"browser_wait", "browser_pdf", "browser_drag"], "Browser", "Playwright"),
# Git
(["git_status", "git_diff", "git_diff_unstaged", "git_diff_staged",
"git_commit", "git_log", "git_add", "git_reset", "git_show",
"git_create_branch", "git_checkout", "git_list_branches", "git_init",
"git_clone"], "Repository", "Git"),
# YouTube Transcripts
(["get_transcript"], "Transcripts", "YouTube"),
# Desktop Commander
(["execute_command", "read_output", "force_terminate", "list_sessions",
"list_processes", "kill_process", "block_command", "unblock_command",
"read_file", "write_file", "search_code", "list_directory",
"get_file_info", "edit_block"], "System", "Desktop Commander"),
# GitHub
(["repository", "issue", "pull_request", "commit", "branch", "fork", "star",
"create_issue", "list_issues", "get_issue", "create_pull_request",
"list_commits", "search_repositories", "create_repository",
"get_file_contents", "push_files", "create_branch",
"search_code", "search_issues"], "Repository", "GitHub"),
# Slack
(["channel", "slack_message", "thread", "reply", "workspace",
"list_channels", "post_message", "reply_to_thread", "search_messages",
"get_channel_history", "get_thread_replies", "get_users",
"get_user_profile"], "Messaging", "Slack"),
# Notion
(["notion_page", "database", "block", "create_page", "update_page",
"search_pages", "get_page", "get_database", "query_database",
"create_database", "append_block_children"], "Pages", "Notion"),
# Spotify
(["play", "pause", "skip", "playlist", "track", "album", "artist",
"search_tracks", "get_playlist", "get_currently_playing",
"add_to_playlist", "create_playlist", "get_recommendations",
"get_top_items"], "Music", "Spotify"),
# Figma
(["figma", "design", "component", "style", "node",
"get_file", "get_file_nodes", "get_image", "get_comments",
"get_team_projects", "get_project_files"], "Design", "Figma"),
# Airtable
(["airtable", "base", "record", "field", "view",
"list_records", "get_record", "create_record", "update_record",
"delete_record", "list_bases", "get_base_schema"], "Data", "Airtable"),
# HubSpot
(["hubspot", "contact", "deal", "company", "ticket", "pipeline",
"crm", "engagement", "association"], "CRM", "HubSpot"),
# Discord
(["discord", "guild", "server", "channel_message", "send_message",
"get_messages", "get_guilds", "get_channels", "add_reaction"], "Messaging", "Discord"),
# Twitter / X (TweetSave)
(["tweetsave", "get_tweet", "get_thread", "to_blog", "batch",
"extract_media"], "Tweets", "Twitter"),
# Shopify Dev
(["shopify", "introspect", "graphql", "search_dev_docs", "liquid",
"polaris", "admin_api", "storefront_api"], "Developer", "Shopify"),
# Zoom
(["zoom", "meeting", "recording", "participant", "webinar",
"create_meeting", "list_meetings", "get_meeting", "delete_meeting",
"update_meeting"], "Meetings", "Zoom"),
# Microsoft 365
(["outlook", "onedrive", "ms365", "microsoft", "mail_folder",
"email", "calendar_event", "contact", "drive_item"], "Mail & Files", "Microsoft 365"),
]
@@ -597,6 +871,7 @@ async def _discover_mcp_tools_stdio(command: str, args: list[str] | None = None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=proc_env,
limit=1024 * 1024, # 1MB buffer for large MCP responses (e.g., MS365 with 87 tools)
)
async def _send(msg: dict) -> None:
@@ -765,15 +1040,17 @@ async def oauth_disconnect(tool_id: str):
access_token = tool.oauth_tokens.get("access_token")
if access_token:
provider = _resolve_oauth_provider(tool)
revoke_url = provider.revoke_url or "https://oauth2.googleapis.com/revoke"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await client.post(
"https://oauth2.googleapis.com/revoke",
revoke_url,
params={"token": access_token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
except Exception as e:
logger.warning(f"Failed to revoke Google token for tool {tool.id}: {e}")
logger.warning(f"Failed to revoke token for tool {tool.id}: {e}")
tool.oauth_tokens = {}
tool.auth_status = "configured"
@@ -784,14 +1061,17 @@ async def oauth_disconnect(tool_id: str):
@tools_lib.router.post("/{tool_id}/oauth/start")
async def oauth_start(tool_id: str):
_load(tool_id)
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
tool = _load(tool_id)
provider = _resolve_oauth_provider(tool)
client_id = os.environ.get(provider.client_id_env, "")
if not client_id:
raise HTTPException(status_code=400, detail="GOOGLE_OAUTH_CLIENT_ID not set in backend .env")
raise HTTPException(status_code=400, detail=f"{provider.client_id_env} not set in backend .env")
_port = os.environ.get("OPENSWARM_PORT", "8324")
redirect_uri = f"http://localhost:{_port}/api/tools/oauth/callback"
state = tool_id
provider_key = tool.oauth_provider or "google"
state = f"{provider_key}:{tool_id}"
_pending_oauth[state] = tool_id
@@ -799,19 +1079,28 @@ async def oauth_start(tool_id: str):
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(GOOGLE_SCOPES),
"access_type": "offline",
"prompt": "consent",
"state": state,
**provider.extra_auth_params,
}
auth_url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
if provider.scopes:
params["scope"] = " ".join(provider.scopes)
# PKCE support (required by Airtable, etc.)
if provider.pkce_required:
code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
params["code_challenge"] = code_challenge
params["code_challenge_method"] = "S256"
_pending_pkce[state] = code_verifier
auth_url = f"{provider.auth_url}?{urlencode(params)}"
return {"auth_url": auth_url}
async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
"""Refresh an expired Google OAuth token. Returns the fresh access_token or None."""
async def refresh_oauth_token(tool: ToolDefinition) -> Optional[str]:
"""Refresh an expired OAuth token. Returns the fresh access_token or None."""
if tool.auth_type != "oauth2":
return None
refresh_token = tool.oauth_tokens.get("refresh_token")
@@ -821,14 +1110,15 @@ async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
if time.time() < expiry - 60:
return tool.oauth_tokens.get("access_token")
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
provider = _resolve_oauth_provider(tool)
client_id = os.environ.get(provider.client_id_env, "")
client_secret = os.environ.get(provider.client_secret_env, "")
if not client_id or not client_secret:
return None
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(GOOGLE_TOKEN_URL, data={
resp = await client.post(provider.token_url, data={
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
@@ -840,20 +1130,24 @@ async def refresh_google_token(tool: ToolDefinition) -> Optional[str]:
tool.oauth_tokens["access_token"] = new_token
tool.oauth_tokens["token_expiry"] = time.time() + data.get("expires_in", 3600)
if not tool.connected_account_email:
if not tool.connected_account_email and provider.userinfo_url:
try:
async with httpx.AsyncClient(timeout=10.0) as info_client:
info_resp = await info_client.get(
GOOGLE_USERINFO_URL,
provider.userinfo_url,
headers={"Authorization": f"Bearer {new_token}"},
)
if info_resp.status_code == 200:
tool.connected_account_email = info_resp.json().get("email")
tool.connected_account_email = info_resp.json().get(provider.userinfo_field)
except Exception:
pass
_save(tool)
return new_token
except Exception as e:
logger.warning(f"Google token refresh failed for tool {tool.id}: {e}")
logger.warning(f"OAuth token refresh failed for tool {tool.id}: {e}")
return None
# Backward-compatible alias
refresh_google_token = refresh_oauth_token
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openswarm",
"version": "1.0.18",
"version": "1.0.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.0.18",
"version": "1.0.20",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "^6.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openswarm",
"version": "1.0.19",
"version": "1.0.20",
"description": "OpenSwarm — AI Agent Orchestrator",
"main": "main.js",
"scripts": {
+154 -5
View File
@@ -1,9 +1,22 @@
import React, { useState, useEffect, useRef } from 'react';
import { Box, Typography, Modal, Button, CircularProgress } from '@mui/material';
import LinkIcon from '@mui/icons-material/Link';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useAppSelector } from '@/shared/hooks';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { API_BASE } from '@/shared/config';
const ONBOARDING_TOOL_INTEGRATIONS = [
{ name: 'Google Workspace', desc: 'Gmail, Calendar, Drive, Docs, Sheets', color: '#4285F4', oauthProvider: 'google',
mcp_config: { type: 'stdio', command: 'uvx', args: ['--from', 'google-workspace-mcp', 'google-workspace-worker'] } },
{ name: 'GitHub', desc: 'Repos, issues, pull requests', color: '#24292E', oauthProvider: 'github',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] } },
{ name: 'Slack', desc: 'Channels, messages, search', color: '#4A154B', oauthProvider: 'slack',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack'] } },
{ name: 'Notion', desc: 'Pages, databases, search', color: '#000000', oauthProvider: 'notion',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@notionhq/notion-mcp-server'] } },
];
const SUBSCRIPTION_PROVIDERS = [
{ id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true },
@@ -15,8 +28,10 @@ const OnboardingModal: React.FC = () => {
const c = useClaudeTokens();
const settings = useAppSelector((s) => s.settings);
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'provider' | 'tools'>('provider');
const [connecting, setConnecting] = useState<string | null>(null);
const [nineRouterReady, setNineRouterReady] = useState<boolean | null>(null);
const [connectedTools, setConnectedTools] = useState<Set<string>>(new Set());
const pollTimerRef = useRef<any>(null);
const msgHandlerRef = useRef<any>(null);
@@ -121,7 +136,7 @@ const OnboardingModal: React.FC = () => {
if (pd.success) {
clearInterval(timer);
pollTimerRef.current = null;
dismiss();
advanceToTools();
}
} catch {}
}, 5000);
@@ -144,7 +159,7 @@ const OnboardingModal: React.FC = () => {
window.removeEventListener('message', msgHandlerRef.current);
msgHandlerRef.current = null;
}
dismiss();
advanceToTools();
}
} catch {}
}, 2000);
@@ -172,7 +187,7 @@ const OnboardingModal: React.FC = () => {
}),
});
} catch {}
dismiss();
advanceToTools();
}
};
window.addEventListener('message', msgHandler);
@@ -192,8 +207,78 @@ const OnboardingModal: React.FC = () => {
}
};
const handleApiKey = () => dismiss();
const handleSkip = () => dismiss();
const advanceToTools = () => {
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
setConnecting(null);
setStep('tools');
};
const handleToolConnect = async (integration: typeof ONBOARDING_TOOL_INTEGRATIONS[0]) => {
setConnecting(integration.name);
try {
// Create the tool
const createRes = await fetch(`${API_BASE}/tools/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: integration.name,
description: integration.desc,
mcp_config: integration.mcp_config,
auth_type: 'oauth2',
auth_status: 'configured',
oauth_provider: integration.oauthProvider,
}),
});
if (!createRes.ok) { setConnecting(null); return; }
const { tool } = await createRes.json();
// Start OAuth
const oauthRes = await fetch(`${API_BASE}/tools/${tool.id}/oauth/start`, { method: 'POST' });
if (!oauthRes.ok) { setConnecting(null); return; }
const { auth_url } = await oauthRes.json();
// Open popup
const popup = window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100');
// Listen for completion
const onMsg = (event: MessageEvent) => {
if (event.data?.type === 'oauth_complete' && event.data?.tool_id === tool.id) {
window.removeEventListener('message', onMsg);
setConnectedTools((prev) => new Set(prev).add(integration.name));
setConnecting(null);
// Trigger discovery in background
fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {});
}
};
window.addEventListener('message', onMsg);
// Fallback: poll for popup close
const poller = setInterval(() => {
if (popup && popup.closed) {
clearInterval(poller);
window.removeEventListener('message', onMsg);
// Check if connected
fetch(`${API_BASE}/tools/${tool.id}`)
.then(r => r.json())
.then(data => {
if (data.tool?.auth_status === 'connected') {
setConnectedTools((prev) => new Set(prev).add(integration.name));
fetch(`${API_BASE}/tools/${tool.id}/discover`, { method: 'POST' }).catch(() => {});
}
})
.catch(() => {});
setConnecting(null);
}
}, 1000);
setTimeout(() => { clearInterval(poller); setConnecting(null); }, 60000);
} catch {
setConnecting(null);
}
};
const handleApiKey = () => advanceToTools();
const handleSkip = () => step === 'tools' ? dismiss() : dismiss();
if (!open) return null;
@@ -204,6 +289,68 @@ const OnboardingModal: React.FC = () => {
border: `1px solid ${c.border.subtle}`, p: 3.5, outline: 'none',
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
}}>
{step === 'tools' ? (
<>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Connect Your Accounts
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, mb: 0.5, textAlign: 'center' }}>
10+ tools already active with no setup needed
</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.ghost, mb: 3, textAlign: 'center' }}>
Connect services below for even more capabilities
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 2.5 }}>
{ONBOARDING_TOOL_INTEGRATIONS.map((ig) => {
const isConnected = connectedTools.has(ig.name);
const isConnecting = connecting === ig.name;
return (
<Box
key={ig.name}
onClick={() => !isConnected && !isConnecting && !connecting && handleToolConnect(ig)}
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
p: 1.5, borderRadius: `${c.radius.md}px`,
border: `1px solid ${isConnected ? `${ig.color}40` : c.border.subtle}`,
cursor: isConnected ? 'default' : connecting ? 'wait' : 'pointer',
bgcolor: isConnected ? `${ig.color}08` : 'transparent',
transition: 'border-color 0.15s, background 0.15s',
...(!isConnected && !connecting && { '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}05` } }),
}}
>
<Box>
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.primary }}>{ig.name}</Typography>
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted }}>{ig.desc}</Typography>
</Box>
{isConnected ? (
<CheckCircleIcon sx={{ fontSize: 18, color: ig.color }} />
) : (
<Typography sx={{ fontSize: '0.68rem', color: isConnecting ? ig.color : c.text.tertiary }}>
{isConnecting ? 'Connecting...' : 'Connect \u2192'}
</Typography>
)}
</Box>
);
})}
</Box>
<Button
onClick={dismiss}
fullWidth
variant={connectedTools.size > 0 ? 'contained' : 'text'}
sx={{
textTransform: 'none', fontSize: '0.78rem', borderRadius: `${c.radius.md}px`,
...(connectedTools.size > 0
? { bgcolor: c.accent.primary, color: '#fff', '&:hover': { bgcolor: c.accent.hover } }
: { color: c.text.ghost, '&:hover': { bgcolor: 'transparent', color: c.text.muted } }),
}}
>
{connectedTools.size > 0 ? 'Done' : 'Skip for now'}
</Button>
</>
) : (
<>
<Typography sx={{ fontSize: '1.3rem', fontWeight: 700, color: c.text.primary, mb: 0.5, textAlign: 'center' }}>
Welcome to OpenSwarm
</Typography>
@@ -268,6 +415,8 @@ const OnboardingModal: React.FC = () => {
>
Skip for now
</Button>
</>
)}
</Box>
</Modal>
);
+57 -4
View File
@@ -190,6 +190,9 @@ interface Props {
autoFocusInput?: boolean;
cardZOrder?: number;
onBringToFront?: (id: string, type: 'agent' | 'view' | 'browser') => void;
isFocused?: boolean;
onFocusRequest?: (sessionId: string) => void;
onFocusExit?: () => void;
}
const MIN_W = 480;
@@ -207,6 +210,7 @@ const AgentCard: React.FC<Props> = ({
session, expanded, cardX, cardY, cardWidth, cardHeight, zoom = 1, spawnFrom, exitTarget,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
onBranch, onMeasuredHeight, snapColumn, autoFocusInput, cardZOrder = 0, onBringToFront,
isFocused = false, onFocusRequest, onFocusExit,
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -463,6 +467,51 @@ const AgentCard: React.FC<Props> = ({
}
: { opacity: 0, scale: 0.85, transition: { duration: 0.2 } };
if (isFocused) {
// Focus mode: render as a simple box filling its container (outside canvas transform)
return (
<Box
ref={cardBoxRef}
sx={{
width: '100%',
height: '100%',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.strong}`,
borderRadius: 3,
p: 2,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 24px 80px rgba(0,0,0,0.5)',
}}
>
{/* Header with close button */}
<Box
onDoubleClick={(e) => { e.stopPropagation(); onFocusExit?.(); }}
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
mb: 1, flexShrink: 0, cursor: 'default',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{session.name || 'Agent'}
</Typography>
<Chip label={session.status} size="small" sx={{ fontSize: '0.7rem', height: 20, bgcolor: session.status === 'running' ? c.status.info : session.status === 'completed' ? c.status.success : c.bg.elevated, color: c.text.secondary }} />
<Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>{session.model} · {formatDuration(session.created_at, undefined, session.status)}</Typography>
</Box>
<IconButton size="small" onClick={() => onFocusExit?.()} sx={{ color: c.text.ghost }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
{/* Chat fills remaining space */}
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<AgentChat sessionId={session.id} autoFocus={true} embedded={true} />
</Box>
</Box>
);
}
return (
<motion.div
layout={false}
@@ -481,7 +530,7 @@ const AgentCard: React.FC<Props> = ({
data-select-type="agent-card"
data-select-id={session.id}
data-select-meta={JSON.stringify({ name: session.name || session.id, status: session.status, model: session.model, mode: session.mode })}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
if (!isSelected && !e.shiftKey) {
@@ -642,8 +691,8 @@ const AgentCard: React.FC<Props> = ({
</Box>
)}
{/* Resize handles: 4 edges + 4 corners */}
{HANDLE_DEFS.map(({ dir, sx }) => (
{/* Resize handles: 4 edges + 4 corners (hidden in focus mode) */}
{!isFocused && HANDLE_DEFS.map(({ dir, sx }) => (
<Box
key={dir}
onPointerDown={handleResizeDown(dir)}
@@ -687,6 +736,10 @@ const AgentCard: React.FC<Props> = ({
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={handleDragPointerUp}
onDoubleClick={(e) => {
e.stopPropagation();
onFocusRequest?.(session.id);
}}
sx={{
position: 'relative',
zIndex: 16,
@@ -695,7 +748,7 @@ const AgentCard: React.FC<Props> = ({
px: 2,
pt: 2,
pb: 1.5,
cursor: isDragging ? 'grabbing' : 'grab',
cursor: isFocused ? 'default' : isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
userSelect: 'none',
flexShrink: 0,
+113 -3
View File
@@ -117,6 +117,7 @@ const DashboardInner: React.FC = () => {
const toolbarRef = useRef<HTMLDivElement>(null);
const [toolbarOpen, setToolbarOpen] = useState(false);
const [focusedCardId, setFocusedCardId] = useState<string | null>(null);
const [highlightedCardId, setHighlightedCardId] = useState<string | null>(null);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [autoFocusSessionId, setAutoFocusSessionId] = useState<string | null>(null);
@@ -531,6 +532,66 @@ const DashboardInner: React.FC = () => {
return () => window.removeEventListener('keydown', handleEnter);
}, [selection.selectedIds, dispatch]);
// Focus mode: pop a card out as full-viewport overlay
const handleFocusRequest = useCallback((sessionId: string) => {
// Auto-expand if collapsed
if (!expandedSessionIds.includes(sessionId)) {
dispatch(toggleExpandSession(sessionId));
}
setFocusedCardId(sessionId);
}, [expandedSessionIds, dispatch]);
const handleFocusExit = useCallback(() => {
setFocusedCardId(null);
}, []);
// Focus mode keyboard: Escape to exit, F to enter
useEffect(() => {
const handleFocusKeys = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
if (e.key === 'Escape' && focusedCardId) {
e.preventDefault();
setFocusedCardId(null);
return;
}
if ((e.key === 'f' || e.key === 'F') && !e.ctrlKey && !e.metaKey && !focusedCardId) {
if (selection.selectedIds.size !== 1) return;
const [id, type] = selection.selectedIds.entries().next().value!;
if (type !== 'agent') return;
e.preventDefault();
handleFocusRequest(id);
}
};
window.addEventListener('keydown', handleFocusKeys);
return () => window.removeEventListener('keydown', handleFocusKeys);
}, [focusedCardId, selection.selectedIds, handleFocusRequest]);
// Auto-zoom to card when it gets expanded (not on initial load)
const prevExpandedRef = useRef<string[]>([]);
useEffect(() => {
if (!layoutInitialized) {
prevExpandedRef.current = expandedSessionIds;
return;
}
const prev = new Set(prevExpandedRef.current);
const newlyExpanded = expandedSessionIds.filter((id) => !prev.has(id));
prevExpandedRef.current = expandedSessionIds;
// Only auto-zoom for single-card expansions (not bulk restore)
if (newlyExpanded.length !== 1) return;
const cardId = newlyExpanded[0];
const card = cards[cardId];
if (!card) return;
setTimeout(() => {
const height = Math.max(EXPANDED_CARD_MIN_H, measuredHeightsRef.current[cardId] || card.height);
canvas.actions.fitToCards([{ x: card.x, y: card.y, width: card.width, height }], 2.0, true);
}, 200);
}, [expandedSessionIds, layoutInitialized, cards, canvas.actions]);
useEffect(() => {
const handleDelete = (e: KeyboardEvent) => {
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
@@ -1303,6 +1364,8 @@ const DashboardInner: React.FC = () => {
{Object.values(cards).map((card) => {
const session = sessions[card.session_id];
if (!session) return null;
// Skip focused card here — rendered outside the canvas transform
if (focusedCardId === session.id) return null;
let origin = spawnOriginsRef.current[session.id];
if (origin) {
@@ -1378,6 +1441,9 @@ const DashboardInner: React.FC = () => {
snapColumn={snapColumn}
autoFocusInput={autoFocusSessionId === session.id}
onBringToFront={handleBringToFront}
isFocused={focusedCardId === session.id}
onFocusRequest={handleFocusRequest}
onFocusExit={handleFocusExit}
/>
);
})}
@@ -1467,9 +1533,53 @@ const DashboardInner: React.FC = () => {
</Box>
{/* Floating zoom controls */}
<Box sx={{ position: 'absolute', bottom: 16, right: 16, zIndex: 10 }}>
<CanvasControls zoom={canvas.zoom} actions={canvas.actions} onTidy={handleTidy} />
</Box>
{!focusedCardId && (
<Box sx={{ position: 'absolute', bottom: 16, right: 16, zIndex: 10 }}>
<CanvasControls zoom={canvas.zoom} actions={canvas.actions} onTidy={handleTidy} />
</Box>
)}
{/* Focus mode: backdrop + card rendered outside canvas transform */}
{focusedCardId && (() => {
const focusedCard = cards[focusedCardId];
const focusedSession = focusedCard ? sessions[focusedCard.session_id] : null;
if (!focusedSession || !focusedCard) return null;
return (
<>
<Box
onClick={handleFocusExit}
sx={{
position: 'fixed',
inset: 0,
bgcolor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1200,
cursor: 'pointer',
}}
/>
<Box sx={{ position: 'fixed', inset: 48, zIndex: 1250 }}>
<AgentCard
session={focusedSession}
expanded={true}
cardX={0}
cardY={0}
cardWidth={0}
cardHeight={0}
cardZOrder={100000}
zoom={1}
isSelected={false}
isHighlighted={false}
onCardSelect={() => {}}
onMeasuredHeight={() => {}}
onBringToFront={() => {}}
isFocused={true}
onFocusRequest={handleFocusRequest}
onFocusExit={handleFocusExit}
autoFocusInput={true}
/>
</Box>
</>
);
})()}
</Box>
</>
);
@@ -1,10 +1,10 @@
import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react';
const MIN_ZOOM = 0.15;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 3.0;
const ZOOM_IN_FACTOR = 1.1;
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
const FIT_PADDING = 200;
const FIT_PADDING = 80;
// Maps the 1100 user setting to an internal multiplier.
// 50 (default) → 0.004, 1 → 0.0004, 100 → 0.008
@@ -80,7 +80,11 @@ export function useCanvasControls(zoomSensitivity: number = 50) {
setState((prev) => {
const delta = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
const factor = Math.pow(2, -delta * sensitivityToMultiplier(sensitivityRef.current));
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
let newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
// Snap to 100% when crossing through the 0.971.03 band (trackpad only)
if (newZoom > 0.97 && newZoom < 1.03 && (prev.zoom <= 0.97 || prev.zoom >= 1.03)) {
newZoom = 1.0;
}
const ratio = newZoom / prev.zoom;
return {
panX: cx - (cx - prev.panX) * ratio,
+435 -40
View File
@@ -94,6 +94,7 @@ interface CredentialField {
label: string;
placeholder: string;
helpText?: string;
optional?: boolean;
}
interface Integration {
@@ -108,28 +109,15 @@ interface Integration {
connectLabel?: string;
connectInstructions?: string;
authType?: 'none' | 'oauth2' | 'env_vars';
oauthProvider?: string;
comingSoon?: boolean;
}
const INTEGRATIONS: Integration[] = [
{
id: 'xbird',
name: 'xbird',
description: 'Twitter/X research — search tweets, read profiles, threads, timelines.',
mcp_config: { type: 'stdio', command: 'bunx', args: ['@checkra1n/xbird'] },
color: '#1DA1F2',
website: 'https://xbird.dev',
icon: '𝕏',
connectLabel: 'Connect 𝕏',
connectInstructions: 'Open x.com in your browser, press F12 → Application → Cookies → x.com, and copy the values for auth_token and ct0.',
credentialFields: [
{ key: 'TWITTER_AUTH_TOKEN', label: 'auth_token', placeholder: 'Paste auth_token cookie value' },
{ key: 'TWITTER_CT0', label: 'ct0', placeholder: 'Paste ct0 cookie value' },
],
},
{
id: 'reddit',
name: 'Reddit',
description: 'Browse subreddits, search posts, get post details, analyze users. No API keys required.',
description: 'Browse subreddits, search posts, get post details, analyze users.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'reddit-mcp-buddy'] },
color: '#FF4500',
website: 'https://github.com/karanb192/reddit-mcp-buddy',
@@ -156,6 +144,336 @@ const INTEGRATIONS: Integration[] = [
</svg>
),
authType: 'oauth2',
oauthProvider: 'google',
},
{
id: 'sequential-thinking',
name: 'Sequential Thinking',
description: 'Dynamic, reflective problem-solving through structured thought sequences.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-sequential-thinking'] },
color: '#8B5CF6',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#8B5CF6"/>
<path d="M12 6a4 4 0 0 0-4 4c0 1.5.8 2.8 2 3.5V15h4v-1.5c1.2-.7 2-2 2-3.5a4 4 0 0 0-4-4zm-1 11h2v1h-2z" fill="#fff"/>
</svg>
),
},
{
id: 'memory',
name: 'Memory',
description: 'Persistent memory using a local knowledge graph. Entities, relations, and observations.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-memory'] },
color: '#06B6D4',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/memory',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#06B6D4"/>
<path d="M12 4C9.2 4 7 6.2 7 9c0 1.9 1 3.5 2.5 4.3V15h5v-1.7C16 12.5 17 10.9 17 9c0-2.8-2.2-5-5-5zm-1.5 13h3v1h-3zm0 2h3v1h-3z" fill="#fff"/>
</svg>
),
},
{
id: 'filesystem',
name: 'Filesystem',
description: 'Read, write, search, and manage local files and directories.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/'] },
color: '#10B981',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#10B981"/>
<path d="M6 6h5l2 2h5v10H6V6zm2 2v8h8V10h-4l-2-2H8z" fill="#fff"/>
</svg>
),
},
{
id: 'playwright',
name: 'Playwright',
description: 'Browser automation — navigate, click, fill forms, take screenshots.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@playwright/mcp'] },
color: '#2EAD33',
website: 'https://github.com/microsoft/playwright-mcp',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#2EAD33"/>
<path d="M7 8h10v8H7V8zm2 2v4h6v-4H9zm1 1h4v2h-4v-2z" fill="#fff"/>
</svg>
),
},
{
id: 'context7',
name: 'Context7',
description: 'Live, up-to-date documentation lookup for libraries and frameworks.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@upstash/context7-mcp@latest'] },
color: '#00E599',
website: 'https://context7.com',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#00E599"/>
<text x="12" y="16.5" textAnchor="middle" fill="#fff" fontSize="12" fontWeight="bold">C7</text>
</svg>
),
},
{
id: 'desktop-commander',
name: 'Desktop Commander',
description: 'Terminal commands, file operations, process management, and diff-based editing.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@wonderwhy-er/desktop-commander'] },
color: '#F59E0B',
website: 'https://github.com/wonderwhy-er/DesktopCommanderMCP',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#F59E0B"/>
<path d="M7 8l4 4-4 4M13 16h4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round"/>
</svg>
),
},
{
id: 'git',
name: 'Git',
description: 'Git operations — log, diff, commit, branch, status, and more on local repositories.',
mcp_config: { type: 'stdio', command: 'uvx', args: ['mcp-server-git'] },
color: '#F05032',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/git',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#F05032"/>
<path d="M12.5 4.5l7 7a.7.7 0 0 1 0 1l-7 7a.7.7 0 0 1-1 0l-7-7a.7.7 0 0 1 0-1l7-7a.7.7 0 0 1 1 0z" fill="none" stroke="#fff" strokeWidth="1.5"/>
<circle cx="12" cy="12" r="1.5" fill="#fff"/>
<circle cx="9" cy="9" r="1.2" fill="#fff"/>
<line x1="10" y1="10" x2="11" y2="11" stroke="#fff" strokeWidth="1"/>
</svg>
),
},
{
id: 'youtube-transcripts',
name: 'YouTube Transcripts',
description: 'Fetch transcripts and captions from YouTube videos.',
comingSoon: true,
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@kimtaeyoon83/mcp-server-youtube-transcript'] },
color: '#FF0000',
website: 'https://github.com/kimtaeyoon83/mcp-server-youtube-transcript',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#FF0000"/>
<path d="M9.5 8.5v7l6-3.5z" fill="#fff"/>
</svg>
),
},
{
id: 'twitter',
name: 'Twitter / X',
description: 'Fetch tweets, threads, and media from Twitter/X. Read-only.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'tweetsave-mcp'] },
color: '#000000',
website: 'https://github.com/zezeron/tweetsave-mcp',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#000"/>
<text x="12" y="16" textAnchor="middle" fill="#fff" fontSize="13" fontWeight="bold">&#x1D54F;</text>
</svg>
),
},
{
id: 'shopify-dev',
name: 'Shopify Dev',
description: 'Search Shopify docs, explore API schemas, and validate GraphQL queries.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@shopify/dev-mcp@latest'] },
color: '#96BF48',
website: 'https://github.com/Shopify/dev-mcp',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#96BF48"/>
<path d="M15.5 5.5l-1.2-.7c-.1-.1-.2-.1-.3 0l-.5.3c-.3-.2-.7-.3-1-.4l-.2-.8c0-.1-.1-.2-.3-.2h-1.4c-.1 0-.2.1-.3.2l-.2.8c-.4.1-.7.2-1 .4l-.5-.3c-.1-.1-.2-.1-.3 0L7.1 5.5c-.1.1-.1.2 0 .3l.4.5c-.1.3-.2.6-.2 1H6.5c-.2 0-.3.1-.3.3v1.4c0 .2.1.3.3.3h.8c.1.4.2.7.4 1l-.4.5c-.1.1-.1.2 0 .3l1 1c.1.1.2.1.3 0l.5-.4c.3.2.6.3 1 .4l.1.8c0 .1.1.3.3.3h1.4c.2 0 .3-.1.3-.3l.1-.8c.4-.1.7-.2 1-.4l.5.4c.1.1.2.1.3 0l1-1c.1-.1.1-.2 0-.3l-.4-.5c.2-.3.3-.6.4-1h.8c.2 0 .3-.1.3-.3V7.6c0-.2-.1-.3-.3-.3h-.8c-.1-.4-.2-.7-.4-1l.4-.5c.1-.1.1-.2 0-.3zM11.3 10a2 2 0 1 1 0-4 2 2 0 0 1 0 4z" fill="#fff" transform="translate(0.7, 3)"/>
</svg>
),
},
// --- OAuth integrations (click Connect → sign in → done) ---
{
id: 'github',
name: 'GitHub',
description: 'Manage repositories, issues, pull requests, branches, and code search.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] },
color: '#24292E',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#24292E"/>
<path d="M12 5C8.13 5 5 8.13 5 12c0 3.1 2 5.7 4.8 6.6.35.07.48-.15.48-.34v-1.2c-1.95.42-2.36-.94-2.36-.94-.32-.81-.78-1.03-.78-1.03-.64-.43.05-.42.05-.42.7.05 1.07.72 1.07.72.63 1.07 1.64.76 2.04.58.06-.45.24-.76.44-.94-1.56-.18-3.2-.78-3.2-3.46 0-.76.27-1.39.72-1.88-.07-.18-.31-.89.07-1.85 0 0 .59-.19 1.93.72a6.7 6.7 0 0 1 3.5 0c1.34-.91 1.93-.72 1.93-.72.38.96.14 1.67.07 1.85.45.49.72 1.12.72 1.88 0 2.69-1.64 3.28-3.2 3.45.25.22.48.65.48 1.3v1.93c0 .19.13.41.48.34C17 17.7 19 15.1 19 12c0-3.87-3.13-7-7-7z" fill="#fff"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'github',
connectLabel: 'Connect GitHub',
},
{
id: 'slack',
name: 'Slack',
description: 'Send messages, read channels, search conversations, and manage workspaces.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-slack'] },
color: '#4A154B',
website: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#4A154B"/>
<path d="M9.1 14.1a1.2 1.2 0 1 1-2.4 0 1.2 1.2 0 0 1 1.2-1.2h1.2v1.2zm.6 0a1.2 1.2 0 1 1 2.4 0v3a1.2 1.2 0 1 1-2.4 0v-3zm1.2-5a1.2 1.2 0 1 1 0-2.4 1.2 1.2 0 0 1 1.2 1.2v1.2H10.9zm0 .6a1.2 1.2 0 1 1 0 2.4h-3a1.2 1.2 0 1 1 0-2.4h3zm5 1.2a1.2 1.2 0 1 1 2.4 0 1.2 1.2 0 0 1-1.2 1.2h-1.2v-1.2zm-.6 0a1.2 1.2 0 1 1-2.4 0v-3a1.2 1.2 0 1 1 2.4 0v3zm-1.2 5a1.2 1.2 0 1 1 0 2.4 1.2 1.2 0 0 1-1.2-1.2v-1.2h1.2zm0-.6a1.2 1.2 0 1 1 0-2.4h3a1.2 1.2 0 1 1 0 2.4h-3z" fill="#fff"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'slack',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'notion',
name: 'Notion',
description: 'Read and write pages, search databases, and manage workspace content.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@notionhq/notion-mcp-server'] },
color: '#000000',
website: 'https://github.com/makenotion/notion-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#000"/>
<path d="M7.5 6.5h5.8l3.2 3.6v7.4H7.5V6.5zm1.2 1.2v8.6h6.6V10.8l-2.6-3.1H8.7z" fill="#fff"/>
<path d="M9.5 10h3M9.5 12h5M9.5 14h4" stroke="#fff" strokeWidth="0.7" fill="none"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'notion',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'spotify',
name: 'Spotify',
description: 'Control playback, search music, manage playlists, and browse your library.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@tbrgeek/spotify-mcp-server'] },
color: '#1DB954',
website: 'https://github.com/tbrgeek/spotify-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#1DB954"/>
<path d="M16.5 10.5c-2.5-1.5-6.5-1.6-8.8-.9-.4.1-.8-.1-.9-.5s.1-.8.5-.9c2.7-.8 7.1-.7 9.9 1 .4.2.5.7.3 1-.2.4-.7.5-1 .3zm-.3 2.7c-.2.3-.6.4-.9.2-2.1-1.3-5.3-1.7-7.7-.9-.3.1-.7 0-.8-.4-.1-.3 0-.7.4-.8 2.8-.9 6.3-.4 8.7 1 .3.2.4.6.3.9zm-1 2.6c-.2.2-.5.3-.7.2-1.8-1.1-4.1-1.4-6.8-.7-.3.1-.5-.1-.6-.4s.1-.5.4-.6c3-.7 5.5-.4 7.5.8.3.2.3.5.2.7z" fill="#fff"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'spotify',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'figma',
name: 'Figma',
description: 'Access design files, inspect components, and extract design data.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'figma-developer-mcp', '--stdio'] },
color: '#F24E1E',
website: 'https://github.com/anthropics/claude-code-mcp-server-figma',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#F24E1E"/>
<path d="M10.5 6h3v3h-3zm0 3h-3v3h3zm0 3h3v3h-3zm3-3h3v3h-3zm-3 6h-3v1.5a1.5 1.5 0 0 0 3 0V18z" fill="#fff" fillOpacity="0.9"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'figma',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'airtable',
name: 'Airtable',
description: 'Read and write records, manage bases, and search structured data.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'airtable-mcp-server'] },
color: '#18BFFF',
website: 'https://github.com/domdomegg/airtable-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#18BFFF"/>
<path d="M6 8h12v2H6zm0 3h5v5H6zm7 0h5v5h-5z" fill="#fff" fillOpacity="0.9"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'airtable',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'hubspot',
name: 'HubSpot',
description: 'Manage contacts, deals, companies, and CRM data.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@hubspot/mcp-server'] },
color: '#FF7A59',
website: 'https://github.com/HubSpot/hubspot-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#FF7A59"/>
<circle cx="12" cy="10.5" r="2.5" fill="none" stroke="#fff" strokeWidth="1.2"/>
<circle cx="16" cy="13.5" r="1.2" fill="#fff"/>
<line x1="13.8" y1="11.8" x2="15" y2="13" stroke="#fff" strokeWidth="1"/>
<path d="M12 13v2.5" stroke="#fff" strokeWidth="1.2"/>
</svg>
),
authType: 'oauth2',
oauthProvider: 'hubspot',
connectLabel: 'Coming Soon',
comingSoon: true,
},
{
id: 'discord',
name: 'Discord',
description: 'Manage servers, send messages, and interact with Discord communities.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'mcp-discord'] },
color: '#5865F2',
website: 'https://github.com/DiscordMCP/discord-mcp',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#5865F2"/>
<path d="M15.5 8.5c-1-.5-2-.8-3.1-.9l-.2.4c1.1.2 2 .6 2.9 1.2a10 10 0 0 0-6.2 0c.9-.6 1.8-1 2.9-1.2l-.2-.4c-1.1.1-2.1.4-3.1.9-2 2.9-2.5 5.7-2.2 8.5 1.2.9 2.4 1.4 3.5 1.8.3-.4.5-.8.7-1.2-.4-.1-.7-.3-1-.5l.3-.2c2.2 1 4.6 1 6.8 0l.3.2c-.3.2-.7.4-1 .5.2.4.5.8.7 1.2 1.1-.4 2.3-.9 3.5-1.8.4-3.2-.6-6-2.6-8.5zM9.7 15c-.7 0-1.3-.7-1.3-1.5s.6-1.5 1.3-1.5 1.3.7 1.3 1.5-.6 1.5-1.3 1.5zm4.6 0c-.7 0-1.3-.7-1.3-1.5s.6-1.5 1.3-1.5 1.3.7 1.3 1.5-.6 1.5-1.3 1.5z" fill="#fff"/>
</svg>
),
connectLabel: 'Coming Soon',
comingSoon: true,
connectInstructions: 'Create a Discord bot at discord.com/developers → New Application → Bot, then copy the bot token.',
credentialFields: [
{ key: 'DISCORD_TOKEN', label: 'Bot Token', placeholder: 'Paste your Discord bot token' },
],
},
{
id: 'zoom',
name: 'Zoom',
description: 'Create, manage, and join Zoom meetings.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@prathamesh0901/zoom-mcp-server'] },
color: '#2D8CFF',
website: 'https://github.com/pras-ops/Zoom_MCP_Server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#2D8CFF"/>
<path d="M7 9h6.5c.3 0 .5.2.5.5v5c0 .3-.2.5-.5.5H7c-.3 0-.5-.2-.5-.5v-5c0-.3.2-.5.5-.5zm8 1.5l2.5-1.5v6l-2.5-1.5v-3z" fill="#fff"/>
</svg>
),
connectLabel: 'Connect Zoom',
connectInstructions: 'Go to marketplace.zoom.us → Develop → Build App → Server-to-Server OAuth App. Activate it, then copy the Account ID, Client ID, and Client Secret.',
credentialFields: [
{ key: 'ZOOM_ACCOUNT_ID', label: 'Account ID', placeholder: 'Your Zoom Account ID' },
{ key: 'ZOOM_CLIENT_ID', label: 'Client ID', placeholder: 'Your Zoom Client ID' },
{ key: 'ZOOM_CLIENT_SECRET', label: 'Client Secret', placeholder: 'Your Zoom Client Secret' },
],
},
{
id: 'microsoft-365',
name: 'Microsoft 365',
description: 'Outlook email, calendar, OneDrive, contacts, Teams, and more. Sign in via the agent when first used.',
mcp_config: { type: 'stdio', command: 'npx', args: ['-y', '@softeria/ms-365-mcp-server'] },
color: '#0078D4',
website: 'https://github.com/softeria-eu/ms-365-mcp-server',
icon: (
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="11" fill="#0078D4"/>
<path d="M6 7h5v5H6V7zm6.5 0H17v5h-4.5V7zM6 13h5v5H6v-5zm6.5 0H17v5h-4.5v-5z" fill="#fff" fillOpacity="0.9"/>
</svg>
),
},
];
@@ -379,8 +697,35 @@ const Tools: React.FC = () => {
const outputItems = useAppSelector((s) => s.outputs.items);
const outputs = useMemo(() => Object.values(outputItems), [outputItems]);
const allTools = Object.values(items);
const tools = allTools;
const uninstalledIntegrations = useMemo(() => INTEGRATIONS.filter((ig) => !allTools.find((t) => t.name === ig.name)), [allTools]);
const tools = useMemo(() => {
return [...allTools].sort((a, b) => {
const aIg = INTEGRATIONS.find(ig => ig.name === a.name);
const bIg = INTEGRATIONS.find(ig => ig.name === b.name);
const aComingSoon = aIg?.comingSoon ? 1 : 0;
const bComingSoon = bIg?.comingSoon ? 1 : 0;
// Coming Soon always at bottom
if (aComingSoon !== bComingSoon) return aComingSoon - bComingSoon;
const aPerms = Object.keys(a.tool_permissions || {}).filter(k => !k.startsWith('_')).length;
const bPerms = Object.keys(b.tool_permissions || {}).filter(k => !k.startsWith('_')).length;
const aConnected = a.auth_status === 'connected' ? 1 : 0;
const bConnected = b.auth_status === 'connected' ? 1 : 0;
const aEnabled = a.enabled !== false ? 1 : 0;
const bEnabled = b.enabled !== false ? 1 : 0;
const aScore = aEnabled * 4 + aConnected * 2 + (aPerms > 0 ? 1 : 0);
const bScore = bEnabled * 4 + bConnected * 2 + (bPerms > 0 ? 1 : 0);
if (bScore !== aScore) return bScore - aScore;
return bPerms - aPerms;
});
}, [allTools]);
const uninstalledIntegrations = useMemo(() => {
const uninstalled = INTEGRATIONS.filter((ig) => !allTools.find((t) => t.name === ig.name));
// Coming Soon goes to the bottom
return uninstalled.sort((a, b) => {
if (a.comingSoon && !b.comingSoon) return 1;
if (!a.comingSoon && b.comingSoon) return -1;
return 0;
});
}, [allTools]);
const getIntegrationForTool = useCallback((tool: ToolDefinition) => INTEGRATIONS.find((ig) => ig.name === tool.name), []);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -465,6 +810,7 @@ const Tools: React.FC = () => {
credentials: {},
auth_type: integration.authType || 'none',
auth_status: 'configured',
...(integration.oauthProvider ? { oauth_provider: integration.oauthProvider } : {}),
}));
if (createTool.fulfilled.match(result)) {
const newTool = result.payload;
@@ -486,6 +832,31 @@ const Tools: React.FC = () => {
}
};
const handleDirectConnect = async (integration: Integration) => {
setIntegrationLoading((p) => ({ ...p, [integration.id]: true }));
try {
const result = await dispatch(createTool({
name: integration.name,
description: integration.description,
command: '',
mcp_config: integration.mcp_config,
credentials: {},
auth_type: integration.authType || 'none',
auth_status: 'configured',
...(integration.oauthProvider ? { oauth_provider: integration.oauthProvider } : {}),
}));
if (!createTool.fulfilled.match(result)) return;
const newTool = result.payload;
if (integration.authType === 'oauth2') {
handleOAuthConnect(newTool.id);
} else if (integration.credentialFields) {
openCredentialsDialog(newTool.id, integration);
}
} finally {
setIntegrationLoading((p) => ({ ...p, [integration.id]: false }));
}
};
const handleDiscover = async (toolId: string) => {
setDiscovering(true);
try {
@@ -730,7 +1101,7 @@ const Tools: React.FC = () => {
auth_type: 'oauth2',
auth_status: 'configured',
}));
setSnackbar({ open: true, message: `Installed "${f.name}" — click "Connect Google" to authorize` });
setSnackbar({ open: true, message: `Installed "${f.name}" — click "Connect" to authorize` });
} else if (hasConfig && mcpConfig.type === 'stdio') {
const result = await dispatch(createTool({
name: f.name,
@@ -773,11 +1144,12 @@ const Tools: React.FC = () => {
const afterConnect = async () => {
const statusResult = await dispatch(fetchToolStatus(toolId));
if (fetchToolStatus.fulfilled.match(statusResult) && statusResult.payload.auth_status === 'connected') {
setSnackbar({ open: true, message: 'Google account connected! Discovering actions…' });
const toolName = allTools.find(t => t.id === toolId)?.name || 'Account';
setSnackbar({ open: true, message: `${toolName} connected! Discovering actions…` });
setExpandedToolId(toolId);
dispatch(discoverTools(toolId));
} else {
setSnackbar({ open: true, message: 'Google account connected!' });
setSnackbar({ open: true, message: `${allTools.find(t => t.id === toolId)?.name || 'Account'} connected!` });
}
};
@@ -797,7 +1169,8 @@ const Tools: React.FC = () => {
}
}, 1000);
} else {
setSnackbar({ open: true, message: 'OAuth failed — make sure GOOGLE_OAUTH_CLIENT_ID is set in backend .env', severity: 'error' });
const errMsg = (result as any)?.payload?.detail || (result as any)?.error?.message || 'OAuth failed — check backend .env for required credentials';
setSnackbar({ open: true, message: errMsg, severity: 'error' });
}
};
@@ -816,7 +1189,7 @@ const Tools: React.FC = () => {
const handleCredentialsSave = async () => {
if (!credDialogToolId || !credDialogIntegration) return;
const hasEmpty = (credDialogIntegration.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim());
const hasEmpty = (credDialogIntegration.credentialFields || []).some((f) => !f.optional && !credDialogValues[f.key]?.trim());
if (hasEmpty) return;
setCredDialogSaving(true);
@@ -1201,16 +1574,34 @@ const Tools: React.FC = () => {
<Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{ig.description}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
{isLoading && <CircularProgress size={16} sx={{ color: ig.color }} />}
<Switch
checked={false}
onChange={() => handleIntegrationToggle(ig)}
disabled={isLoading}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: ig.color },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color },
}}
/>
{ig.comingSoon ? (
<Chip label="Coming Soon" size="small" sx={{ bgcolor: `${ig.color}15`, color: ig.color, fontSize: '0.7rem', fontStyle: 'italic', height: 24 }} />
) : (
<>
{(ig.authType === 'oauth2' || ig.credentialFields) && (
<Button
size="small"
variant="outlined"
startIcon={isLoading ? <CircularProgress size={14} /> : <LinkIcon sx={{ fontSize: 14 }} />}
onClick={() => handleDirectConnect(ig)}
disabled={isLoading}
sx={{ borderColor: `${ig.color}40`, color: ig.color, '&:hover': { borderColor: ig.color, bgcolor: `${ig.color}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0, mr: 0.5 }}
>
{ig.connectLabel || `Connect ${ig.name}`}
</Button>
)}
{isLoading && !(ig.authType === 'oauth2' || ig.credentialFields) && <CircularProgress size={16} sx={{ color: ig.color }} />}
<Switch
checked={false}
onChange={() => handleIntegrationToggle(ig)}
disabled={isLoading}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: ig.color },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: ig.color },
}}
/>
</>
)}
</Box>
</Box>
</CardContent>
@@ -1379,7 +1770,8 @@ const Tools: React.FC = () => {
);
};
const isDisabled = tool.enabled === false;
const isComingSoon = ig?.comingSoon === true;
const isDisabled = tool.enabled === false || isComingSoon;
return (
<Card key={tool.id} sx={{ bgcolor: c.bg.surface, border: `1px solid ${isExpanded ? c.accent.primary : c.border.subtle}`, borderRadius: 2, boxShadow: c.shadow.sm, '&:hover': { borderColor: isDisabled ? c.border.subtle : c.accent.primary, boxShadow: isDisabled ? undefined : '0 0 0 1px rgba(174,86,48,0.12)' }, transition: 'border-color 0.2s, box-shadow 0.2s' }}>
@@ -1417,7 +1809,10 @@ const Tools: React.FC = () => {
</Box>
{tool.description && <Typography sx={{ color: c.text.muted, fontSize: '0.84rem' }}>{tool.description}</Typography>}
</Box>
{!isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && (
{isComingSoon && (
<Chip label="Coming Soon" size="small" sx={{ bgcolor: `${ig?.color || c.text.ghost}15`, color: ig?.color || c.text.ghost, fontSize: '0.7rem', fontStyle: 'italic', height: 24, flexShrink: 0 }} />
)}
{!isComingSoon && !isDisabled && tool.auth_type === 'oauth2' && tool.auth_status !== 'connected' && (
<Button
size="small"
variant="outlined"
@@ -1425,10 +1820,10 @@ const Tools: React.FC = () => {
onClick={(e) => { e.stopPropagation(); handleOAuthConnect(tool.id); }}
sx={{ borderColor: `${c.status.info}40`, color: c.status.info, '&:hover': { borderColor: c.status.info, bgcolor: `${c.status.info}10` }, textTransform: 'none', fontSize: '0.78rem', borderRadius: 1.5, py: 0.5, flexShrink: 0 }}
>
Connect Google
{ig?.connectLabel || `Connect ${ig?.name || 'Account'}`}
</Button>
)}
{!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
{!isComingSoon && !isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && (
<Button
size="small"
variant="outlined"
@@ -1439,7 +1834,7 @@ const Tools: React.FC = () => {
{ig.connectLabel || 'Connect'}
</Button>
)}
{!isDisabled && ig && tool.auth_status === 'connected' && (
{!isComingSoon && !isDisabled && ig && tool.auth_status === 'connected' && (
<Tooltip title={ig.credentialFields || ig.authType === 'oauth2' ? 'Disconnect' : ''}>
<Chip
icon={<CheckCircleIcon sx={{ fontSize: 12 }} />}
@@ -1451,7 +1846,7 @@ const Tools: React.FC = () => {
/>
</Tooltip>
)}
{ig && (
{ig && !isComingSoon && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
{!!integrationLoading[ig.id] && <CircularProgress size={16} sx={{ color: ig.color }} />}
<Switch
@@ -2058,7 +2453,7 @@ const Tools: React.FC = () => {
<Button
variant="contained"
onClick={handleCredentialsSave}
disabled={credDialogSaving || (credDialogIntegration?.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim())}
disabled={credDialogSaving || (credDialogIntegration?.credentialFields || []).some((f) => !f.optional && !credDialogValues[f.key]?.trim())}
startIcon={credDialogSaving ? <CircularProgress size={14} /> : <LinkIcon sx={{ fontSize: 14 }} />}
sx={{ bgcolor: credDialogIntegration?.color || c.accent.primary, '&:hover': { bgcolor: credDialogIntegration?.color || c.accent.pressed, filter: 'brightness(0.9)' }, textTransform: 'none', borderRadius: 2 }}
>
+1 -1
View File
@@ -75,7 +75,7 @@ const initialState: SettingsState = {
anthropic_api_key: null,
browser_homepage: 'https://www.google.com',
auto_select_mode_on_new_agent: false,
expand_new_chats_in_dashboard: false,
expand_new_chats_in_dashboard: true,
auto_reveal_sub_agents: true,
dev_mode: false,
},
+5 -1
View File
@@ -12,6 +12,7 @@ export interface ToolDefinition {
credentials: Record<string, string>;
auth_type: string;
auth_status: string;
oauth_provider?: string;
oauth_tokens: Record<string, any>;
tool_permissions: Record<string, any>;
connected_account_email?: string;
@@ -92,7 +93,10 @@ export const startOAuth = createAsyncThunk(
'tools/startOAuth',
async (toolId: string) => {
const res = await fetch(`${TOOLS_API}/${toolId}/oauth/start`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to start OAuth');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to start OAuth');
}
const data = await res.json();
return data as { auth_url: string };
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
---
name: remotion-best-practices
description: Best practices for Remotion - Video creation in React
metadata:
tags: remotion, video, react, animation, composition
---
## When to use
Use this skills whenever you are dealing with Remotion code to obtain the domain-specific knowledge.
## Captions
When dealing with captions or subtitles, load the [./rules/subtitles.md](./rules/subtitles.md) file for more information.
## Using FFmpeg
For some video operations, such as trimming videos or detecting silence, FFmpeg should be used. Load the [./rules/ffmpeg.md](./rules/ffmpeg.md) file for more information.
## Audio visualization
When needing to visualize audio (spectrum bars, waveforms, bass-reactive effects), load the [./rules/audio-visualization.md](./rules/audio-visualization.md) file for more information.
## Sound effects
When needing to use sound effects, load the [./rules/sound-effects.md](./rules/sound-effects.md) file for more information.
## How to use
Read individual rule files for detailed explanations and code examples:
- [rules/3d.md](rules/3d.md) - 3D content in Remotion using Three.js and React Three Fiber
- [rules/animations.md](rules/animations.md) - Fundamental animation skills for Remotion
- [rules/assets.md](rules/assets.md) - Importing images, videos, audio, and fonts into Remotion
- [rules/audio.md](rules/audio.md) - Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
- [rules/calculate-metadata.md](rules/calculate-metadata.md) - Dynamically set composition duration, dimensions, and props
- [rules/can-decode.md](rules/can-decode.md) - Check if a video can be decoded by the browser using Mediabunny
- [rules/charts.md](rules/charts.md) - Chart and data visualization patterns for Remotion (bar, pie, line, stock charts)
- [rules/compositions.md](rules/compositions.md) - Defining compositions, stills, folders, default props and dynamic metadata
- [rules/extract-frames.md](rules/extract-frames.md) - Extract frames from videos at specific timestamps using Mediabunny
- [rules/fonts.md](rules/fonts.md) - Loading Google Fonts and local fonts in Remotion
- [rules/get-audio-duration.md](rules/get-audio-duration.md) - Getting the duration of an audio file in seconds with Mediabunny
- [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - Getting the width and height of a video file with Mediabunny
- [rules/get-video-duration.md](rules/get-video-duration.md) - Getting the duration of a video file in seconds with Mediabunny
- [rules/gifs.md](rules/gifs.md) - Displaying GIFs synchronized with Remotion's timeline
- [rules/images.md](rules/images.md) - Embedding images in Remotion using the Img component
- [rules/light-leaks.md](rules/light-leaks.md) - Light leak overlay effects using @remotion/light-leaks
- [rules/lottie.md](rules/lottie.md) - Embedding Lottie animations in Remotion
- [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - Measuring DOM element dimensions in Remotion
- [rules/measuring-text.md](rules/measuring-text.md) - Measuring text dimensions, fitting text to containers, and checking overflow
- [rules/sequencing.md](rules/sequencing.md) - Sequencing patterns for Remotion - delay, trim, limit duration of items
- [rules/tailwind.md](rules/tailwind.md) - Using TailwindCSS in Remotion
- [rules/text-animations.md](rules/text-animations.md) - Typography and text animation patterns for Remotion
- [rules/timing.md](rules/timing.md) - Interpolation curves in Remotion - linear, easing, spring animations
- [rules/transitions.md](rules/transitions.md) - Scene transition patterns for Remotion
- [rules/transparent-videos.md](rules/transparent-videos.md) - Rendering out a video with transparency
- [rules/trimming.md](rules/trimming.md) - Trimming patterns for Remotion - cut the beginning or end of animations
- [rules/videos.md](rules/videos.md) - Embedding videos in Remotion - trimming, volume, speed, looping, pitch
- [rules/parameters.md](rules/parameters.md) - Make a video parametrizable by adding a Zod schema
- [rules/maps.md](rules/maps.md) - Add a map using Mapbox and animate it
- [rules/voiceover.md](rules/voiceover.md) - Adding AI-generated voiceover to Remotion compositions using ElevenLabs TTS
@@ -0,0 +1,86 @@
---
name: 3d
description: 3D content in Remotion using Three.js and React Three Fiber.
metadata:
tags: 3d, three, threejs
---
# Using Three.js and React Three Fiber in Remotion
Follow React Three Fiber and Three.js best practices.
Only the following Remotion-specific rules need to be followed:
## Prerequisites
First, the `@remotion/three` package needs to be installed.
If it is not, use the following command:
```bash
npx remotion add @remotion/three # If project uses npm
bunx remotion add @remotion/three # If project uses bun
yarn remotion add @remotion/three # If project uses yarn
pnpm exec remotion add @remotion/three # If project uses pnpm
```
## Using ThreeCanvas
You MUST wrap 3D content in `<ThreeCanvas>` and include proper lighting.
`<ThreeCanvas>` MUST have a `width` and `height` prop.
```tsx
import { ThreeCanvas } from "@remotion/three";
import { useVideoConfig } from "remotion";
const { width, height } = useVideoConfig();
<ThreeCanvas width={width} height={height}>
<ambientLight intensity={0.4} />
<directionalLight position={[5, 5, 5]} intensity={0.8} />
<mesh>
<sphereGeometry args={[1, 32, 32]} />
<meshStandardMaterial color="red" />
</mesh>
</ThreeCanvas>;
```
## No animations not driven by `useCurrentFrame()`
Shaders, models etc MUST NOT animate by themselves.
No animations are allowed unless they are driven by `useCurrentFrame()`.
Otherwise, it will cause flickering during rendering.
Using `useFrame()` from `@react-three/fiber` is forbidden.
## Animate using `useCurrentFrame()`
Use `useCurrentFrame()` to perform animations.
```tsx
const frame = useCurrentFrame();
const rotationY = frame * 0.02;
<mesh rotation={[0, rotationY, 0]}>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial color="#4a9eff" />
</mesh>;
```
## Using `<Sequence>` inside `<ThreeCanvas>`
The `layout` prop of any `<Sequence>` inside a `<ThreeCanvas>` must be set to `none`.
```tsx
import { Sequence } from "remotion";
import { ThreeCanvas } from "@remotion/three";
const { width, height } = useVideoConfig();
<ThreeCanvas width={width} height={height}>
<Sequence layout="none">
<mesh>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial color="#4a9eff" />
</mesh>
</Sequence>
</ThreeCanvas>;
```
@@ -0,0 +1,27 @@
---
name: animations
description: Fundamental animation skills for Remotion
metadata:
tags: animations, transitions, frames, useCurrentFrame
---
All animations MUST be driven by the `useCurrentFrame()` hook.
Write animations in seconds and multiply them by the `fps` value from `useVideoConfig()`.
```tsx
import { useCurrentFrame } from "remotion";
export const FadeIn = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const opacity = interpolate(frame, [0, 2 * fps], [0, 1], {
extrapolateRight: "clamp",
});
return <div style={{ opacity }}>Hello World!</div>;
};
```
CSS transitions or animations are FORBIDDEN - they will not render correctly.
Tailwind animation class names are FORBIDDEN - they will not render correctly.
@@ -0,0 +1,78 @@
---
name: assets
description: Importing images, videos, audio, and fonts into Remotion
metadata:
tags: assets, staticFile, images, fonts, public
---
# Importing assets in Remotion
## The public folder
Place assets in the `public/` folder at your project root.
## Using staticFile()
You MUST use `staticFile()` to reference files from the `public/` folder:
```tsx
import { Img, staticFile } from "remotion";
export const MyComposition = () => {
return <Img src={staticFile("logo.png")} />;
};
```
The function returns an encoded URL that works correctly when deploying to subdirectories.
## Using with components
**Images:**
```tsx
import { Img, staticFile } from "remotion";
<Img src={staticFile("photo.png")} />;
```
**Videos:**
```tsx
import { Video } from "@remotion/media";
import { staticFile } from "remotion";
<Video src={staticFile("clip.mp4")} />;
```
**Audio:**
```tsx
import { Audio } from "@remotion/media";
import { staticFile } from "remotion";
<Audio src={staticFile("music.mp3")} />;
```
**Fonts:**
```tsx
import { staticFile } from "remotion";
const fontFamily = new FontFace("MyFont", `url(${staticFile("font.woff2")})`);
await fontFamily.load();
document.fonts.add(fontFamily);
```
## Remote URLs
Remote URLs can be used directly without `staticFile()`:
```tsx
<Img src="https://example.com/image.png" />
<Video src="https://remotion.media/video.mp4" />
```
## Important notes
- Remotion components (`<Img>`, `<Video>`, `<Audio>`) ensure assets are fully loaded before rendering
- Special characters in filenames (`#`, `?`, `&`) are automatically encoded
@@ -0,0 +1,173 @@
import {loadFont} from '@remotion/google-fonts/Inter';
import {AbsoluteFill, spring, useCurrentFrame, useVideoConfig} from 'remotion';
const {fontFamily} = loadFont();
const COLOR_BAR = '#D4AF37';
const COLOR_TEXT = '#ffffff';
const COLOR_MUTED = '#888888';
const COLOR_BG = '#0a0a0a';
const COLOR_AXIS = '#333333';
// Ideal composition size: 1280x720
const Title: React.FC<{children: React.ReactNode}> = ({children}) => (
<div style={{textAlign: 'center', marginBottom: 40}}>
<div style={{color: COLOR_TEXT, fontSize: 48, fontWeight: 600}}>
{children}
</div>
</div>
);
const YAxis: React.FC<{steps: number[]; height: number}> = ({
steps,
height,
}) => (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height,
paddingRight: 16,
}}
>
{steps
.slice()
.reverse()
.map((step) => (
<div
key={step}
style={{
color: COLOR_MUTED,
fontSize: 20,
textAlign: 'right',
}}
>
{step.toLocaleString()}
</div>
))}
</div>
);
const Bar: React.FC<{
height: number;
progress: number;
}> = ({height, progress}) => (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}
>
<div
style={{
width: '100%',
height,
backgroundColor: COLOR_BAR,
borderRadius: '8px 8px 0 0',
opacity: progress,
}}
/>
</div>
);
const XAxis: React.FC<{
children: React.ReactNode;
labels: string[];
height: number;
}> = ({children, labels, height}) => (
<div style={{flex: 1, display: 'flex', flexDirection: 'column'}}>
<div
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 16,
height,
borderLeft: `2px solid ${COLOR_AXIS}`,
borderBottom: `2px solid ${COLOR_AXIS}`,
paddingLeft: 16,
}}
>
{children}
</div>
<div
style={{
display: 'flex',
gap: 16,
paddingLeft: 16,
marginTop: 12,
}}
>
{labels.map((label) => (
<div
key={label}
style={{
flex: 1,
textAlign: 'center',
color: COLOR_MUTED,
fontSize: 20,
}}
>
{label}
</div>
))}
</div>
</div>
);
export const MyAnimation = () => {
const frame = useCurrentFrame();
const {fps, height} = useVideoConfig();
const data = [
{month: 'Jan', price: 2039},
{month: 'Mar', price: 2160},
{month: 'May', price: 2327},
{month: 'Jul', price: 2426},
{month: 'Sep', price: 2634},
{month: 'Nov', price: 2672},
];
const minPrice = 2000;
const maxPrice = 2800;
const priceRange = maxPrice - minPrice;
const chartHeight = height - 280;
const yAxisSteps = [2000, 2400, 2800];
return (
<AbsoluteFill
style={{
backgroundColor: COLOR_BG,
padding: 60,
display: 'flex',
flexDirection: 'column',
fontFamily,
}}
>
<Title>Gold Price 2024</Title>
<div style={{display: 'flex', flex: 1}}>
<YAxis steps={yAxisSteps} height={chartHeight} />
<XAxis height={chartHeight} labels={data.map((d) => d.month)}>
{data.map((item, i) => {
const progress = spring({
frame: frame - i * 5 - 10,
fps,
config: {damping: 18, stiffness: 80},
});
const barHeight =
((item.price - minPrice) / priceRange) * chartHeight * progress;
return (
<Bar key={item.month} height={barHeight} progress={progress} />
);
})}
</XAxis>
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,100 @@
import {
AbsoluteFill,
interpolate,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
const COLOR_BG = '#ffffff';
const COLOR_TEXT = '#000000';
const FULL_TEXT = 'From prompt to motion graphics. This is Remotion.';
const PAUSE_AFTER = 'From prompt to motion graphics.';
const FONT_SIZE = 72;
const FONT_WEIGHT = 700;
const CHAR_FRAMES = 2;
const CURSOR_BLINK_FRAMES = 16;
const PAUSE_SECONDS = 1;
// Ideal composition size: 1280x720
const getTypedText = ({
frame,
fullText,
pauseAfter,
charFrames,
pauseFrames,
}: {
frame: number;
fullText: string;
pauseAfter: string;
charFrames: number;
pauseFrames: number;
}): string => {
const pauseIndex = fullText.indexOf(pauseAfter);
const preLen =
pauseIndex >= 0 ? pauseIndex + pauseAfter.length : fullText.length;
let typedChars = 0;
if (frame < preLen * charFrames) {
typedChars = Math.floor(frame / charFrames);
} else if (frame < preLen * charFrames + pauseFrames) {
typedChars = preLen;
} else {
const postPhase = frame - preLen * charFrames - pauseFrames;
typedChars = Math.min(
fullText.length,
preLen + Math.floor(postPhase / charFrames),
);
}
return fullText.slice(0, typedChars);
};
const Cursor: React.FC<{
frame: number;
blinkFrames: number;
symbol?: string;
}> = ({frame, blinkFrames, symbol = '\u258C'}) => {
const opacity = interpolate(
frame % blinkFrames,
[0, blinkFrames / 2, blinkFrames],
[1, 0, 1],
{extrapolateLeft: 'clamp', extrapolateRight: 'clamp'},
);
return <span style={{opacity}}>{symbol}</span>;
};
export const MyAnimation = () => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const pauseFrames = Math.round(fps * PAUSE_SECONDS);
const typedText = getTypedText({
frame,
fullText: FULL_TEXT,
pauseAfter: PAUSE_AFTER,
charFrames: CHAR_FRAMES,
pauseFrames,
});
return (
<AbsoluteFill
style={{
backgroundColor: COLOR_BG,
}}
>
<div
style={{
color: COLOR_TEXT,
fontSize: FONT_SIZE,
fontWeight: FONT_WEIGHT,
fontFamily: 'sans-serif',
}}
>
<span>{typedText}</span>
<Cursor frame={frame} blinkFrames={CURSOR_BLINK_FRAMES} />
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,103 @@
import {loadFont} from '@remotion/google-fonts/Inter';
import React from 'react';
import {AbsoluteFill, spring, useCurrentFrame, useVideoConfig} from 'remotion';
/*
* Highlight a word in a sentence with a spring-animated wipe effect.
*/
// Ideal composition size: 1280x720
const COLOR_BG = '#ffffff';
const COLOR_TEXT = '#000000';
const COLOR_HIGHLIGHT = '#A7C7E7';
const FULL_TEXT = 'This is Remotion.';
const HIGHLIGHT_WORD = 'Remotion';
const FONT_SIZE = 72;
const FONT_WEIGHT = 700;
const HIGHLIGHT_START_FRAME = 30;
const HIGHLIGHT_WIPE_DURATION = 18;
const {fontFamily} = loadFont();
const Highlight: React.FC<{
word: string;
color: string;
delay: number;
durationInFrames: number;
}> = ({word, color, delay, durationInFrames}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const highlightProgress = spring({
fps,
frame,
config: {damping: 200},
delay,
durationInFrames,
});
const scaleX = Math.max(0, Math.min(1, highlightProgress));
return (
<span style={{position: 'relative', display: 'inline-block'}}>
<span
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: '1.05em',
transform: `translateY(-50%) scaleX(${scaleX})`,
transformOrigin: 'left center',
backgroundColor: color,
borderRadius: '0.18em',
zIndex: 0,
}}
/>
<span style={{position: 'relative', zIndex: 1}}>{word}</span>
</span>
);
};
export const MyAnimation = () => {
const highlightIndex = FULL_TEXT.indexOf(HIGHLIGHT_WORD);
const hasHighlight = highlightIndex >= 0;
const preText = hasHighlight ? FULL_TEXT.slice(0, highlightIndex) : FULL_TEXT;
const postText = hasHighlight
? FULL_TEXT.slice(highlightIndex + HIGHLIGHT_WORD.length)
: '';
return (
<AbsoluteFill
style={{
backgroundColor: COLOR_BG,
alignItems: 'center',
justifyContent: 'center',
fontFamily,
}}
>
<div
style={{
color: COLOR_TEXT,
fontSize: FONT_SIZE,
fontWeight: FONT_WEIGHT,
}}
>
{hasHighlight ? (
<>
<span>{preText}</span>
<Highlight
word={HIGHLIGHT_WORD}
color={COLOR_HIGHLIGHT}
delay={HIGHLIGHT_START_FRAME}
durationInFrames={HIGHLIGHT_WIPE_DURATION}
/>
<span>{postText}</span>
</>
) : (
<span>{FULL_TEXT}</span>
)}
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,198 @@
---
name: audio-visualization
description: Audio visualization patterns - spectrum bars, waveforms, bass-reactive effects
metadata:
tags: audio, visualization, spectrum, waveform, bass, music, audiogram, frequency
---
# Audio Visualization in Remotion
## Prerequisites
```bash
npx remotion add @remotion/media-utils
```
## Loading Audio Data
Use `useWindowedAudioData()` (https://www.remotion.dev/docs/use-windowed-audio-data) to load audio data:
```tsx
import { useWindowedAudioData } from "@remotion/media-utils";
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
src: staticFile("podcast.wav"),
frame,
fps,
windowInSeconds: 30,
});
```
## Spectrum Bar Visualization
Use `visualizeAudio()` (https://www.remotion.dev/docs/visualize-audio) to get frequency data for bar charts:
```tsx
import { useWindowedAudioData, visualizeAudio } from "@remotion/media-utils";
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
src: staticFile("music.mp3"),
frame,
fps,
windowInSeconds: 30,
});
if (!audioData) {
return null;
}
const frequencies = visualizeAudio({
fps,
frame,
audioData,
numberOfSamples: 256,
optimizeFor: "speed",
dataOffsetInSeconds,
});
return (
<div style={{ display: "flex", alignItems: "flex-end", height: 200 }}>
{frequencies.map((v, i) => (
<div
key={i}
style={{
flex: 1,
height: `${v * 100}%`,
backgroundColor: "#0b84f3",
margin: "0 1px",
}}
/>
))}
</div>
);
```
- `numberOfSamples` must be power of 2 (32, 64, 128, 256, 512, 1024)
- Values range 0-1; left of array = bass, right = highs
- Use `optimizeFor: "speed"` for Lambda or high sample counts
**Important:** When passing `audioData` to child components, also pass the `frame` from the parent. Do not call `useCurrentFrame()` in each child - this causes discontinuous visualization when children are inside `<Sequence>` with offsets.
## Waveform Visualization
Use `visualizeAudioWaveform()` (https://www.remotion.dev/docs/media-utils/visualize-audio-waveform) with `createSmoothSvgPath()` (https://www.remotion.dev/docs/media-utils/create-smooth-svg-path) for oscilloscope-style displays:
```tsx
import {
createSmoothSvgPath,
useWindowedAudioData,
visualizeAudioWaveform,
} from "@remotion/media-utils";
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { width, fps } = useVideoConfig();
const HEIGHT = 200;
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
src: staticFile("voice.wav"),
frame,
fps,
windowInSeconds: 30,
});
if (!audioData) {
return null;
}
const waveform = visualizeAudioWaveform({
fps,
frame,
audioData,
numberOfSamples: 256,
windowInSeconds: 0.5,
dataOffsetInSeconds,
});
const path = createSmoothSvgPath({
points: waveform.map((y, i) => ({
x: (i / (waveform.length - 1)) * width,
y: HEIGHT / 2 + (y * HEIGHT) / 2,
})),
});
return (
<svg width={width} height={HEIGHT}>
<path d={path} fill="none" stroke="#0b84f3" strokeWidth={2} />
</svg>
);
```
## Bass-Reactive Effects
Extract low frequencies for beat-reactive animations:
```tsx
const frequencies = visualizeAudio({
fps,
frame,
audioData,
numberOfSamples: 128,
optimizeFor: "speed",
dataOffsetInSeconds,
});
const lowFrequencies = frequencies.slice(0, 32);
const bassIntensity =
lowFrequencies.reduce((sum, v) => sum + v, 0) / lowFrequencies.length;
const scale = 1 + bassIntensity * 0.5;
const opacity = Math.min(0.6, bassIntensity * 0.8);
```
## Volume-Based Waveform
Use `getWaveformPortion()` (https://www.remotion.dev/docs/get-waveform-portion) when you need simplified volume data instead of frequency spectrum:
```tsx
import { getWaveformPortion } from "@remotion/media-utils";
import { useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const currentTimeInSeconds = frame / fps;
const waveform = getWaveformPortion({
audioData,
startTimeInSeconds: currentTimeInSeconds,
durationInSeconds: 5,
numberOfSamples: 50,
});
// Returns array of { index, amplitude } objects (amplitude: 0-1)
waveform.map((bar) => (
<div key={bar.index} style={{ height: bar.amplitude * 100 }} />
));
```
## Postprocessing
Low frequencies naturally dominate. Apply logarithmic scaling for visual balance:
```tsx
const minDb = -100;
const maxDb = -30;
const scaled = frequencies.map((value) => {
const db = 20 * Math.log10(value);
return (db - minDb) / (maxDb - minDb);
});
```
@@ -0,0 +1,169 @@
---
name: audio
description: Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
metadata:
tags: audio, media, trim, volume, speed, loop, pitch, mute, sound, sfx
---
# Using audio in Remotion
## Prerequisites
First, the @remotion/media package needs to be installed.
If it is not installed, use the following command:
```bash
npx remotion add @remotion/media
```
## Importing Audio
Use `<Audio>` from `@remotion/media` to add audio to your composition.
```tsx
import { Audio } from "@remotion/media";
import { staticFile } from "remotion";
export const MyComposition = () => {
return <Audio src={staticFile("audio.mp3")} />;
};
```
Remote URLs are also supported:
```tsx
<Audio src="https://remotion.media/audio.mp3" />
```
By default, audio plays from the start, at full volume and full length.
Multiple audio tracks can be layered by adding multiple `<Audio>` components.
## Trimming
Use `trimBefore` and `trimAfter` to remove portions of the audio. Values are in frames.
```tsx
const { fps } = useVideoConfig();
return (
<Audio
src={staticFile("audio.mp3")}
trimBefore={2 * fps} // Skip the first 2 seconds
trimAfter={10 * fps} // End at the 10 second mark
/>
);
```
The audio still starts playing at the beginning of the composition - only the specified portion is played.
## Delaying
Wrap the audio in a `<Sequence>` to delay when it starts:
```tsx
import { Sequence, staticFile } from "remotion";
import { Audio } from "@remotion/media";
const { fps } = useVideoConfig();
return (
<Sequence from={1 * fps}>
<Audio src={staticFile("audio.mp3")} />
</Sequence>
);
```
The audio will start playing after 1 second.
## Volume
Set a static volume (0 to 1):
```tsx
<Audio src={staticFile("audio.mp3")} volume={0.5} />
```
Or use a callback for dynamic volume based on the current frame:
```tsx
import { interpolate } from "remotion";
const { fps } = useVideoConfig();
return (
<Audio
src={staticFile("audio.mp3")}
volume={(f) =>
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
}
/>
);
```
The value of `f` starts at 0 when the audio begins to play, not the composition frame.
## Muting
Use `muted` to silence the audio. It can be set dynamically:
```tsx
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<Audio
src={staticFile("audio.mp3")}
muted={frame >= 2 * fps && frame <= 4 * fps} // Mute between 2s and 4s
/>
);
```
## Speed
Use `playbackRate` to change the playback speed:
```tsx
<Audio src={staticFile("audio.mp3")} playbackRate={2} /> {/* 2x speed */}
<Audio src={staticFile("audio.mp3")} playbackRate={0.5} /> {/* Half speed */}
```
Reverse playback is not supported.
## Looping
Use `loop` to loop the audio indefinitely:
```tsx
<Audio src={staticFile("audio.mp3")} loop />
```
Use `loopVolumeCurveBehavior` to control how the frame count behaves when looping:
- `"repeat"`: Frame count resets to 0 each loop (default)
- `"extend"`: Frame count continues incrementing
```tsx
<Audio
src={staticFile("audio.mp3")}
loop
loopVolumeCurveBehavior="extend"
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
/>
```
## Pitch
Use `toneFrequency` to adjust the pitch without affecting speed. Values range from 0.01 to 2:
```tsx
<Audio
src={staticFile("audio.mp3")}
toneFrequency={1.5} // Higher pitch
/>
<Audio
src={staticFile("audio.mp3")}
toneFrequency={0.8} // Lower pitch
/>
```
Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the `<Player />`.
@@ -0,0 +1,134 @@
---
name: calculate-metadata
description: Dynamically set composition duration, dimensions, and props
metadata:
tags: calculateMetadata, duration, dimensions, props, dynamic
---
# Using calculateMetadata
Use `calculateMetadata` on a `<Composition>` to dynamically set duration, dimensions, and transform props before rendering.
```tsx
<Composition
id="MyComp"
component={MyComponent}
durationInFrames={300}
fps={30}
width={1920}
height={1080}
defaultProps={{ videoSrc: "https://remotion.media/video.mp4" }}
calculateMetadata={calculateMetadata}
/>
```
## Setting duration based on a video
Use the [`getVideoDuration`](./get-video-duration.md) and [`getVideoDimensions`](./get-video-dimensions.md) skills to get the video duration and dimensions:
```tsx
import { CalculateMetadataFunction } from "remotion";
import { getVideoDuration } from "./get-video-duration";
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
const durationInSeconds = await getVideoDuration(props.videoSrc);
return {
durationInFrames: Math.ceil(durationInSeconds * 30),
};
};
```
## Matching dimensions of a video
Use the [`getVideoDimensions`](./get-video-dimensions.md) skill to get the video dimensions:
```tsx
import { CalculateMetadataFunction } from "remotion";
import { getVideoDuration } from "./get-video-duration";
import { getVideoDimensions } from "./get-video-dimensions";
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
const dimensions = await getVideoDimensions(props.videoSrc);
return {
width: dimensions.width,
height: dimensions.height,
};
};
```
## Setting duration based on multiple videos
```tsx
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
const metadataPromises = props.videos.map((video) =>
getVideoDuration(video.src),
);
const allMetadata = await Promise.all(metadataPromises);
const totalDuration = allMetadata.reduce(
(sum, durationInSeconds) => sum + durationInSeconds,
0,
);
return {
durationInFrames: Math.ceil(totalDuration * 30),
};
};
```
## Setting a default outName
Set the default output filename based on props:
```tsx
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
return {
defaultOutName: `video-${props.id}.mp4`,
};
};
```
## Transforming props
Fetch data or transform props before rendering:
```tsx
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
abortSignal,
}) => {
const response = await fetch(props.dataUrl, { signal: abortSignal });
const data = await response.json();
return {
props: {
...props,
fetchedData: data,
},
};
};
```
The `abortSignal` cancels stale requests when props change in the Studio.
## Return value
All fields are optional. Returned values override the `<Composition>` props:
- `durationInFrames`: Number of frames
- `width`: Composition width in pixels
- `height`: Composition height in pixels
- `fps`: Frames per second
- `props`: Transformed props passed to the component
- `defaultOutName`: Default output filename
- `defaultCodec`: Default codec for rendering
@@ -0,0 +1,81 @@
---
name: can-decode
description: Check if a video can be decoded by the browser using Mediabunny
metadata:
tags: decode, validation, video, audio, compatibility, browser
---
# Checking if a video can be decoded
Use Mediabunny to check if a video can be decoded by the browser before attempting to play it.
First, install the right version of Mediabunny:
```bash
npx remotion add mediabunny
```
## The `canDecode()` function
This function can be copy-pasted into any project.
```tsx
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const canDecode = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
try {
await input.getFormat();
} catch {
return false;
}
const videoTrack = await input.getPrimaryVideoTrack();
if (videoTrack && !(await videoTrack.canDecode())) {
return false;
}
const audioTrack = await input.getPrimaryAudioTrack();
if (audioTrack && !(await audioTrack.canDecode())) {
return false;
}
return true;
};
```
## Usage
```tsx
const src = "https://remotion.media/video.mp4";
const isDecodable = await canDecode(src);
if (isDecodable) {
console.log("Video can be decoded");
} else {
console.log("Video cannot be decoded by this browser");
}
```
## Using with Blob
For file uploads or drag-and-drop, use `BlobSource`:
```tsx
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
export const canDecodeBlob = async (blob: Blob) => {
const input = new Input({
formats: ALL_FORMATS,
source: new BlobSource(blob),
});
// Same validation logic as above
};
```
@@ -0,0 +1,120 @@
---
name: charts
description: Chart and data visualization patterns for Remotion. Use when creating bar charts, pie charts, line charts, stock graphs, or any data-driven animations.
metadata:
tags: charts, data, visualization, bar-chart, pie-chart, line-chart, stock-chart, svg-paths, graphs
---
# Charts in Remotion
Create charts using React code - HTML, SVG, and D3.js are all supported.
Disable all animations from third party libraries - they cause flickering.
Drive all animations from `useCurrentFrame()`.
## Bar Chart
```tsx
const STAGGER_DELAY = 5;
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const bars = data.map((item, i) => {
const height = spring({
frame,
fps,
delay: i * STAGGER_DELAY,
config: { damping: 200 },
});
return <div style={{ height: height * item.value }} />;
});
```
## Pie Chart
Animate segments using stroke-dashoffset, starting from 12 o'clock:
```tsx
const progress = interpolate(frame, [0, 100], [0, 1]);
const circumference = 2 * Math.PI * radius;
const segmentLength = (value / total) * circumference;
const offset = interpolate(progress, [0, 1], [segmentLength, 0]);
<circle
r={radius}
cx={center}
cy={center}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={`${segmentLength} ${circumference}`}
strokeDashoffset={offset}
transform={`rotate(-90 ${center} ${center})`}
/>;
```
## Line Chart / Path Animation
Use `@remotion/paths` for animating SVG paths (line charts, stock graphs, signatures).
Install: `npx remotion add @remotion/paths`
Docs: https://remotion.dev/docs/paths.md
### Convert data points to SVG path
```tsx
type Point = { x: number; y: number };
const generateLinePath = (points: Point[]): string => {
if (points.length < 2) return "";
return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
};
```
### Draw path with animation
```tsx
import { evolvePath } from "@remotion/paths";
const path = "M 100 200 L 200 150 L 300 180 L 400 100";
const progress = interpolate(frame, [0, 2 * fps], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.quad),
});
const { strokeDasharray, strokeDashoffset } = evolvePath(progress, path);
<path
d={path}
fill="none"
stroke="#FF3232"
strokeWidth={4}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>;
```
### Follow path with marker/arrow
```tsx
import {
getLength,
getPointAtLength,
getTangentAtLength,
} from "@remotion/paths";
const pathLength = getLength(path);
const point = getPointAtLength(path, progress * pathLength);
const tangent = getTangentAtLength(path, progress * pathLength);
const angle = Math.atan2(tangent.y, tangent.x);
<g
style={{
transform: `translate(${point.x}px, ${point.y}px) rotate(${angle}rad)`,
transformOrigin: "0 0",
}}
>
<polygon points="0,0 -20,-10 -20,10" fill="#FF3232" />
</g>;
```
@@ -0,0 +1,154 @@
---
name: compositions
description: Defining compositions, stills, folders, default props and dynamic metadata
metadata:
tags: composition, still, folder, props, metadata
---
A `<Composition>` defines the component, width, height, fps and duration of a renderable video.
It normally is placed in the `src/Root.tsx` file.
```tsx
import { Composition } from "remotion";
import { MyComposition } from "./MyComposition";
export const RemotionRoot = () => {
return (
<Composition
id="MyComposition"
component={MyComposition}
durationInFrames={100}
fps={30}
width={1080}
height={1080}
/>
);
};
```
## Default Props
Pass `defaultProps` to provide initial values for your component.
Values must be JSON-serializable (`Date`, `Map`, `Set`, and `staticFile()` are supported).
```tsx
import { Composition } from "remotion";
import { MyComposition, MyCompositionProps } from "./MyComposition";
export const RemotionRoot = () => {
return (
<Composition
id="MyComposition"
component={MyComposition}
durationInFrames={100}
fps={30}
width={1080}
height={1080}
defaultProps={
{
title: "Hello World",
color: "#ff0000",
} satisfies MyCompositionProps
}
/>
);
};
```
Use `type` declarations for props rather than `interface` to ensure `defaultProps` type safety.
## Folders
Use `<Folder>` to organize compositions in the sidebar.
Folder names can only contain letters, numbers, and hyphens.
```tsx
import { Composition, Folder } from "remotion";
export const RemotionRoot = () => {
return (
<>
<Folder name="Marketing">
<Composition id="Promo" /* ... */ />
<Composition id="Ad" /* ... */ />
</Folder>
<Folder name="Social">
<Folder name="Instagram">
<Composition id="Story" /* ... */ />
<Composition id="Reel" /* ... */ />
</Folder>
</Folder>
</>
);
};
```
## Stills
Use `<Still>` for single-frame images. It does not require `durationInFrames` or `fps`.
```tsx
import { Still } from "remotion";
import { Thumbnail } from "./Thumbnail";
export const RemotionRoot = () => {
return (
<Still id="Thumbnail" component={Thumbnail} width={1280} height={720} />
);
};
```
## Calculate Metadata
Use `calculateMetadata` to make dimensions, duration, or props dynamic based on data.
```tsx
import { Composition, CalculateMetadataFunction } from "remotion";
import { MyComposition, MyCompositionProps } from "./MyComposition";
const calculateMetadata: CalculateMetadataFunction<
MyCompositionProps
> = async ({ props, abortSignal }) => {
const data = await fetch(`https://api.example.com/video/${props.videoId}`, {
signal: abortSignal,
}).then((res) => res.json());
return {
durationInFrames: Math.ceil(data.duration * 30),
props: {
...props,
videoUrl: data.url,
},
};
};
export const RemotionRoot = () => {
return (
<Composition
id="MyComposition"
component={MyComposition}
durationInFrames={100} // Placeholder, will be overridden
fps={30}
width={1080}
height={1080}
defaultProps={{ videoId: "abc123" }}
calculateMetadata={calculateMetadata}
/>
);
};
```
The function can return `props`, `durationInFrames`, `width`, `height`, `fps`, and codec-related defaults. It runs once before rendering begins.
## Nesting compositions within another
To add a composition within another composition, you can use the `<Sequence>` component with a `width` and `height` prop to specify the size of the composition.
```tsx
<AbsoluteFill>
<Sequence width={COMPOSITION_WIDTH} height={COMPOSITION_HEIGHT}>
<CompositionComponent />
</Sequence>
</AbsoluteFill>
```
@@ -0,0 +1,184 @@
---
name: display-captions
description: Displaying captions in Remotion with TikTok-style pages and word highlighting
metadata:
tags: captions, subtitles, display, tiktok, highlight
---
# Displaying captions in Remotion
This guide explains how to display captions in Remotion, assuming you already have captions in the [`Caption`](https://www.remotion.dev/docs/captions/caption) format.
## Prerequisites
Read [Transcribing audio](transcribe-captions.md) for how to generate captions.
First, the [`@remotion/captions`](https://www.remotion.dev/docs/captions) package needs to be installed.
If it is not installed, use the following command:
```bash
npx remotion add @remotion/captions
```
## Fetching captions
First, fetch your captions JSON file. Use [`useDelayRender()`](https://www.remotion.dev/docs/use-delay-render) to hold the render until the captions are loaded:
```tsx
import { useState, useEffect, useCallback } from "react";
import { AbsoluteFill, staticFile, useDelayRender } from "remotion";
import type { Caption } from "@remotion/captions";
export const MyComponent: React.FC = () => {
const [captions, setCaptions] = useState<Caption[] | null>(null);
const { delayRender, continueRender, cancelRender } = useDelayRender();
const [handle] = useState(() => delayRender());
const fetchCaptions = useCallback(async () => {
try {
// Assuming captions.json is in the public/ folder.
const response = await fetch(staticFile("captions123.json"));
const data = await response.json();
setCaptions(data);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
if (!captions) {
return null;
}
return <AbsoluteFill>{/* Render captions here */}</AbsoluteFill>;
};
```
## Creating pages
Use `createTikTokStyleCaptions()` to group captions into pages. The `combineTokensWithinMilliseconds` option controls how many words appear at once:
```tsx
import { useMemo } from "react";
import { createTikTokStyleCaptions } from "@remotion/captions";
import type { Caption } from "@remotion/captions";
// How often captions should switch (in milliseconds)
// Higher values = more words per page
// Lower values = fewer words (more word-by-word)
const SWITCH_CAPTIONS_EVERY_MS = 1200;
const { pages } = useMemo(() => {
return createTikTokStyleCaptions({
captions,
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
});
}, [captions]);
```
## Rendering with Sequences
Map over the pages and render each one in a `<Sequence>`. Calculate the start frame and duration from the page timing:
```tsx
import { Sequence, useVideoConfig, AbsoluteFill } from "remotion";
import type { TikTokPage } from "@remotion/captions";
const CaptionedContent: React.FC = () => {
const { fps } = useVideoConfig();
return (
<AbsoluteFill>
{pages.map((page, index) => {
const nextPage = pages[index + 1] ?? null;
const startFrame = (page.startMs / 1000) * fps;
const endFrame = Math.min(
nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps,
);
const durationInFrames = endFrame - startFrame;
if (durationInFrames <= 0) {
return null;
}
return (
<Sequence
key={index}
from={startFrame}
durationInFrames={durationInFrames}
>
<CaptionPage page={page} />
</Sequence>
);
})}
</AbsoluteFill>
);
};
```
## White-space preservation
The captions are whitespace sensitive. You should include spaces in the `text` field before each word. Use `whiteSpace: "pre"` to preserve the whitespace in the captions.
## Separate component for captions
Put captioning logic in a separate component.
Make a new file for it.
## Word highlighting
A caption page contains `tokens` which you can use to highlight the currently spoken word:
```tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
import type { TikTokPage } from "@remotion/captions";
const HIGHLIGHT_COLOR = "#39E508";
const CaptionPage: React.FC<{ page: TikTokPage }> = ({ page }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Current time relative to the start of the sequence
const currentTimeMs = (frame / fps) * 1000;
// Convert to absolute time by adding the page start
const absoluteTimeMs = page.startMs + currentTimeMs;
return (
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
<div style={{ fontSize: 80, fontWeight: "bold", whiteSpace: "pre" }}>
{page.tokens.map((token) => {
const isActive =
token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
return (
<span
key={token.fromMs}
style={{ color: isActive ? HIGHLIGHT_COLOR : "white" }}
>
{token.text}
</span>
);
})}
</div>
</AbsoluteFill>
);
};
```
## Display captions alongside video content
By default, put the captions alongside the video content, so the captions are in sync.
For each video, make a new captions JSON file.
```tsx
<AbsoluteFill>
<Video src={staticFile("video.mp4")} />
<CaptionPage page={page} />
</AbsoluteFill>
```
@@ -0,0 +1,229 @@
---
name: extract-frames
description: Extract frames from videos at specific timestamps using Mediabunny
metadata:
tags: frames, extract, video, thumbnail, filmstrip, canvas
---
# Extracting frames from videos
Use Mediabunny to extract frames from videos at specific timestamps. This is useful for generating thumbnails, filmstrips, or processing individual frames.
## The `extractFrames()` function
This function can be copy-pasted into any project.
```tsx
import {
ALL_FORMATS,
Input,
UrlSource,
VideoSample,
VideoSampleSink,
} from "mediabunny";
type Options = {
track: { width: number; height: number };
container: string;
durationInSeconds: number | null;
};
export type ExtractFramesTimestampsInSecondsFn = (
options: Options,
) => Promise<number[]> | number[];
export type ExtractFramesProps = {
src: string;
timestampsInSeconds: number[] | ExtractFramesTimestampsInSecondsFn;
onVideoSample: (sample: VideoSample) => void;
signal?: AbortSignal;
};
export async function extractFrames({
src,
timestampsInSeconds,
onVideoSample,
signal,
}: ExtractFramesProps): Promise<void> {
using input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src),
});
const [durationInSeconds, format, videoTrack] = await Promise.all([
input.computeDuration(),
input.getFormat(),
input.getPrimaryVideoTrack(),
]);
if (!videoTrack) {
throw new Error("No video track found in the input");
}
if (signal?.aborted) {
throw new Error("Aborted");
}
const timestamps =
typeof timestampsInSeconds === "function"
? await timestampsInSeconds({
track: {
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
},
container: format.name,
durationInSeconds,
})
: timestampsInSeconds;
if (timestamps.length === 0) {
return;
}
if (signal?.aborted) {
throw new Error("Aborted");
}
const sink = new VideoSampleSink(videoTrack);
for await (using videoSample of sink.samplesAtTimestamps(timestamps)) {
if (signal?.aborted) {
break;
}
if (!videoSample) {
continue;
}
onVideoSample(videoSample);
}
}
```
## Basic usage
Extract frames at specific timestamps:
```tsx
await extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: [0, 1, 2, 3, 4],
onVideoSample: (sample) => {
const canvas = document.createElement("canvas");
canvas.width = sample.displayWidth;
canvas.height = sample.displayHeight;
const ctx = canvas.getContext("2d");
sample.draw(ctx!, 0, 0);
},
});
```
## Creating a filmstrip
Use a callback function to dynamically calculate timestamps based on video metadata:
```tsx
const canvasWidth = 500;
const canvasHeight = 80;
const fromSeconds = 0;
const toSeconds = 10;
await extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: async ({ track, durationInSeconds }) => {
const aspectRatio = track.width / track.height;
const amountOfFramesFit = Math.ceil(
canvasWidth / (canvasHeight * aspectRatio),
);
const segmentDuration = toSeconds - fromSeconds;
const timestamps: number[] = [];
for (let i = 0; i < amountOfFramesFit; i++) {
timestamps.push(
fromSeconds + (segmentDuration / amountOfFramesFit) * (i + 0.5),
);
}
return timestamps;
},
onVideoSample: (sample) => {
console.log(`Frame at ${sample.timestamp}s`);
const canvas = document.createElement("canvas");
canvas.width = sample.displayWidth;
canvas.height = sample.displayHeight;
const ctx = canvas.getContext("2d");
sample.draw(ctx!, 0, 0);
},
});
```
## Cancellation with AbortSignal
Cancel frame extraction after a timeout:
```tsx
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
try {
await extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: [0, 1, 2, 3, 4],
onVideoSample: (sample) => {
using frame = sample;
const canvas = document.createElement("canvas");
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
const ctx = canvas.getContext("2d");
frame.draw(ctx!, 0, 0);
},
signal: controller.signal,
});
console.log("Frame extraction complete!");
} catch (error) {
console.error("Frame extraction was aborted or failed:", error);
}
```
## Timeout with Promise.race
```tsx
const controller = new AbortController();
const timeoutPromise = new Promise<never>((_, reject) => {
const timeoutId = setTimeout(() => {
controller.abort();
reject(new Error("Frame extraction timed out after 10 seconds"));
}, 10000);
controller.signal.addEventListener("abort", () => clearTimeout(timeoutId), {
once: true,
});
});
try {
await Promise.race([
extractFrames({
src: "https://remotion.media/video.mp4",
timestampsInSeconds: [0, 1, 2, 3, 4],
onVideoSample: (sample) => {
using frame = sample;
const canvas = document.createElement("canvas");
canvas.width = frame.displayWidth;
canvas.height = frame.displayHeight;
const ctx = canvas.getContext("2d");
frame.draw(ctx!, 0, 0);
},
signal: controller.signal,
}),
timeoutPromise,
]);
console.log("Frame extraction complete!");
} catch (error) {
console.error("Frame extraction was aborted or failed:", error);
}
```
@@ -0,0 +1,38 @@
---
name: ffmpeg
description: Using FFmpeg and FFprobe in Remotion
metadata:
tags: ffmpeg, ffprobe, video, trimming
---
## FFmpeg in Remotion
`ffmpeg` and `ffprobe` do not need to be installed. They are available via the `bunx remotion ffmpeg` and `bunx remotion ffprobe`:
```bash
bunx remotion ffmpeg -i input.mp4 output.mp3
bunx remotion ffprobe input.mp4
```
### Trimming videos
You have 2 options for trimming videos:
1. Use the FFmpeg command line. You MUST re-encode the video to avoid frozen frames at the start of the video.
```bash
# Re-encodes from the exact frame
bunx remotion ffmpeg -ss 00:00:05 -i public/input.mp4 -to 00:00:10 -c:v libx264 -c:a aac public/output.mp4
```
2. Use the `trimBefore` and `trimAfter` props of the `<Video>` component. The benefit is that this is non-destructive and you can change the trim at any time.
```tsx
import { Video } from "@remotion/media";
<Video
src={staticFile("video.mp4")}
trimBefore={5 * fps}
trimAfter={10 * fps}
/>;
```
@@ -0,0 +1,152 @@
---
name: fonts
description: Loading Google Fonts and local fonts in Remotion
metadata:
tags: fonts, google-fonts, typography, text
---
# Using fonts in Remotion
## Google Fonts with @remotion/google-fonts
The recommended way to use Google Fonts. It's type-safe and automatically blocks rendering until the font is ready.
### Prerequisites
First, the @remotion/google-fonts package needs to be installed.
If it is not installed, use the following command:
```bash
npx remotion add @remotion/google-fonts # If project uses npm
bunx remotion add @remotion/google-fonts # If project uses bun
yarn remotion add @remotion/google-fonts # If project uses yarn
pnpm exec remotion add @remotion/google-fonts # If project uses pnpm
```
```tsx
import { loadFont } from "@remotion/google-fonts/Lobster";
const { fontFamily } = loadFont();
export const MyComposition = () => {
return <div style={{ fontFamily }}>Hello World</div>;
};
```
Preferrably, specify only needed weights and subsets to reduce file size:
```tsx
import { loadFont } from "@remotion/google-fonts/Roboto";
const { fontFamily } = loadFont("normal", {
weights: ["400", "700"],
subsets: ["latin"],
});
```
### Waiting for font to load
Use `waitUntilDone()` if you need to know when the font is ready:
```tsx
import { loadFont } from "@remotion/google-fonts/Lobster";
const { fontFamily, waitUntilDone } = loadFont();
await waitUntilDone();
```
## Local fonts with @remotion/fonts
For local font files, use the `@remotion/fonts` package.
### Prerequisites
First, install @remotion/fonts:
```bash
npx remotion add @remotion/fonts # If project uses npm
bunx remotion add @remotion/fonts # If project uses bun
yarn remotion add @remotion/fonts # If project uses yarn
pnpm exec remotion add @remotion/fonts # If project uses pnpm
```
### Loading a local font
Place your font file in the `public/` folder and use `loadFont()`:
```tsx
import { loadFont } from "@remotion/fonts";
import { staticFile } from "remotion";
await loadFont({
family: "MyFont",
url: staticFile("MyFont-Regular.woff2"),
});
export const MyComposition = () => {
return <div style={{ fontFamily: "MyFont" }}>Hello World</div>;
};
```
### Loading multiple weights
Load each weight separately with the same family name:
```tsx
import { loadFont } from "@remotion/fonts";
import { staticFile } from "remotion";
await Promise.all([
loadFont({
family: "Inter",
url: staticFile("Inter-Regular.woff2"),
weight: "400",
}),
loadFont({
family: "Inter",
url: staticFile("Inter-Bold.woff2"),
weight: "700",
}),
]);
```
### Available options
```tsx
loadFont({
family: "MyFont", // Required: name to use in CSS
url: staticFile("font.woff2"), // Required: font file URL
format: "woff2", // Optional: auto-detected from extension
weight: "400", // Optional: font weight
style: "normal", // Optional: normal or italic
display: "block", // Optional: font-display behavior
});
```
## Using in components
Call `loadFont()` at the top level of your component or in a separate file that's imported early:
```tsx
import { loadFont } from "@remotion/google-fonts/Montserrat";
const { fontFamily } = loadFont("normal", {
weights: ["400", "700"],
subsets: ["latin"],
});
export const Title: React.FC<{ text: string }> = ({ text }) => {
return (
<h1
style={{
fontFamily,
fontSize: 80,
fontWeight: "bold",
}}
>
{text}
</h1>
);
};
```
@@ -0,0 +1,58 @@
---
name: get-audio-duration
description: Getting the duration of an audio file in seconds with Mediabunny
metadata:
tags: duration, audio, length, time, seconds, mp3, wav
---
# Getting audio duration with Mediabunny
Mediabunny can extract the duration of an audio file. It works in browser, Node.js, and Bun environments.
## Getting audio duration
```tsx title="get-audio-duration.ts"
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const getAudioDuration = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
const durationInSeconds = await input.computeDuration();
return durationInSeconds;
};
```
## Usage
```tsx
const duration = await getAudioDuration("https://remotion.media/audio.mp3");
console.log(duration); // e.g. 180.5 (seconds)
```
## Using with staticFile in Remotion
Make sure to wrap the file path in `staticFile()`:
```tsx
import { staticFile } from "remotion";
const duration = await getAudioDuration(staticFile("audio.mp3"));
```
## In Node.js and Bun
Use `FileSource` instead of `UrlSource`:
```tsx
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
const input = new Input({
formats: ALL_FORMATS,
source: new FileSource(file), // File object from input or drag-drop
});
```
@@ -0,0 +1,68 @@
---
name: get-video-dimensions
description: Getting the width and height of a video file with Mediabunny
metadata:
tags: dimensions, width, height, resolution, size, video
---
# Getting video dimensions with Mediabunny
Mediabunny can extract the width and height of a video file. It works in browser, Node.js, and Bun environments.
## Getting video dimensions
```tsx
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const getVideoDimensions = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error("No video track found");
}
return {
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
};
};
```
## Usage
```tsx
const dimensions = await getVideoDimensions("https://remotion.media/video.mp4");
console.log(dimensions.width); // e.g. 1920
console.log(dimensions.height); // e.g. 1080
```
## Using with local files
For local files, use `FileSource` instead of `UrlSource`:
```tsx
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
const input = new Input({
formats: ALL_FORMATS,
source: new FileSource(file), // File object from input or drag-drop
});
const videoTrack = await input.getPrimaryVideoTrack();
const width = videoTrack.displayWidth;
const height = videoTrack.displayHeight;
```
## Using with staticFile in Remotion
```tsx
import { staticFile } from "remotion";
const dimensions = await getVideoDimensions(staticFile("video.mp4"));
```
@@ -0,0 +1,60 @@
---
name: get-video-duration
description: Getting the duration of a video file in seconds with Mediabunny
metadata:
tags: duration, video, length, time, seconds
---
# Getting video duration with Mediabunny
Mediabunny can extract the duration of a video file. It works in browser, Node.js, and Bun environments.
## Getting video duration
```tsx
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
export const getVideoDuration = async (src: string) => {
const input = new Input({
formats: ALL_FORMATS,
source: new UrlSource(src, {
getRetryDelay: () => null,
}),
});
const durationInSeconds = await input.computeDuration();
return durationInSeconds;
};
```
## Usage
```tsx
const duration = await getVideoDuration("https://remotion.media/video.mp4");
console.log(duration); // e.g. 10.5 (seconds)
```
## Video files from the public/ directory
Make sure to wrap the file path in `staticFile()`:
```tsx
import { staticFile } from "remotion";
const duration = await getVideoDuration(staticFile("video.mp4"));
```
## In Node.js and Bun
Use `FileSource` instead of `UrlSource`:
```tsx
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
const input = new Input({
formats: ALL_FORMATS,
source: new FileSource(file), // File object from input or drag-drop
});
const durationInSeconds = await input.computeDuration();
```
@@ -0,0 +1,141 @@
---
name: gif
description: Displaying GIFs, APNG, AVIF and WebP in Remotion
metadata:
tags: gif, animation, images, animated, apng, avif, webp
---
# Using Animated images in Remotion
## Basic usage
Use `<AnimatedImage>` to display a GIF, APNG, AVIF or WebP image synchronized with Remotion's timeline:
```tsx
import { AnimatedImage, staticFile } from "remotion";
export const MyComposition = () => {
return (
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} />
);
};
```
Remote URLs are also supported (must have CORS enabled):
```tsx
<AnimatedImage
src="https://example.com/animation.gif"
width={500}
height={500}
/>
```
## Sizing and fit
Control how the image fills its container with the `fit` prop:
```tsx
// Stretch to fill (default)
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="fill" />
// Maintain aspect ratio, fit inside container
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="contain" />
// Fill container, crop if needed
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="cover" />
```
## Playback speed
Use `playbackRate` to control the animation speed:
```tsx
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={2} /> {/* 2x speed */}
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={0.5} /> {/* Half speed */}
```
## Looping behavior
Control what happens when the animation finishes:
```tsx
// Loop indefinitely (default)
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="loop" />
// Play once, show final frame
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="pause-after-finish" />
// Play once, then clear canvas
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="clear-after-finish" />
```
## Styling
Use the `style` prop for additional CSS (use `width` and `height` props for sizing):
```tsx
<AnimatedImage
src={staticFile("animation.gif")}
width={500}
height={500}
style={{
borderRadius: 20,
position: "absolute",
top: 100,
left: 50,
}}
/>
```
## Getting GIF duration
Use `getGifDurationInSeconds()` from `@remotion/gif` to get the duration of a GIF.
```bash
npx remotion add @remotion/gif
```
```tsx
import { getGifDurationInSeconds } from "@remotion/gif";
import { staticFile } from "remotion";
const duration = await getGifDurationInSeconds(staticFile("animation.gif"));
console.log(duration); // e.g. 2.5
```
This is useful for setting the composition duration to match the GIF:
```tsx
import { getGifDurationInSeconds } from "@remotion/gif";
import { staticFile, CalculateMetadataFunction } from "remotion";
const calculateMetadata: CalculateMetadataFunction = async () => {
const duration = await getGifDurationInSeconds(staticFile("animation.gif"));
return {
durationInFrames: Math.ceil(duration * 30),
};
};
```
## Alternative
If `<AnimatedImage>` does not work (only supported in Chrome and Firefox), you can use `<Gif>` from `@remotion/gif` instead.
```bash
npx remotion add @remotion/gif # If project uses npm
bunx remotion add @remotion/gif # If project uses bun
yarn remotion add @remotion/gif # If project uses yarn
pnpm exec remotion add @remotion/gif # If project uses pnpm
```
```tsx
import { Gif } from "@remotion/gif";
import { staticFile } from "remotion";
export const MyComposition = () => {
return <Gif src={staticFile("animation.gif")} width={500} height={500} />;
};
```
The `<Gif>` component has the same props as `<AnimatedImage>` but only supports GIF files.
@@ -0,0 +1,134 @@
---
name: images
description: Embedding images in Remotion using the <Img> component
metadata:
tags: images, img, staticFile, png, jpg, svg, webp
---
# Using images in Remotion
## The `<Img>` component
Always use the `<Img>` component from `remotion` to display images:
```tsx
import { Img, staticFile } from "remotion";
export const MyComposition = () => {
return <Img src={staticFile("photo.png")} />;
};
```
## Important restrictions
**You MUST use the `<Img>` component from `remotion`.** Do not use:
- Native HTML `<img>` elements
- Next.js `<Image>` component
- CSS `background-image`
The `<Img>` component ensures images are fully loaded before rendering, preventing flickering and blank frames during video export.
## Local images with staticFile()
Place images in the `public/` folder and use `staticFile()` to reference them:
```
my-video/
├─ public/
│ ├─ logo.png
│ ├─ avatar.jpg
│ └─ icon.svg
├─ src/
├─ package.json
```
```tsx
import { Img, staticFile } from "remotion";
<Img src={staticFile("logo.png")} />;
```
## Remote images
Remote URLs can be used directly without `staticFile()`:
```tsx
<Img src="https://example.com/image.png" />
```
Ensure remote images have CORS enabled.
For animated GIFs, use the `<Gif>` component from `@remotion/gif` instead.
## Sizing and positioning
Use the `style` prop to control size and position:
```tsx
<Img
src={staticFile("photo.png")}
style={{
width: 500,
height: 300,
position: "absolute",
top: 100,
left: 50,
objectFit: "cover",
}}
/>
```
## Dynamic image paths
Use template literals for dynamic file references:
```tsx
import { Img, staticFile, useCurrentFrame } from "remotion";
const frame = useCurrentFrame();
// Image sequence
<Img src={staticFile(`frames/frame${frame}.png`)} />
// Selecting based on props
<Img src={staticFile(`avatars/${props.userId}.png`)} />
// Conditional images
<Img src={staticFile(`icons/${isActive ? "active" : "inactive"}.svg`)} />
```
This pattern is useful for:
- Image sequences (frame-by-frame animations)
- User-specific avatars or profile images
- Theme-based icons
- State-dependent graphics
## Getting image dimensions
Use `getImageDimensions()` to get the dimensions of an image:
```tsx
import { getImageDimensions, staticFile } from "remotion";
const { width, height } = await getImageDimensions(staticFile("photo.png"));
```
This is useful for calculating aspect ratios or sizing compositions:
```tsx
import {
getImageDimensions,
staticFile,
CalculateMetadataFunction,
} from "remotion";
const calculateMetadata: CalculateMetadataFunction = async () => {
const { width, height } = await getImageDimensions(staticFile("photo.png"));
return {
width,
height,
};
};
```
@@ -0,0 +1,69 @@
---
name: import-srt-captions
description: Importing .srt subtitle files into Remotion using @remotion/captions
metadata:
tags: captions, subtitles, srt, import, parse
---
# Importing .srt subtitles into Remotion
If you have an existing `.srt` subtitle file, you can import it into Remotion using `parseSrt()` from `@remotion/captions`.
If you don't have a .srt file, read [Transcribing audio](transcribe-captions.md) for how to generate captions instead.
## Prerequisites
First, the @remotion/captions package needs to be installed.
If it is not installed, use the following command:
```bash
npx remotion add @remotion/captions # If project uses npm
bunx remotion add @remotion/captions # If project uses bun
yarn remotion add @remotion/captions # If project uses yarn
pnpm exec remotion add @remotion/captions # If project uses pnpm
```
## Reading an .srt file
Use `staticFile()` to reference an `.srt` file in your `public` folder, then fetch and parse it:
```tsx
import { useState, useEffect, useCallback } from "react";
import { AbsoluteFill, staticFile, useDelayRender } from "remotion";
import { parseSrt } from "@remotion/captions";
import type { Caption } from "@remotion/captions";
export const MyComponent: React.FC = () => {
const [captions, setCaptions] = useState<Caption[] | null>(null);
const { delayRender, continueRender, cancelRender } = useDelayRender();
const [handle] = useState(() => delayRender());
const fetchCaptions = useCallback(async () => {
try {
const response = await fetch(staticFile("subtitles.srt"));
const text = await response.text();
const { captions: parsed } = parseSrt({ input: text });
setCaptions(parsed);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
if (!captions) {
return null;
}
return <AbsoluteFill>{/* Use captions here */}</AbsoluteFill>;
};
```
Remote URLs are also supported - you can `fetch()` a remote file via URL instead of using `staticFile()`.
## Using imported captions
Once parsed, the captions are in the `Caption` format and can be used with all `@remotion/captions` utilities.
@@ -0,0 +1,73 @@
---
name: light-leaks
description: Light leak overlay effects for Remotion using @remotion/light-leaks.
metadata:
tags: light-leaks, overlays, effects, transitions
---
## Light Leaks
This only works from Remotion 4.0.415 and up. Use `npx remotion versions` to check your Remotion version and `npx remotion upgrade` to upgrade your Remotion version.
`<LightLeak>` from `@remotion/light-leaks` renders a WebGL-based light leak effect. It reveals during the first half of its duration and retracts during the second half.
Typically used inside a `<TransitionSeries.Overlay>` to play over the cut point between two scenes. See the **transitions** rule for `<TransitionSeries>` and overlay usage.
## Prerequisites
```bash
npx remotion add @remotion/light-leaks
```
## Basic usage with TransitionSeries
```tsx
import { TransitionSeries } from "@remotion/transitions";
import { LightLeak } from "@remotion/light-leaks";
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneA />
</TransitionSeries.Sequence>
<TransitionSeries.Overlay durationInFrames={30}>
<LightLeak />
</TransitionSeries.Overlay>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneB />
</TransitionSeries.Sequence>
</TransitionSeries>;
```
## Props
- `durationInFrames?` — defaults to the parent sequence/composition duration. The effect reveals during the first half and retracts during the second half.
- `seed?` — determines the shape of the light leak pattern. Different seeds produce different patterns. Default: `0`.
- `hueShift?` — rotates the hue in degrees (`0``360`). Default: `0` (yellow-to-orange). `120` = green, `240` = blue.
## Customizing the look
```tsx
import { LightLeak } from "@remotion/light-leaks";
// Blue-tinted light leak with a different pattern
<LightLeak seed={5} hueShift={240} />;
// Green-tinted light leak
<LightLeak seed={2} hueShift={120} />;
```
## Standalone usage
`<LightLeak>` can also be used outside of `<TransitionSeries>`, for example as a decorative overlay in any composition:
```tsx
import { AbsoluteFill } from "remotion";
import { LightLeak } from "@remotion/light-leaks";
const MyComp: React.FC = () => (
<AbsoluteFill>
<MyContent />
<LightLeak durationInFrames={60} seed={3} />
</AbsoluteFill>
);
```
@@ -0,0 +1,70 @@
---
name: lottie
description: Embedding Lottie animations in Remotion.
metadata:
category: Animation
---
# Using Lottie Animations in Remotion
## Prerequisites
First, the @remotion/lottie package needs to be installed.
If it is not, use the following command:
```bash
npx remotion add @remotion/lottie # If project uses npm
bunx remotion add @remotion/lottie # If project uses bun
yarn remotion add @remotion/lottie # If project uses yarn
pnpm exec remotion add @remotion/lottie # If project uses pnpm
```
## Displaying a Lottie file
To import a Lottie animation:
- Fetch the Lottie asset
- Wrap the loading process in `delayRender()` and `continueRender()`
- Save the animation data in a state
- Render the Lottie animation using the `Lottie` component from the `@remotion/lottie` package
```tsx
import { Lottie, LottieAnimationData } from "@remotion/lottie";
import { useEffect, useState } from "react";
import { cancelRender, continueRender, delayRender } from "remotion";
export const MyAnimation = () => {
const [handle] = useState(() => delayRender("Loading Lottie animation"));
const [animationData, setAnimationData] =
useState<LottieAnimationData | null>(null);
useEffect(() => {
fetch("https://assets4.lottiefiles.com/packages/lf20_zyquagfl.json")
.then((data) => data.json())
.then((json) => {
setAnimationData(json);
continueRender(handle);
})
.catch((err) => {
cancelRender(err);
});
}, [handle]);
if (!animationData) {
return null;
}
return <Lottie animationData={animationData} />;
};
```
## Styling and animating
Lottie supports the `style` prop to allow styles and animations:
```tsx
return (
<Lottie animationData={animationData} style={{ width: 400, height: 400 }} />
);
```
@@ -0,0 +1,412 @@
---
name: maps
description: Make map animations with Mapbox
metadata:
tags: map, map animation, mapbox
---
Maps can be added to a Remotion video with Mapbox.
The [Mapbox documentation](https://docs.mapbox.com/mapbox-gl-js/api/) has the API reference.
## Prerequisites
Mapbox and `@turf/turf` need to be installed.
Search the project for lockfiles and run the correct command depending on the package manager:
If `package-lock.json` is found, use the following command:
```bash
npm i mapbox-gl @turf/turf @types/mapbox-gl
```
If `bun.lock` is found, use the following command:
```bash
bun i mapbox-gl @turf/turf @types/mapbox-gl
```
If `yarn.lock` is found, use the following command:
```bash
yarn add mapbox-gl @turf/turf @types/mapbox-gl
```
If `pnpm-lock.yaml` is found, use the following command:
```bash
pnpm i mapbox-gl @turf/turf @types/mapbox-gl
```
The user needs to create a free Mapbox account and create an access token by visiting https://console.mapbox.com/account/access-tokens/.
The mapbox token needs to be added to the `.env` file:
```txt title=".env"
REMOTION_MAPBOX_TOKEN==pk.your-mapbox-access-token
```
## Adding a map
Here is a basic example of a map in Remotion.
```tsx
import { useEffect, useMemo, useRef, useState } from "react";
import { AbsoluteFill, useDelayRender, useVideoConfig } from "remotion";
import mapboxgl, { Map } from "mapbox-gl";
export const lineCoordinates = [
[6.56158447265625, 46.059891147620725],
[6.5691375732421875, 46.05679376154153],
[6.5842437744140625, 46.05059898938315],
[6.594886779785156, 46.04702502069337],
[6.601066589355469, 46.0460718554722],
[6.6089630126953125, 46.0365370783104],
[6.6185760498046875, 46.018420689207964],
];
mapboxgl.accessToken = process.env.REMOTION_MAPBOX_TOKEN as string;
export const MyComposition = () => {
const ref = useRef<HTMLDivElement>(null);
const { delayRender, continueRender } = useDelayRender();
const { width, height } = useVideoConfig();
const [handle] = useState(() => delayRender("Loading map..."));
const [map, setMap] = useState<Map | null>(null);
useEffect(() => {
const _map = new Map({
container: ref.current!,
zoom: 11.53,
center: [6.5615, 46.0598],
pitch: 65,
bearing: 0,
style: "mapbox://styles/mapbox/standard",
interactive: false,
fadeDuration: 0,
});
_map.on("style.load", () => {
// Hide all features from the Mapbox Standard style
const hideFeatures = [
"showRoadsAndTransit",
"showRoads",
"showTransit",
"showPedestrianRoads",
"showRoadLabels",
"showTransitLabels",
"showPlaceLabels",
"showPointOfInterestLabels",
"showPointsOfInterest",
"showAdminBoundaries",
"showLandmarkIcons",
"showLandmarkIconLabels",
"show3dObjects",
"show3dBuildings",
"show3dTrees",
"show3dLandmarks",
"show3dFacades",
];
for (const feature of hideFeatures) {
_map.setConfigProperty("basemap", feature, false);
}
_map.setConfigProperty("basemap", "colorTrunks", "rgba(0, 0, 0, 0)");
_map.addSource("trace", {
type: "geojson",
data: {
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: lineCoordinates,
},
},
});
_map.addLayer({
type: "line",
source: "trace",
id: "line",
paint: {
"line-color": "black",
"line-width": 5,
},
layout: {
"line-cap": "round",
"line-join": "round",
},
});
});
_map.on("load", () => {
continueRender(handle);
setMap(_map);
});
}, [handle, lineCoordinates]);
const style: React.CSSProperties = useMemo(
() => ({ width, height, position: "absolute" }),
[width, height],
);
return <AbsoluteFill ref={ref} style={style} />;
};
```
The following is important in Remotion:
- Animations must be driven by `useCurrentFrame()` and animations that Mapbox brings itself should be disabled. For example, the `fadeDuration` prop should be set to `0`, `interactive` should be set to `false`, etc.
- Loading the map should be delayed using `useDelayRender()` and the map should be set to `null` until it is loaded.
- The element containing the ref MUST have an explicit width and height and `position: "absolute"`.
- Do not add a `_map.remove();` cleanup function.
## Drawing lines
Unless I request it, do not add a glow effect to the lines.
Unless I request it, do not add additional points to the lines.
## Map style
By default, use the `mapbox://styles/mapbox/standard` style.
Hide the labels from the base map style.
Unless I request otherwise, remove all features from the Mapbox Standard style.
```tsx
// Hide all features from the Mapbox Standard style
const hideFeatures = [
"showRoadsAndTransit",
"showRoads",
"showTransit",
"showPedestrianRoads",
"showRoadLabels",
"showTransitLabels",
"showPlaceLabels",
"showPointOfInterestLabels",
"showPointsOfInterest",
"showAdminBoundaries",
"showLandmarkIcons",
"showLandmarkIconLabels",
"show3dObjects",
"show3dBuildings",
"show3dTrees",
"show3dLandmarks",
"show3dFacades",
];
for (const feature of hideFeatures) {
_map.setConfigProperty("basemap", feature, false);
}
_map.setConfigProperty("basemap", "colorMotorways", "transparent");
_map.setConfigProperty("basemap", "colorRoads", "transparent");
_map.setConfigProperty("basemap", "colorTrunks", "transparent");
```
## Animating the camera
You can animate the camera along the line by adding a `useEffect` hook that updates the camera position based on the current frame.
Unless I ask for it, do not jump between camera angles.
```tsx
import * as turf from "@turf/turf";
import { interpolate } from "remotion";
import { Easing } from "remotion";
import { useCurrentFrame, useVideoConfig, useDelayRender } from "remotion";
const animationDuration = 20;
const cameraAltitude = 4000;
```
```tsx
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const { delayRender, continueRender } = useDelayRender();
useEffect(() => {
if (!map) {
return;
}
const handle = delayRender("Moving point...");
const routeDistance = turf.length(turf.lineString(lineCoordinates));
const progress = interpolate(
frame / fps,
[0.00001, animationDuration],
[0, 1],
{
easing: Easing.inOut(Easing.sin),
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
},
);
const camera = map.getFreeCameraOptions();
const alongRoute = turf.along(
turf.lineString(lineCoordinates),
routeDistance * progress,
).geometry.coordinates;
camera.lookAtPoint({
lng: alongRoute[0],
lat: alongRoute[1],
});
map.setFreeCameraOptions(camera);
map.once("idle", () => continueRender(handle));
}, [lineCoordinates, fps, frame, handle, map]);
```
Notes:
IMPORTANT: Keep the camera by default so north is up.
IMPORTANT: For multi-step animations, set all properties at all stages (zoom, position, line progress) to prevent jumps. Override initial values.
- The progress is clamped to a minimum value to avoid the line being empty, which can lead to turf errors
- See [Timing](./timing.md) for more options for timing.
- Consider the dimensions of the composition and make the lines thick enough and the label font size large enough to be legible for when the composition is scaled down.
## Animating lines
### Straight lines (linear interpolation)
To animate a line that appears straight on the map, use linear interpolation between coordinates. Do NOT use turf's `lineSliceAlong` or `along` functions, as they use geodesic (great circle) calculations which appear curved on a Mercator projection.
```tsx
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
useEffect(() => {
if (!map) return;
const animationHandle = delayRender("Animating line...");
const progress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
});
// Linear interpolation for a straight line on the map
const start = lineCoordinates[0];
const end = lineCoordinates[1];
const currentLng = start[0] + (end[0] - start[0]) * progress;
const currentLat = start[1] + (end[1] - start[1]) * progress;
const lineData: GeoJSON.Feature<GeoJSON.LineString> = {
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: [start, [currentLng, currentLat]],
},
};
const source = map.getSource("trace") as mapboxgl.GeoJSONSource;
if (source) {
source.setData(lineData);
}
map.once("idle", () => continueRender(animationHandle));
}, [frame, map, durationInFrames]);
```
### Curved lines (geodesic/great circle)
To animate a line that follows the geodesic (great circle) path between two points, use turf's `lineSliceAlong`. This is useful for showing flight paths or the actual shortest distance on Earth.
```tsx
import * as turf from "@turf/turf";
const routeLine = turf.lineString(lineCoordinates);
const routeDistance = turf.length(routeLine);
const currentDistance = Math.max(0.001, routeDistance * progress);
const slicedLine = turf.lineSliceAlong(routeLine, 0, currentDistance);
const source = map.getSource("route") as mapboxgl.GeoJSONSource;
if (source) {
source.setData(slicedLine);
}
```
## Markers
Add labels, and markers where appropriate.
```tsx
_map.addSource("markers", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { name: "Point 1" },
geometry: { type: "Point", coordinates: [-118.2437, 34.0522] },
},
],
},
});
_map.addLayer({
id: "city-markers",
type: "circle",
source: "markers",
paint: {
"circle-radius": 40,
"circle-color": "#FF4444",
"circle-stroke-width": 4,
"circle-stroke-color": "#FFFFFF",
},
});
_map.addLayer({
id: "labels",
type: "symbol",
source: "markers",
layout: {
"text-field": ["get", "name"],
"text-font": ["DIN Pro Bold", "Arial Unicode MS Bold"],
"text-size": 50,
"text-offset": [0, 0.5],
"text-anchor": "top",
},
paint: {
"text-color": "#FFFFFF",
"text-halo-color": "#000000",
"text-halo-width": 2,
},
});
```
Make sure they are big enough. Check the composition dimensions and scale the labels accordingly.
For a composition size of 1920x1080, the label font size should be at least 40px.
IMPORTANT: Keep the `text-offset` small enough so it is close to the marker. Consider the marker circle radius. For a circle radius of 40, this is a good offset:
```tsx
"text-offset": [0, 0.5],
```
## 3D buildings
To enable 3D buildings, use the following code:
```tsx
_map.setConfigProperty("basemap", "show3dObjects", true);
_map.setConfigProperty("basemap", "show3dLandmarks", true);
_map.setConfigProperty("basemap", "show3dBuildings", true);
```
## Rendering
When rendering a map animation, make sure to render with the following flags:
```
npx remotion render --gl=angle --concurrency=1
```
@@ -0,0 +1,34 @@
---
name: measuring-dom-nodes
description: Measuring DOM element dimensions in Remotion
metadata:
tags: measure, layout, dimensions, getBoundingClientRect, scale
---
# Measuring DOM nodes in Remotion
Remotion applies a `scale()` transform to the video container, which affects values from `getBoundingClientRect()`. Use `useCurrentScale()` to get correct measurements.
## Measuring element dimensions
```tsx
import { useCurrentScale } from "remotion";
import { useRef, useEffect, useState } from "react";
export const MyComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const scale = useCurrentScale();
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
setDimensions({
width: rect.width / scale,
height: rect.height / scale,
});
}, [scale]);
return <div ref={ref}>Content to measure</div>;
};
```
@@ -0,0 +1,140 @@
---
name: measuring-text
description: Measuring text dimensions, fitting text to containers, and checking overflow
metadata:
tags: measure, text, layout, dimensions, fitText, fillTextBox
---
# Measuring text in Remotion
## Prerequisites
Install @remotion/layout-utils if it is not already installed:
```bash
npx remotion add @remotion/layout-utils
```
## Measuring text dimensions
Use `measureText()` to calculate the width and height of text:
```tsx
import { measureText } from "@remotion/layout-utils";
const { width, height } = measureText({
text: "Hello World",
fontFamily: "Arial",
fontSize: 32,
fontWeight: "bold",
});
```
Results are cached - duplicate calls return the cached result.
## Fitting text to a width
Use `fitText()` to find the optimal font size for a container:
```tsx
import { fitText } from "@remotion/layout-utils";
const { fontSize } = fitText({
text: "Hello World",
withinWidth: 600,
fontFamily: "Inter",
fontWeight: "bold",
});
return (
<div
style={{
fontSize: Math.min(fontSize, 80), // Cap at 80px
fontFamily: "Inter",
fontWeight: "bold",
}}
>
Hello World
</div>
);
```
## Checking text overflow
Use `fillTextBox()` to check if text exceeds a box:
```tsx
import { fillTextBox } from "@remotion/layout-utils";
const box = fillTextBox({ maxBoxWidth: 400, maxLines: 3 });
const words = ["Hello", "World", "This", "is", "a", "test"];
for (const word of words) {
const { exceedsBox } = box.add({
text: word + " ",
fontFamily: "Arial",
fontSize: 24,
});
if (exceedsBox) {
// Text would overflow, handle accordingly
break;
}
}
```
## Best practices
**Load fonts first:** Only call measurement functions after fonts are loaded.
```tsx
import { loadFont } from "@remotion/google-fonts/Inter";
const { fontFamily, waitUntilDone } = loadFont("normal", {
weights: ["400"],
subsets: ["latin"],
});
waitUntilDone().then(() => {
// Now safe to measure
const { width } = measureText({
text: "Hello",
fontFamily,
fontSize: 32,
});
});
```
**Use validateFontIsLoaded:** Catch font loading issues early:
```tsx
measureText({
text: "Hello",
fontFamily: "MyCustomFont",
fontSize: 32,
validateFontIsLoaded: true, // Throws if font not loaded
});
```
**Match font properties:** Use the same properties for measurement and rendering:
```tsx
const fontStyle = {
fontFamily: "Inter",
fontSize: 32,
fontWeight: "bold" as const,
letterSpacing: "0.5px",
};
const { width } = measureText({
text: "Hello",
...fontStyle,
});
return <div style={fontStyle}>Hello</div>;
```
**Avoid padding and border:** Use `outline` instead of `border` to prevent layout differences:
```tsx
<div style={{ outline: "2px solid red" }}>Text</div>
```
@@ -0,0 +1,109 @@
---
name: parameters
description: Make a video parametrizable by adding a Zod schema
metadata:
tags: parameters, zod, schema
---
To make a video parametrizable, a Zod schema can be added to a composition.
First, `zod` must be installed .
Search the project for lockfiles and run the correct command depending on the package manager:
If `package-lock.json` is found, use the following command:
```bash
npm i zod
```
If `bun.lockb` is found, use the following command:
```bash
bun i zod
```
If `yarn.lock` is found, use the following command:
```bash
yarn add zod
```
If `pnpm-lock.yaml` is found, use the following command:
```bash
pnpm i zod
```
Then, a Zod schema can be defined alongside the component:
```tsx title="src/MyComposition.tsx"
import { z } from "zod";
export const MyCompositionSchema = z.object({
title: z.string(),
});
const MyComponent: React.FC<z.infer<typeof MyCompositionSchema>> = () => {
return (
<div>
<h1>{props.title}</h1>
</div>
);
};
```
In the root file, the schema can be passed to the composition:
```tsx title="src/Root.tsx"
import { Composition } from "remotion";
import { MycComponent, MyCompositionSchema } from "./MyComposition";
export const RemotionRoot = () => {
return (
<Composition
id="MyComposition"
component={MyComponent}
durationInFrames={100}
fps={30}
width={1080}
height={1080}
defaultProps={{ title: "Hello World" }}
schema={MyCompositionSchema}
/>
);
};
```
Now, the user can edit the parameter visually in the sidebar.
All schemas that are supported by Zod are supported by Remotion.
Remotion requires that the top-level type is a z.object(), because the collection of props of a React component is always an object.
## Color picker
For adding a color picker, use `zColor()` from `@remotion/zod-types`.
If it is not installed, use the following command:
```bash
npx remotion add @remotion/zod-types # If project uses npm
bunx remotion add @remotion/zod-types # If project uses bun
yarn remotion add @remotion/zod-types # If project uses yarn
pnpm exec remotion add @remotion/zod-types # If project uses pnpm
```
Then import `zColor` from `@remotion/zod-types`:
```tsx
import { zColor } from "@remotion/zod-types";
```
Then use it in the schema:
```tsx
export const MyCompositionSchema = z.object({
color: zColor(),
});
```
@@ -0,0 +1,118 @@
---
name: sequencing
description: Sequencing patterns for Remotion - delay, trim, limit duration of items
metadata:
tags: sequence, series, timing, delay, trim
---
Use `<Sequence>` to delay when an element appears in the timeline.
```tsx
import { Sequence } from "remotion";
const {fps} = useVideoConfig();
<Sequence from={1 * fps} durationInFrames={2 * fps} premountFor={1 * fps}>
<Title />
</Sequence>
<Sequence from={2 * fps} durationInFrames={2 * fps} premountFor={1 * fps}>
<Subtitle />
</Sequence>
```
This will by default wrap the component in an absolute fill element.
If the items should not be wrapped, use the `layout` prop:
```tsx
<Sequence layout="none">
<Title />
</Sequence>
```
## Premounting
This loads the component in the timeline before it is actually played.
Always premount any `<Sequence>`!
```tsx
<Sequence premountFor={1 * fps}>
<Title />
</Sequence>
```
## Series
Use `<Series>` when elements should play one after another without overlap.
```tsx
import { Series } from "remotion";
<Series>
<Series.Sequence durationInFrames={45}>
<Intro />
</Series.Sequence>
<Series.Sequence durationInFrames={60}>
<MainContent />
</Series.Sequence>
<Series.Sequence durationInFrames={30}>
<Outro />
</Series.Sequence>
</Series>;
```
Same as with `<Sequence>`, the items will be wrapped in an absolute fill element by default when using `<Series.Sequence>`, unless the `layout` prop is set to `none`.
### Series with overlaps
Use negative offset for overlapping sequences:
```tsx
<Series>
<Series.Sequence durationInFrames={60}>
<SceneA />
</Series.Sequence>
<Series.Sequence offset={-15} durationInFrames={60}>
{/* Starts 15 frames before SceneA ends */}
<SceneB />
</Series.Sequence>
</Series>
```
## Frame References Inside Sequences
Inside a Sequence, `useCurrentFrame()` returns the local frame (starting from 0):
```tsx
<Sequence from={60} durationInFrames={30}>
<MyComponent />
{/* Inside MyComponent, useCurrentFrame() returns 0-29, not 60-89 */}
</Sequence>
```
## Nested Sequences
Sequences can be nested for complex timing:
```tsx
<Sequence from={0} durationInFrames={120}>
<Background />
<Sequence from={15} durationInFrames={90} layout="none">
<Title />
</Sequence>
<Sequence from={45} durationInFrames={60} layout="none">
<Subtitle />
</Sequence>
</Sequence>
```
## Nesting compositions within another
To add a composition within another composition, you can use the `<Sequence>` component with a `width` and `height` prop to specify the size of the composition.
```tsx
<AbsoluteFill>
<Sequence width={COMPOSITION_WIDTH} height={COMPOSITION_HEIGHT}>
<CompositionComponent />
</Sequence>
</AbsoluteFill>
```
@@ -0,0 +1,30 @@
---
name: sfx
description: Including sound effects
metadata:
tags: sfx, sound, effect, audio
---
To include a sound effect, use the `<Audio>` tag:
```tsx
import { Audio } from "@remotion/sfx";
<Audio src={"https://remotion.media/whoosh.wav"} />;
```
The following sound effects are available:
- `https://remotion.media/whoosh.wav`
- `https://remotion.media/whip.wav`
- `https://remotion.media/page-turn.wav`
- `https://remotion.media/switch.wav`
- `https://remotion.media/mouse-click.wav`
- `https://remotion.media/shutter-modern.wav`
- `https://remotion.media/shutter-old.wav`
- `https://remotion.media/ding.wav`
- `https://remotion.media/bruh.wav`
- `https://remotion.media/vine-boom.wav`
- `https://remotion.media/windows-xp-error.wav`
For more sound effects, search the internet. A good resource is https://github.com/kapishdima/soundcn/tree/main/assets.
@@ -0,0 +1,36 @@
---
name: subtitles
description: subtitles and caption rules
metadata:
tags: subtitles, captions, remotion, json
---
All captions must be processed in JSON. The captions must use the `Caption` type which is the following:
```ts
import type { Caption } from "@remotion/captions";
```
This is the definition:
```ts
type Caption = {
text: string;
startMs: number;
endMs: number;
timestampMs: number | null;
confidence: number | null;
};
```
## Generating captions
To transcribe video and audio files to generate captions, load the [./transcribe-captions.md](./transcribe-captions.md) file for more instructions.
## Displaying captions
To display captions in your video, load the [./display-captions.md](./display-captions.md) file for more instructions.
## Importing captions
To import captions from a .srt file, load the [./import-srt-captions.md](./import-srt-captions.md) file for more instructions.
@@ -0,0 +1,11 @@
---
name: tailwind
description: Using TailwindCSS in Remotion.
metadata:
---
You can and should use TailwindCSS in Remotion, if TailwindCSS is installed in the project.
Don't use `transition-*` or `animate-*` classes - always animate using the `useCurrentFrame()` hook.
Tailwind must be installed and enabled first in a Remotion project - fetch https://www.remotion.dev/docs/tailwind using WebFetch for instructions.
@@ -0,0 +1,20 @@
---
name: text-animations
description: Typography and text animation patterns for Remotion.
metadata:
tags: typography, text, typewriter, highlighter ken
---
## Text animations
Based on `useCurrentFrame()`, reduce the string character by character to create a typewriter effect.
## Typewriter Effect
See [Typewriter](assets/text-animations-typewriter.tsx) for an advanced example with a blinking cursor and a pause after the first sentence.
Always use string slicing for typewriter effects. Never use per-character opacity.
## Word Highlighting
See [Word Highlight](assets/text-animations-word-highlight.tsx) for an example for how a word highlight is animated, like with a highlighter pen.
@@ -0,0 +1,179 @@
---
name: timing
description: Interpolation curves in Remotion - linear, easing, spring animations
metadata:
tags: spring, bounce, easing, interpolation
---
A simple linear interpolation is done using the `interpolate` function.
```ts title="Going from 0 to 1 over 100 frames"
import { interpolate } from "remotion";
const opacity = interpolate(frame, [0, 100], [0, 1]);
```
By default, the values are not clamped, so the value can go outside the range [0, 1].
Here is how they can be clamped:
```ts title="Going from 0 to 1 over 100 frames with extrapolation"
const opacity = interpolate(frame, [0, 100], [0, 1], {
extrapolateRight: "clamp",
extrapolateLeft: "clamp",
});
```
## Spring animations
Spring animations have a more natural motion.
They go from 0 to 1 over time.
```ts title="Spring animation from 0 to 1 over 100 frames"
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scale = spring({
frame,
fps,
});
```
### Physical properties
The default configuration is: `mass: 1, damping: 10, stiffness: 100`.
This leads to the animation having a bit of bounce before it settles.
The config can be overwritten like this:
```ts
const scale = spring({
frame,
fps,
config: { damping: 200 },
});
```
The recommended configuration for a natural motion without a bounce is: `{ damping: 200 }`.
Here are some common configurations:
```tsx
const smooth = { damping: 200 }; // Smooth, no bounce (subtle reveals)
const snappy = { damping: 20, stiffness: 200 }; // Snappy, minimal bounce (UI elements)
const bouncy = { damping: 8 }; // Bouncy entrance (playful animations)
const heavy = { damping: 15, stiffness: 80, mass: 2 }; // Heavy, slow, small bounce
```
### Delay
The animation starts immediately by default.
Use the `delay` parameter to delay the animation by a number of frames.
```tsx
const entrance = spring({
frame: frame - ENTRANCE_DELAY,
fps,
delay: 20,
});
```
### Duration
A `spring()` has a natural duration based on the physical properties.
To stretch the animation to a specific duration, use the `durationInFrames` parameter.
```tsx
const spring = spring({
frame,
fps,
durationInFrames: 40,
});
```
### Combining spring() with interpolate()
Map spring output (0-1) to custom ranges:
```tsx
const springProgress = spring({
frame,
fps,
});
// Map to rotation
const rotation = interpolate(springProgress, [0, 1], [0, 360]);
<div style={{ rotate: rotation + "deg" }} />;
```
### Adding springs
Springs return just numbers, so math can be performed:
```tsx
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const inAnimation = spring({
frame,
fps,
});
const outAnimation = spring({
frame,
fps,
durationInFrames: 1 * fps,
delay: durationInFrames - 1 * fps,
});
const scale = inAnimation - outAnimation;
```
## Easing
Easing can be added to the `interpolate` function:
```ts
import { interpolate, Easing } from "remotion";
const value1 = interpolate(frame, [0, 100], [0, 1], {
easing: Easing.inOut(Easing.quad),
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
```
The default easing is `Easing.linear`.
There are various other convexities:
- `Easing.in` for starting slow and accelerating
- `Easing.out` for starting fast and slowing down
- `Easing.inOut`
and curves (sorted from most linear to most curved):
- `Easing.quad`
- `Easing.sin`
- `Easing.exp`
- `Easing.circle`
Convexities and curves need be combined for an easing function:
```ts
const value1 = interpolate(frame, [0, 100], [0, 1], {
easing: Easing.inOut(Easing.quad),
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
```
Cubic bezier curves are also supported:
```ts
const value1 = interpolate(frame, [0, 100], [0, 1], {
easing: Easing.bezier(0.8, 0.22, 0.96, 0.65),
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
```
@@ -0,0 +1,70 @@
---
name: transcribe-captions
description: Transcribing audio to generate captions in Remotion
metadata:
tags: captions, transcribe, whisper, audio, speech-to-text
---
# Transcribing audio
To transcribe audio to generate captions in Remotion, you can use the [`transcribe()`](https://www.remotion.dev/docs/install-whisper-cpp/transcribe) function from the [`@remotion/install-whisper-cpp`](https://www.remotion.dev/docs/install-whisper-cpp) package.
## Prerequisites
First, the @remotion/install-whisper-cpp package needs to be installed.
If it is not installed, use the following command:
```bash
npx remotion add @remotion/install-whisper-cpp
```
## Transcribing
Make a Node.js script to download Whisper.cpp and a model, and transcribe the audio.
```ts
import path from "path";
import {
downloadWhisperModel,
installWhisperCpp,
transcribe,
toCaptions,
} from "@remotion/install-whisper-cpp";
import fs from "fs";
const to = path.join(process.cwd(), "whisper.cpp");
await installWhisperCpp({
to,
version: "1.5.5",
});
await downloadWhisperModel({
model: "medium.en",
folder: to,
});
// Convert the audio to a 16KHz wav file first if needed:
// import {execSync} from 'child_process';
// execSync('ffmpeg -i /path/to/audio.mp4 -ar 16000 /path/to/audio.wav -y');
const whisperCppOutput = await transcribe({
model: "medium.en",
whisperPath: to,
whisperCppVersion: "1.5.5",
inputPath: "/path/to/audio123.wav",
tokenLevelTimestamps: true,
});
// Optional: Apply our recommended postprocessing
const { captions } = toCaptions({
whisperCppOutput,
});
// Write it to the public/ folder so it can be fetched from Remotion
fs.writeFileSync("captions123.json", JSON.stringify(captions, null, 2));
```
Transcribe each clip individually and create multiple JSON files.
See [Displaying captions](display-captions.md) for how to display the captions in Remotion.
@@ -0,0 +1,197 @@
---
name: transitions
description: Scene transitions and overlays for Remotion using TransitionSeries.
metadata:
tags: transitions, overlays, fade, slide, wipe, scenes
---
## TransitionSeries
`<TransitionSeries>` arranges scenes and supports two ways to enhance the cut point between them:
- **Transitions** (`<TransitionSeries.Transition>`) — crossfade, slide, wipe, etc. between two scenes. Shortens the timeline because both scenes play simultaneously during the transition.
- **Overlays** (`<TransitionSeries.Overlay>`) — render an effect (e.g. a light leak) on top of the cut point without shortening the timeline.
Children are absolutely positioned.
## Prerequisites
```bash
npx remotion add @remotion/transitions
```
## Transition example
```tsx
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneA />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: 15 })}
/>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneB />
</TransitionSeries.Sequence>
</TransitionSeries>;
```
## Overlay example
Any React component can be used as an overlay. For a ready-made effect, see the **light-leaks** rule.
```tsx
import { TransitionSeries } from "@remotion/transitions";
import { LightLeak } from "@remotion/light-leaks";
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneA />
</TransitionSeries.Sequence>
<TransitionSeries.Overlay durationInFrames={20}>
<LightLeak />
</TransitionSeries.Overlay>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneB />
</TransitionSeries.Sequence>
</TransitionSeries>;
```
## Mixing transitions and overlays
Transitions and overlays can coexist in the same `<TransitionSeries>`, but an overlay cannot be adjacent to a transition or another overlay.
```tsx
import { TransitionSeries, linearTiming } from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { LightLeak } from "@remotion/light-leaks";
<TransitionSeries>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneA />
</TransitionSeries.Sequence>
<TransitionSeries.Overlay durationInFrames={30}>
<LightLeak />
</TransitionSeries.Overlay>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneB />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: 15 })}
/>
<TransitionSeries.Sequence durationInFrames={60}>
<SceneC />
</TransitionSeries.Sequence>
</TransitionSeries>;
```
## Transition props
`<TransitionSeries.Transition>` requires:
- `presentation` — the visual effect (e.g. `fade()`, `slide()`, `wipe()`).
- `timing` — controls speed and easing (e.g. `linearTiming()`, `springTiming()`).
## Overlay props
`<TransitionSeries.Overlay>` accepts:
- `durationInFrames` — how long the overlay is visible (positive integer).
- `offset?` — shifts the overlay relative to the cut point center. Positive = later, negative = earlier. Default: `0`.
## Available transition types
Import transitions from their respective modules:
```tsx
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { wipe } from "@remotion/transitions/wipe";
import { flip } from "@remotion/transitions/flip";
import { clockWipe } from "@remotion/transitions/clock-wipe";
```
## Slide transition with direction
```tsx
import { slide } from "@remotion/transitions/slide";
<TransitionSeries.Transition
presentation={slide({ direction: "from-left" })}
timing={linearTiming({ durationInFrames: 20 })}
/>;
```
Directions: `"from-left"`, `"from-right"`, `"from-top"`, `"from-bottom"`
## Timing options
```tsx
import { linearTiming, springTiming } from "@remotion/transitions";
// Linear timing - constant speed
linearTiming({ durationInFrames: 20 });
// Spring timing - organic motion
springTiming({ config: { damping: 200 }, durationInFrames: 25 });
```
## Duration calculation
Transitions overlap adjacent scenes, so the total composition length is **shorter** than the sum of all sequence durations. Overlays do **not** affect the total duration.
For example, with two 60-frame sequences and a 15-frame transition:
- Without transitions: `60 + 60 = 120` frames
- With transition: `60 + 60 - 15 = 105` frames
Adding an overlay between two other sequences does not change the total.
### Getting the duration of a transition
Use the `getDurationInFrames()` method on the timing object:
```tsx
import { linearTiming, springTiming } from "@remotion/transitions";
const linearDuration = linearTiming({
durationInFrames: 20,
}).getDurationInFrames({ fps: 30 });
// Returns 20
const springDuration = springTiming({
config: { damping: 200 },
}).getDurationInFrames({ fps: 30 });
// Returns calculated duration based on spring physics
```
For `springTiming` without an explicit `durationInFrames`, the duration depends on `fps` because it calculates when the spring animation settles.
### Calculating total composition duration
```tsx
import { linearTiming } from "@remotion/transitions";
const scene1Duration = 60;
const scene2Duration = 60;
const scene3Duration = 60;
const timing1 = linearTiming({ durationInFrames: 15 });
const timing2 = linearTiming({ durationInFrames: 20 });
const transition1Duration = timing1.getDurationInFrames({ fps: 30 });
const transition2Duration = timing2.getDurationInFrames({ fps: 30 });
const totalDuration =
scene1Duration +
scene2Duration +
scene3Duration -
transition1Duration -
transition2Duration;
// 60 + 60 + 60 - 15 - 20 = 145 frames
```
@@ -0,0 +1,106 @@
---
name: transparent-videos
description: Rendering transparent videos in Remotion
metadata:
tags: transparent, alpha, codec, vp9, prores, webm
---
# Rendering Transparent Videos
Remotion can render transparent videos in two ways: as a ProRes video or as a WebM video.
## Transparent ProRes
Ideal for when importing into video editing software.
**CLI:**
```bash
npx remotion render --image-format=png --pixel-format=yuva444p10le --codec=prores --prores-profile=4444 MyComp out.mov
```
**Default in Studio** (restart Studio after changing):
```ts
// remotion.config.ts
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("png");
Config.setPixelFormat("yuva444p10le");
Config.setCodec("prores");
Config.setProResProfile("4444");
```
**Setting it as the default export settings for a composition** (using `calculateMetadata`):
```tsx
import { CalculateMetadataFunction } from "remotion";
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
return {
defaultCodec: "prores",
defaultVideoImageFormat: "png",
defaultPixelFormat: "yuva444p10le",
defaultProResProfile: "4444",
};
};
<Composition
id="my-video"
component={MyVideo}
durationInFrames={150}
fps={30}
width={1920}
height={1080}
calculateMetadata={calculateMetadata}
/>;
```
## Transparent WebM (VP9)
Ideal for when playing in a browser.
**CLI:**
```bash
npx remotion render --image-format=png --pixel-format=yuva420p --codec=vp9 MyComp out.webm
```
**Default in Studio** (restart Studio after changing):
```ts
// remotion.config.ts
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("png");
Config.setPixelFormat("yuva420p");
Config.setCodec("vp9");
```
**Setting it as the default export settings for a composition** (using `calculateMetadata`):
```tsx
import { CalculateMetadataFunction } from "remotion";
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
return {
defaultCodec: "vp8",
defaultVideoImageFormat: "png",
defaultPixelFormat: "yuva420p",
};
};
<Composition
id="my-video"
component={MyVideo}
durationInFrames={150}
fps={30}
width={1920}
height={1080}
calculateMetadata={calculateMetadata}
/>;
```
@@ -0,0 +1,51 @@
---
name: trimming
description: Trimming patterns for Remotion - cut the beginning or end of animations
metadata:
tags: sequence, trim, clip, cut, offset
---
Use `<Sequence>` with a negative `from` value to trim the start of an animation.
## Trim the Beginning
A negative `from` value shifts time backwards, making the animation start partway through:
```tsx
import { Sequence, useVideoConfig } from "remotion";
const fps = useVideoConfig();
<Sequence from={-0.5 * fps}>
<MyAnimation />
</Sequence>;
```
The animation appears 15 frames into its progress - the first 15 frames are trimmed off.
Inside `<MyAnimation>`, `useCurrentFrame()` starts at 15 instead of 0.
## Trim the End
Use `durationInFrames` to unmount content after a specified duration:
```tsx
<Sequence durationInFrames={1.5 * fps}>
<MyAnimation />
</Sequence>
```
The animation plays for 45 frames, then the component unmounts.
## Trim and Delay
Nest sequences to both trim the beginning and delay when it appears:
```tsx
<Sequence from={30}>
<Sequence from={-15}>
<MyAnimation />
</Sequence>
</Sequence>
```
The inner sequence trims 15 frames from the start, and the outer sequence delays the result by 30 frames.
@@ -0,0 +1,171 @@
---
name: videos
description: Embedding videos in Remotion - trimming, volume, speed, looping, pitch
metadata:
tags: video, media, trim, volume, speed, loop, pitch
---
# Using videos in Remotion
## Prerequisites
First, the @remotion/media package needs to be installed.
If it is not, use the following command:
```bash
npx remotion add @remotion/media # If project uses npm
bunx remotion add @remotion/media # If project uses bun
yarn remotion add @remotion/media # If project uses yarn
pnpm exec remotion add @remotion/media # If project uses pnpm
```
Use `<Video>` from `@remotion/media` to embed videos into your composition.
```tsx
import { Video } from "@remotion/media";
import { staticFile } from "remotion";
export const MyComposition = () => {
return <Video src={staticFile("video.mp4")} />;
};
```
Remote URLs are also supported:
```tsx
<Video src="https://remotion.media/video.mp4" />
```
## Trimming
Use `trimBefore` and `trimAfter` to remove portions of the video. Values are in seconds.
```tsx
const { fps } = useVideoConfig();
return (
<Video
src={staticFile("video.mp4")}
trimBefore={2 * fps} // Skip the first 2 seconds
trimAfter={10 * fps} // End at the 10 second mark
/>
);
```
## Delaying
Wrap the video in a `<Sequence>` to delay when it appears:
```tsx
import { Sequence, staticFile } from "remotion";
import { Video } from "@remotion/media";
const { fps } = useVideoConfig();
return (
<Sequence from={1 * fps}>
<Video src={staticFile("video.mp4")} />
</Sequence>
);
```
The video will appear after 1 second.
## Sizing and Position
Use the `style` prop to control size and position:
```tsx
<Video
src={staticFile("video.mp4")}
style={{
width: 500,
height: 300,
position: "absolute",
top: 100,
left: 50,
objectFit: "cover",
}}
/>
```
## Volume
Set a static volume (0 to 1):
```tsx
<Video src={staticFile("video.mp4")} volume={0.5} />
```
Or use a callback for dynamic volume based on the current frame:
```tsx
import { interpolate } from "remotion";
const { fps } = useVideoConfig();
return (
<Video
src={staticFile("video.mp4")}
volume={(f) =>
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
}
/>
);
```
Use `muted` to silence the video entirely:
```tsx
<Video src={staticFile("video.mp4")} muted />
```
## Speed
Use `playbackRate` to change the playback speed:
```tsx
<Video src={staticFile("video.mp4")} playbackRate={2} /> {/* 2x speed */}
<Video src={staticFile("video.mp4")} playbackRate={0.5} /> {/* Half speed */}
```
Reverse playback is not supported.
## Looping
Use `loop` to loop the video indefinitely:
```tsx
<Video src={staticFile("video.mp4")} loop />
```
Use `loopVolumeCurveBehavior` to control how the frame count behaves when looping:
- `"repeat"`: Frame count resets to 0 each loop (for `volume` callback)
- `"extend"`: Frame count continues incrementing
```tsx
<Video
src={staticFile("video.mp4")}
loop
loopVolumeCurveBehavior="extend"
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
/>
```
## Pitch
Use `toneFrequency` to adjust the pitch without affecting speed. Values range from 0.01 to 2:
```tsx
<Video
src={staticFile("video.mp4")}
toneFrequency={1.5} // Higher pitch
/>
<Video
src={staticFile("video.mp4")}
toneFrequency={0.8} // Lower pitch
/>
```
Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the `<Player />`.
@@ -0,0 +1,99 @@
---
name: voiceover
description: Adding AI-generated voiceover to Remotion compositions using TTS
metadata:
tags: voiceover, audio, elevenlabs, tts, speech, calculateMetadata, dynamic duration
---
# Adding AI voiceover to a Remotion composition
Use ElevenLabs TTS to generate speech audio per scene, then use [`calculateMetadata`](./calculate-metadata) to dynamically size the composition to match the audio.
## Prerequisites
By default this guide uses **ElevenLabs** as the TTS provider (`ELEVENLABS_API_KEY` environment variable). Users may substitute any TTS service that can produce an audio file.
If the user has not specified a TTS provider, recommend ElevenLabs and ask for their API key.
Ensure the environment variable is available when running the generation script:
```bash
node --strip-types generate-voiceover.ts
```
## Generating audio with ElevenLabs
Create a script that reads the config, calls the ElevenLabs API for each scene, and writes MP3 files to the `public/` directory so Remotion can access them via `staticFile()`.
The core API call for a single scene:
```ts title="generate-voiceover.ts"
const response = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
{
method: "POST",
headers: {
"xi-api-key": process.env.ELEVENLABS_API_KEY!,
"Content-Type": "application/json",
Accept: "audio/mpeg",
},
body: JSON.stringify({
text: "Welcome to the show.",
model_id: "eleven_multilingual_v2",
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
style: 0.3,
},
}),
},
);
const audioBuffer = Buffer.from(await response.arrayBuffer());
writeFileSync(`public/voiceover/${compositionId}/${scene.id}.mp3`, audioBuffer);
```
## Dynamic composition duration with calculateMetadata
Use [`calculateMetadata`](./calculate-metadata.md) to measure the [audio durations](./get-audio-duration.md) and set the composition length accordingly.
```tsx
import { CalculateMetadataFunction, staticFile } from "remotion";
import { getAudioDuration } from "./get-audio-duration";
const FPS = 30;
const SCENE_AUDIO_FILES = [
"voiceover/my-comp/scene-01-intro.mp3",
"voiceover/my-comp/scene-02-main.mp3",
"voiceover/my-comp/scene-03-outro.mp3",
];
export const calculateMetadata: CalculateMetadataFunction<Props> = async ({
props,
}) => {
const durations = await Promise.all(
SCENE_AUDIO_FILES.map((file) => getAudioDuration(staticFile(file))),
);
const sceneDurations = durations.map((durationInSeconds) => {
return durationInSeconds * FPS;
});
return {
durationInFrames: Math.ceil(sceneDurations.reduce((sum, d) => sum + d, 0)),
};
};
```
The computed `sceneDurations` are passed into the component via a `voiceover` prop so the component knows how long each scene should be.
If the composition uses [`<TransitionSeries>`](./transitions.md), subtract the overlap from total duration: [./transitions.md#calculating-total-composition-duration](./transitions.md#calculating-total-composition-duration)
## Rendering audio in the component
See [audio.md](./audio.md) for more information on how to render audio in the component.
## Delaying audio start
See [audio.md#delaying](./audio.md#delaying) for more information on how to delay the audio start.
@@ -0,0 +1 @@
../../.agents/skills/remotion-best-practices
@@ -0,0 +1 @@
../../.agents/skills/remotion-best-practices
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.DS_Store
.env
# Ignore the output video from Git but not videos you import into src/.
out
@@ -0,0 +1 @@
../../.agents/skills/remotion-best-practices
+5
View File
@@ -0,0 +1,5 @@
{
"useTabs": false,
"bracketSpacing": true,
"tabWidth": 2
}
+54
View File
@@ -0,0 +1,54 @@
# Remotion video
<p align="center">
<a href="https://github.com/remotion-dev/logo">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-dark.apng">
<img alt="Animated Remotion Logo" src="https://github.com/remotion-dev/logo/raw/main/animated-logo-banner-light.gif">
</picture>
</a>
</p>
Welcome to your Remotion project!
## Commands
**Install Dependencies**
```console
npm i
```
**Start Preview**
```console
npm run dev
```
**Render video**
```console
npx remotion render
```
**Upgrade Remotion**
```console
npx remotion upgrade
```
## Docs
Get started with Remotion by reading the [fundamentals page](https://www.remotion.dev/docs/the-fundamentals).
## Help
We provide help on our [Discord server](https://discord.gg/6VzzNDwUwV).
## Issues
Found an issue with Remotion? [File an issue here](https://github.com/remotion-dev/remotion/issues/new).
## License
Note that for some entities a company license is needed. [Read the terms here](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md).
+3
View File
@@ -0,0 +1,3 @@
import { config } from "@remotion/eslint-config-flat";
export default config;
+4899
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "openswarm-demo1",
"version": "1.0.0",
"description": "My Remotion video",
"repository": {},
"license": "UNLICENSED",
"private": true,
"dependencies": {
"@remotion/cli": "4.0.441",
"@remotion/tailwind-v4": "4.0.441",
"@remotion/transitions": "4.0.441",
"react": "19.2.3",
"react-dom": "19.2.3",
"remotion": "4.0.441",
"tailwindcss": "4.0.0"
},
"devDependencies": {
"@remotion/eslint-config-flat": "4.0.441",
"@types/react": "19.2.7",
"@types/web": "0.0.166",
"eslint": "9.19.0",
"prettier": "3.8.1",
"typescript": "5.9.3"
},
"scripts": {
"dev": "remotion studio",
"build": "remotion bundle",
"upgrade": "remotion upgrade",
"lint": "eslint src && tsc"
},
"sideEffects": [
"*.css"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+13
View File
@@ -0,0 +1,13 @@
/**
* Note: When using the Node.JS APIs, the config file
* doesn't apply. Instead, pass options directly to the APIs.
*
* All configuration options: https://remotion.dev/docs/config
*/
import { Config } from "@remotion/cli/config";
import { enableTailwind } from '@remotion/tailwind-v4';
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
Config.overrideWebpackConfig(enableTailwind);
+65
View File
@@ -0,0 +1,65 @@
import { AbsoluteFill } from "remotion";
import {
TransitionSeries,
linearTiming,
} from "@remotion/transitions";
import { fade } from "@remotion/transitions/fade";
import { slide } from "@remotion/transitions/slide";
import { HookScene } from "./scenes/HookScene";
import { IntroScene } from "./scenes/IntroScene";
import { DashboardScene } from "./scenes/DashboardScene";
import { FeaturesScene } from "./scenes/FeaturesScene";
import { CtaScene } from "./scenes/CtaScene";
export const MyComposition: React.FC = () => {
return (
<AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>
<TransitionSeries>
{/* Scene 1: Hook - pain point (4s) */}
<TransitionSeries.Sequence durationInFrames={120}>
<HookScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: 15 })}
/>
{/* Scene 2: Product intro with logo (5s) */}
<TransitionSeries.Sequence durationInFrames={150}>
<IntroScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={slide({ direction: "from-right" })}
timing={linearTiming({ durationInFrames: 15 })}
/>
{/* Scene 3: Dashboard demo with zoom/pan (10s) */}
<TransitionSeries.Sequence durationInFrames={300}>
<DashboardScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: 15 })}
/>
{/* Scene 4: Feature callouts (6s) */}
<TransitionSeries.Sequence durationInFrames={180}>
<FeaturesScene />
</TransitionSeries.Sequence>
<TransitionSeries.Transition
presentation={fade()}
timing={linearTiming({ durationInFrames: 15 })}
/>
{/* Scene 5: CTA (5s) */}
<TransitionSeries.Sequence durationInFrames={150}>
<CtaScene />
</TransitionSeries.Sequence>
</TransitionSeries>
</AbsoluteFill>
);
};
+22
View File
@@ -0,0 +1,22 @@
import "./index.css";
import { Composition } from "remotion";
import { MyComposition } from "./Composition";
// Total scene durations: 120 + 150 + 300 + 180 + 150 = 900
// Minus transitions: 4 * 15 = 60
// Effective duration: 840 frames = 28 seconds at 30fps
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="OpenSwarmDemo"
component={MyComposition}
durationInFrames={840}
fps={30}
width={1920}
height={1080}
/>
</>
);
};
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);
+148
View File
@@ -0,0 +1,148 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Img,
staticFile,
} from "remotion";
export const CtaScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
const logoProgress = spring({
frame,
fps,
config: { damping: 12 },
});
const textProgress = spring({
frame: frame - 8,
fps,
config: { damping: 200 },
});
const urlProgress = spring({
frame: frame - 18,
fps,
config: { damping: 200 },
});
const subtextProgress = spring({
frame: frame - 28,
fps,
config: { damping: 200 },
});
const textY = interpolate(textProgress, [0, 1], [20, 0]);
const urlY = interpolate(urlProgress, [0, 1], [20, 0]);
// Subtle pulsing glow on CTA
const pulse = interpolate(
frame % 60,
[0, 30, 60],
[0.4, 0.7, 0.4],
{ extrapolateRight: "clamp" }
);
// Fade out at the very end
const fadeOut = interpolate(
frame,
[durationInFrames - 10, durationInFrames],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<AbsoluteFill
style={{
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
opacity: fadeOut,
}}
>
{/* Background glow */}
<div
style={{
position: "absolute",
width: 500,
height: 500,
borderRadius: "50%",
background:
"radial-gradient(circle, #ae5630 0%, transparent 60%)",
opacity: pulse * 0.15,
}}
/>
{/* Logo */}
<div
style={{
transform: `scale(${logoProgress})`,
marginBottom: 24,
}}
>
<Img
src={staticFile("icon.png")}
style={{
width: 100,
height: 100,
borderRadius: 16,
}}
/>
</div>
{/* Get Started text */}
<div
style={{
fontSize: 56,
fontWeight: 800,
color: "#ffffff",
opacity: textProgress,
transform: `translateY(${textY}px)`,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
letterSpacing: -1,
}}
>
Get Started
</div>
{/* URL */}
<div
style={{
fontSize: 32,
fontWeight: 600,
color: "#ae5630",
opacity: Math.max(0, urlProgress),
transform: `translateY(${urlY}px)`,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
marginTop: 16,
padding: "12px 32px",
border: "2px solid #ae5630",
borderRadius: 12,
}}
>
openswarm.info
</div>
{/* Open source badge */}
<div
style={{
fontSize: 18,
color: "#666",
opacity: Math.max(0, subtextProgress),
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
marginTop: 24,
fontWeight: 500,
}}
>
Free & Open Source
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,135 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Img,
staticFile,
Easing,
} from "remotion";
export const DashboardScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Screenshot enters with a spring scale
const enterProgress = spring({
frame,
fps,
config: { damping: 200 },
});
const screenshotScale = interpolate(enterProgress, [0, 1], [0.85, 1]);
const screenshotOpacity = enterProgress;
// Slow zoom into the dashboard over time
const zoomProgress = interpolate(frame, [30, 240], [1, 1.25], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.quad),
});
// Pan slightly to show different parts
const panX = interpolate(frame, [30, 120, 200, 240], [0, -40, -20, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const panY = interpolate(frame, [30, 120, 200, 240], [0, -20, -30, -10], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Label callouts that appear at different times
const labels = [
{
text: "Spatial Canvas",
x: 640,
y: 80,
delay: 40,
},
{
text: "5 Agents Running",
x: 350,
y: 50,
delay: 80,
},
{
text: "Browser & View Cards",
x: 900,
y: 400,
delay: 130,
},
];
return (
<AbsoluteFill
style={{
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
}}
>
{/* Screenshot with zoom/pan */}
<div
style={{
opacity: screenshotOpacity,
transform: `scale(${screenshotScale * zoomProgress}) translate(${panX}px, ${panY}px)`,
}}
>
<Img
src={staticFile("screenshot.png")}
style={{
width: 1200,
borderRadius: 16,
boxShadow: "0 20px 80px rgba(174, 86, 48, 0.3)",
}}
/>
</div>
{/* Animated labels */}
{labels.map((label, i) => {
const labelProgress = spring({
frame: frame - label.delay,
fps,
config: { damping: 200 },
});
const labelOpacity = Math.max(0, labelProgress);
const labelY = interpolate(labelProgress, [0, 1], [15, 0]);
return (
<div
key={i}
style={{
position: "absolute",
left: label.x,
top: label.y,
opacity: labelOpacity,
transform: `translateY(${labelY}px) scale(${zoomProgress}) translate(${panX}px, ${panY}px)`,
}}
>
<div
style={{
background: "rgba(174, 86, 48, 0.95)",
color: "#fff",
padding: "8px 18px",
borderRadius: 8,
fontSize: 20,
fontWeight: 600,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
whiteSpace: "nowrap",
boxShadow: "0 4px 20px rgba(0,0,0,0.5)",
}}
>
{label.text}
</div>
</div>
);
})}
</AbsoluteFill>
);
};
@@ -0,0 +1,167 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
const features = [
{
icon: "parallel",
title: "Parallel Agents",
description: "Run unlimited agents side by side",
color: "#818cf8",
},
{
icon: "approval",
title: "Human-in-the-Loop",
description: "Approve every action before it runs",
color: "#4ade80",
},
{
icon: "local",
title: "100% Local",
description: "No cloud. No telemetry. Your machine.",
color: "#fbbf24",
},
];
const FeatureIcon: React.FC<{ type: string; color: string }> = ({
type,
color,
}) => {
if (type === "parallel") {
return (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<rect x="4" y="8" width="14" height="28" rx="3" fill={color} opacity={0.7} />
<rect x="22" y="4" width="14" height="36" rx="3" fill={color} />
<rect x="13" y="12" width="14" height="20" rx="3" fill={color} opacity={0.5} />
</svg>
);
}
if (type === "approval") {
return (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<circle cx="22" cy="22" r="18" stroke={color} strokeWidth="3" fill="none" />
<path d="M14 22l6 6 10-12" stroke={color} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
return (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<rect x="6" y="6" width="32" height="32" rx="6" stroke={color} strokeWidth="3" fill="none" />
<circle cx="22" cy="22" r="6" fill={color} />
<path d="M22 12v-4M22 36v-4M12 22H8M36 22h-4" stroke={color} strokeWidth="2" strokeLinecap="round" />
</svg>
);
};
export const FeaturesScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const titleProgress = spring({
frame,
fps,
config: { damping: 200 },
});
return (
<AbsoluteFill
style={{
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
padding: 80,
}}
>
{/* Section title */}
<div
style={{
position: "absolute",
top: 100,
fontSize: 22,
fontWeight: 600,
color: "#ae5630",
opacity: titleProgress,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
textTransform: "uppercase",
letterSpacing: 4,
}}
>
Why Open Swarm
</div>
{/* Feature cards */}
<div
style={{
display: "flex",
gap: 48,
marginTop: 40,
}}
>
{features.map((feature, i) => {
const cardProgress = spring({
frame: frame - 8 - i * 10,
fps,
config: { damping: 15, stiffness: 120 },
});
const cardScale = interpolate(cardProgress, [0, 1], [0.8, 1]);
const cardY = interpolate(cardProgress, [0, 1], [40, 0]);
return (
<div
key={i}
style={{
opacity: Math.max(0, cardProgress),
transform: `scale(${cardScale}) translateY(${cardY}px)`,
background: "#141414",
border: `1px solid ${feature.color}22`,
borderRadius: 16,
padding: "40px 36px",
width: 320,
textAlign: "center",
}}
>
<div
style={{
marginBottom: 20,
display: "flex",
justifyContent: "center",
}}
>
<FeatureIcon type={feature.icon} color={feature.color} />
</div>
<div
style={{
fontSize: 28,
fontWeight: 700,
color: "#ffffff",
marginBottom: 12,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}}
>
{feature.title}
</div>
<div
style={{
fontSize: 18,
color: "#888",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
lineHeight: 1.5,
}}
>
{feature.description}
</div>
</div>
);
})}
</div>
</AbsoluteFill>
);
};
+117
View File
@@ -0,0 +1,117 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from "remotion";
export const HookScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const line1 = "Managing AI agents";
const line2 = "shouldn't feel like this.";
const line1Progress = spring({
frame,
fps,
config: { damping: 200 },
});
const line2Progress = spring({
frame: frame - 15,
fps,
config: { damping: 200 },
});
const line1Y = interpolate(line1Progress, [0, 1], [40, 0]);
const line2Y = interpolate(line2Progress, [0, 1], [40, 0]);
// Terminal chaos icons that fade in scattered
const chaosOpacity = interpolate(frame, [20, 35], [0, 0.15], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const terminalLines = [
"$ claude --chat agent-1 &",
"$ claude --chat agent-2 &",
"$ claude --chat agent-3 &",
"[agent-1] Error: context overflow",
"[agent-2] Waiting for approval...",
"[agent-3] ████████ 43% complete",
"$ fg 1",
"[agent-1] Permission denied: write",
];
return (
<AbsoluteFill
style={{
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
}}
>
{/* Scattered terminal text in background */}
<div
style={{
position: "absolute",
inset: 0,
opacity: chaosOpacity,
fontFamily: "monospace",
fontSize: 14,
color: "#ff6b5b",
padding: 60,
lineHeight: 2.2,
whiteSpace: "pre-wrap",
}}
>
{terminalLines.map((line, i) => {
const charCount = interpolate(
frame,
[20 + i * 3, 35 + i * 3],
[0, line.length],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<div key={i} style={{ opacity: 0.6 + (i % 3) * 0.15 }}>
{line.slice(0, Math.floor(charCount))}
</div>
);
})}
</div>
{/* Main text */}
<div style={{ textAlign: "center", zIndex: 1 }}>
<div
style={{
fontSize: 64,
fontWeight: 700,
color: "#ffffff",
opacity: line1Progress,
transform: `translateY(${line1Y}px)`,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
}}
>
{line1}
</div>
<div
style={{
fontSize: 64,
fontWeight: 700,
color: "#ff6b5b",
opacity: Math.max(0, line2Progress),
transform: `translateY(${line2Y}px)`,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
marginTop: 8,
}}
>
{line2}
</div>
</div>
</AbsoluteFill>
);
};
+118
View File
@@ -0,0 +1,118 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Img,
staticFile,
} from "remotion";
export const IntroScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const logoScale = spring({
frame,
fps,
config: { damping: 12, stiffness: 100 },
});
const logoRotation = interpolate(logoScale, [0, 1], [-15, 0]);
const titleProgress = spring({
frame: frame - 10,
fps,
config: { damping: 200 },
});
const taglineProgress = spring({
frame: frame - 20,
fps,
config: { damping: 200 },
});
const titleY = interpolate(titleProgress, [0, 1], [30, 0]);
const taglineY = interpolate(taglineProgress, [0, 1], [20, 0]);
// Subtle glow pulse behind logo
const glowScale = interpolate(frame, [0, 60], [0.8, 1.2], {
extrapolateRight: "clamp",
});
const glowOpacity = interpolate(frame, [0, 30, 60], [0, 0.3, 0.15], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill
style={{
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
}}
>
{/* Glow effect */}
<div
style={{
position: "absolute",
width: 300,
height: 300,
borderRadius: "50%",
background:
"radial-gradient(circle, #ae5630 0%, transparent 70%)",
opacity: glowOpacity,
transform: `scale(${glowScale})`,
}}
/>
{/* Logo */}
<div
style={{
transform: `scale(${logoScale}) rotate(${logoRotation}deg)`,
marginBottom: 24,
}}
>
<Img
src={staticFile("icon.png")}
style={{
width: 140,
height: 140,
borderRadius: 20,
}}
/>
</div>
{/* Product name */}
<div
style={{
fontSize: 72,
fontWeight: 800,
color: "#ffffff",
opacity: titleProgress,
transform: `translateY(${titleY}px)`,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
letterSpacing: -2,
}}
>
Open Swarm
</div>
{/* Tagline */}
<div
style={{
fontSize: 28,
color: "#ae5630",
opacity: Math.max(0, taglineProgress),
transform: `translateY(${taglineY}px)`,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontWeight: 500,
marginTop: 12,
}}
>
Your agents, orchestrated.
</div>
</AbsoluteFill>
);
};
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2018",
"module": "commonjs",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"lib": ["es2015"],
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true
},
"exclude": ["remotion.config.ts"]
}