mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-05 17:27:42 +02:00
[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:
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.0.19",
|
||||
"version": "1.0.20",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 1–100 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.97–1.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,
|
||||
|
||||
@@ -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">𝕏</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 }}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
+173
@@ -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>
|
||||
);
|
||||
};
|
||||
+100
@@ -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>
|
||||
);
|
||||
};
|
||||
+103
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -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).
|
||||
@@ -0,0 +1,3 @@
|
||||
import { config } from "@remotion/eslint-config-flat";
|
||||
|
||||
export default config;
|
||||
Generated
+4899
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user