Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f4b704af3 | |||
| 921b09458e | |||
| b33ca2fa96 | |||
| 537a2b8bb8 | |||
| 261e2ee862 | |||
| 878b1d3d33 | |||
| 7d0953d6ff | |||
| da02a4e283 | |||
| 8ffd44a6f9 | |||
| 92c19924a9 | |||
| 0afa3a87d4 | |||
| 3d08a2fa1b | |||
| 5e88eb2ba0 | |||
| 17e2a27c51 | |||
| 214e60c951 | |||
| f77be22c65 | |||
| 582dbbbbf7 | |||
| 0bac07ded3 | |||
| a912cd4568 | |||
| cc7136b1ac | |||
| 6dfab35501 | |||
| 85973e0082 | |||
| eceb89b824 |
@@ -14,6 +14,16 @@
|
||||
# LLM_MODEL is no longer read from .env — this line is kept for reference only.
|
||||
# LLM_MODEL=anthropic/claude-opus-4.6
|
||||
|
||||
# =============================================================================
|
||||
# LLM PROVIDER (Google AI Studio / Gemini)
|
||||
# =============================================================================
|
||||
# Native Gemini API via Google's OpenAI-compatible endpoint.
|
||||
# Get your key at: https://aistudio.google.com/app/apikey
|
||||
# GOOGLE_API_KEY=your_google_ai_studio_key_here
|
||||
# GEMINI_API_KEY=your_gemini_key_here # alias for GOOGLE_API_KEY
|
||||
# Optional base URL override (default: Google's OpenAI-compatible endpoint)
|
||||
# GEMINI_BASE_URL=https://generativelanguage.googleapis.com/v1beta/openai
|
||||
|
||||
# =============================================================================
|
||||
# LLM PROVIDER (z.ai / GLM)
|
||||
# =============================================================================
|
||||
|
||||
+121
-11
@@ -34,6 +34,12 @@ than the provider's default.
|
||||
Per-task direct endpoint overrides (e.g. AUXILIARY_VISION_BASE_URL,
|
||||
AUXILIARY_VISION_API_KEY) let callers route a specific auxiliary task to a
|
||||
custom OpenAI-compatible endpoint without touching the main model settings.
|
||||
|
||||
Payment / credit exhaustion fallback:
|
||||
When a resolved provider returns HTTP 402 or a credit-related error,
|
||||
call_llm() automatically retries with the next available provider in the
|
||||
auto-detection chain. This handles the common case where a user depletes
|
||||
their OpenRouter balance but has Codex OAuth or another provider available.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -55,6 +61,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
|
||||
_API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
|
||||
"gemini": "gemini-3-flash-preview",
|
||||
"zai": "glm-4.5-flash",
|
||||
"kimi-coding": "kimi-k2-turbo-preview",
|
||||
"minimax": "MiniMax-M2.7-highspeed",
|
||||
@@ -873,10 +880,90 @@ _AUTO_PROVIDER_LABELS = {
|
||||
"_resolve_api_key_provider": "api-key",
|
||||
}
|
||||
|
||||
|
||||
_AGGREGATOR_PROVIDERS = frozenset({"openrouter", "nous"})
|
||||
|
||||
|
||||
def _get_provider_chain() -> List[tuple]:
|
||||
"""Return the ordered provider detection chain.
|
||||
|
||||
Built at call time (not module level) so that test patches
|
||||
on the ``_try_*`` functions are picked up correctly.
|
||||
"""
|
||||
return [
|
||||
("openrouter", _try_openrouter),
|
||||
("nous", _try_nous),
|
||||
("local/custom", _try_custom_endpoint),
|
||||
("openai-codex", _try_codex),
|
||||
("api-key", _resolve_api_key_provider),
|
||||
]
|
||||
|
||||
|
||||
def _is_payment_error(exc: Exception) -> bool:
|
||||
"""Detect payment/credit/quota exhaustion errors.
|
||||
|
||||
Returns True for HTTP 402 (Payment Required) and for 429/other errors
|
||||
whose message indicates billing exhaustion rather than rate limiting.
|
||||
"""
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status == 402:
|
||||
return True
|
||||
err_lower = str(exc).lower()
|
||||
# OpenRouter and other providers include "credits" or "afford" in 402 bodies,
|
||||
# but sometimes wrap them in 429 or other codes.
|
||||
if status in (402, 429, None):
|
||||
if any(kw in err_lower for kw in ("credits", "insufficient funds",
|
||||
"can only afford", "billing",
|
||||
"payment required")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _try_payment_fallback(
|
||||
failed_provider: str,
|
||||
task: str = None,
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Try alternative providers after a payment/credit error.
|
||||
|
||||
Iterates the standard auto-detection chain, skipping the provider that
|
||||
returned a payment error.
|
||||
|
||||
Returns:
|
||||
(client, model, provider_label) or (None, None, "") if no fallback.
|
||||
"""
|
||||
# Normalise the failed provider label for matching.
|
||||
skip = failed_provider.lower().strip()
|
||||
# Also skip Step-1 main-provider path if it maps to the same backend.
|
||||
# (e.g. main_provider="openrouter" → skip "openrouter" in chain)
|
||||
main_provider = _read_main_provider()
|
||||
skip_labels = {skip}
|
||||
if main_provider and main_provider.lower() in skip:
|
||||
skip_labels.add(main_provider.lower())
|
||||
# Map common resolved_provider values back to chain labels.
|
||||
_alias_to_label = {"openrouter": "openrouter", "nous": "nous",
|
||||
"openai-codex": "openai-codex", "codex": "openai-codex",
|
||||
"custom": "local/custom", "local/custom": "local/custom"}
|
||||
skip_chain_labels = {_alias_to_label.get(s, s) for s in skip_labels}
|
||||
|
||||
tried = []
|
||||
for label, try_fn in _get_provider_chain():
|
||||
if label in skip_chain_labels:
|
||||
continue
|
||||
client, model = try_fn()
|
||||
if client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s: payment error on %s — falling back to %s (%s)",
|
||||
task or "call", failed_provider, label, model or "default",
|
||||
)
|
||||
return client, model, label
|
||||
tried.append(label)
|
||||
|
||||
logger.warning(
|
||||
"Auxiliary %s: payment error on %s and no fallback available (tried: %s)",
|
||||
task or "call", failed_provider, ", ".join(tried),
|
||||
)
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
"""Full auto-detection chain.
|
||||
|
||||
@@ -904,10 +991,7 @@ def _resolve_auto() -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
|
||||
# ── Step 2: aggregator / fallback chain ──────────────────────────────
|
||||
tried = []
|
||||
for try_fn in (_try_openrouter, _try_nous, _try_custom_endpoint,
|
||||
_try_codex, _resolve_api_key_provider):
|
||||
fn_name = getattr(try_fn, "__name__", "unknown")
|
||||
label = _AUTO_PROVIDER_LABELS.get(fn_name, fn_name)
|
||||
for label, try_fn in _get_provider_chain():
|
||||
client, model = try_fn()
|
||||
if client is not None:
|
||||
if tried:
|
||||
@@ -1785,12 +1869,15 @@ def call_llm(
|
||||
f"was found. Set the {_explicit.upper()}_API_KEY environment "
|
||||
f"variable, or switch to a different provider with `hermes model`."
|
||||
)
|
||||
# For auto/custom, fall back to OpenRouter
|
||||
# For auto/custom with no credentials, try the full auto chain
|
||||
# rather than hardcoding OpenRouter (which may be depleted).
|
||||
# Pass model=None so each provider uses its own default —
|
||||
# resolved_model may be an OpenRouter-format slug that doesn't
|
||||
# work on other providers.
|
||||
if not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, falling back to openrouter",
|
||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||
task or "call", resolved_provider)
|
||||
client, final_model = _get_cached_client(
|
||||
"openrouter", resolved_model or _OPENROUTER_MODEL)
|
||||
client, final_model = _get_cached_client("auto")
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
@@ -1811,7 +1898,7 @@ def call_llm(
|
||||
tools=tools, timeout=effective_timeout, extra_body=extra_body,
|
||||
base_url=resolved_base_url)
|
||||
|
||||
# Handle max_tokens vs max_completion_tokens retry
|
||||
# Handle max_tokens vs max_completion_tokens retry, then payment fallback.
|
||||
try:
|
||||
return client.chat.completions.create(**kwargs)
|
||||
except Exception as first_err:
|
||||
@@ -1819,7 +1906,30 @@ def call_llm(
|
||||
if "max_tokens" in err_str or "unsupported_parameter" in err_str:
|
||||
kwargs.pop("max_tokens", None)
|
||||
kwargs["max_completion_tokens"] = max_tokens
|
||||
return client.chat.completions.create(**kwargs)
|
||||
try:
|
||||
return client.chat.completions.create(**kwargs)
|
||||
except Exception as retry_err:
|
||||
# If the max_tokens retry also hits a payment error,
|
||||
# fall through to the payment fallback below.
|
||||
if not _is_payment_error(retry_err):
|
||||
raise
|
||||
first_err = retry_err
|
||||
|
||||
# ── Payment / credit exhaustion fallback ──────────────────────
|
||||
# When the resolved provider returns 402 or a credit-related error,
|
||||
# try alternative providers instead of giving up. This handles the
|
||||
# common case where a user runs out of OpenRouter credits but has
|
||||
# Codex OAuth or another provider available.
|
||||
if _is_payment_error(first_err):
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task)
|
||||
if fb_client is not None:
|
||||
fb_kwargs = _build_call_kwargs(
|
||||
fb_label, fb_model, messages,
|
||||
temperature=temperature, max_tokens=max_tokens,
|
||||
tools=tools, timeout=effective_timeout,
|
||||
extra_body=extra_body)
|
||||
return fb_client.chat.completions.create(**fb_kwargs)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ logger = logging.getLogger(__name__)
|
||||
# are preserved so the full model name reaches cache lookups and server queries.
|
||||
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||
"zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
|
||||
"gemini", "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", "deepseek",
|
||||
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba",
|
||||
"custom", "local",
|
||||
# Common aliases
|
||||
"google", "google-gemini", "google-ai-studio",
|
||||
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
|
||||
"github-models", "kimi", "moonshot", "claude", "deep-seek",
|
||||
"opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
|
||||
@@ -101,6 +102,11 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"gpt-4": 128000,
|
||||
# Google
|
||||
"gemini": 1048576,
|
||||
# Gemma (open models served via AI Studio)
|
||||
"gemma-4-31b": 256000,
|
||||
"gemma-4-26b": 256000,
|
||||
"gemma-3": 131072,
|
||||
"gemma": 8192, # fallback for older gemma models
|
||||
# DeepSeek
|
||||
"deepseek": 128000,
|
||||
# Meta
|
||||
@@ -175,7 +181,7 @@ _URL_TO_PROVIDER: Dict[str, str] = {
|
||||
"dashscope.aliyuncs.com": "alibaba",
|
||||
"dashscope-intl.aliyuncs.com": "alibaba",
|
||||
"openrouter.ai": "openrouter",
|
||||
"generativelanguage.googleapis.com": "google",
|
||||
"generativelanguage.googleapis.com": "gemini",
|
||||
"inference-api.nousresearch.com": "nous",
|
||||
"api.deepseek.com": "deepseek",
|
||||
"api.githubcopilot.com": "copilot",
|
||||
|
||||
@@ -160,6 +160,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
||||
"kilocode": "kilo",
|
||||
"fireworks": "fireworks-ai",
|
||||
"huggingface": "huggingface",
|
||||
"gemini": "google",
|
||||
"google": "google",
|
||||
"xai": "xai",
|
||||
"nvidia": "nvidia",
|
||||
@@ -422,6 +423,39 @@ def list_provider_models(provider: str) -> List[str]:
|
||||
return list(models.keys())
|
||||
|
||||
|
||||
# Patterns that indicate non-agentic or noise models (TTS, embedding,
|
||||
# dated preview snapshots, live/streaming-only, image-only).
|
||||
import re
|
||||
_NOISE_PATTERNS: re.Pattern = re.compile(
|
||||
r"-tts\b|embedding|live-|-(preview|exp)-\d{2,4}[-_]|"
|
||||
r"-image\b|-image-preview\b|-customtools\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def list_agentic_models(provider: str) -> List[str]:
|
||||
"""Return model IDs suitable for agentic use from models.dev.
|
||||
|
||||
Filters for tool_call=True and excludes noise (TTS, embedding,
|
||||
dated preview snapshots, live/streaming, image-only models).
|
||||
Returns an empty list on any failure.
|
||||
"""
|
||||
models = _get_provider_models(provider)
|
||||
if models is None:
|
||||
return []
|
||||
|
||||
result = []
|
||||
for mid, entry in models.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if not entry.get("tool_call", False):
|
||||
continue
|
||||
if _NOISE_PATTERNS.search(mid):
|
||||
continue
|
||||
result.append(mid)
|
||||
return result
|
||||
|
||||
|
||||
def search_models_dev(
|
||||
query: str, provider: str = None, limit: int = 5
|
||||
) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -187,7 +187,7 @@ TOOL_USE_ENFORCEMENT_GUIDANCE = (
|
||||
|
||||
# Model name substrings that trigger tool-use enforcement guidance.
|
||||
# Add new patterns here when a model family needs explicit steering.
|
||||
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma")
|
||||
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok")
|
||||
|
||||
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
|
||||
# where GPT models abandon work on partial results, skip prerequisite lookups,
|
||||
|
||||
@@ -16,6 +16,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_skill_commands: Dict[str, Dict[str, Any]] = {}
|
||||
_PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
# Patterns for sanitizing skill names into clean hyphen-separated slugs.
|
||||
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
|
||||
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
|
||||
|
||||
|
||||
def build_plan_path(
|
||||
@@ -196,7 +199,14 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||
description = line[:80]
|
||||
break
|
||||
seen_names.add(name)
|
||||
# Normalize to hyphen-separated slug, stripping
|
||||
# non-alnum chars (e.g. +, /) to avoid invalid
|
||||
# Telegram command names downstream.
|
||||
cmd_name = name.lower().replace(' ', '-').replace('_', '-')
|
||||
cmd_name = _SKILL_INVALID_CHARS.sub('', cmd_name)
|
||||
cmd_name = _SKILL_MULTI_HYPHEN.sub('-', cmd_name).strip('-')
|
||||
if not cmd_name:
|
||||
continue
|
||||
_skill_commands[f"/{cmd_name}"] = {
|
||||
"name": name,
|
||||
"description": description or f"Invoke the {name} skill",
|
||||
|
||||
@@ -18,7 +18,8 @@ model:
|
||||
# "anthropic" - Direct Anthropic API (requires: ANTHROPIC_API_KEY)
|
||||
# "openai-codex" - OpenAI Codex (requires: hermes login --provider openai-codex)
|
||||
# "copilot" - GitHub Copilot / GitHub Models (requires: GITHUB_TOKEN)
|
||||
# "zai" - z.ai / ZhipuAI GLM (requires: GLM_API_KEY)
|
||||
# "gemini" - Use Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY)
|
||||
# "zai" - Use z.ai / ZhipuAI GLM models (requires: GLM_API_KEY)
|
||||
# "kimi-coding" - Kimi / Moonshot AI (requires: KIMI_API_KEY)
|
||||
# "minimax" - MiniMax global (requires: MINIMAX_API_KEY)
|
||||
# "minimax-cn" - MiniMax China (requires: MINIMAX_CN_API_KEY)
|
||||
@@ -315,7 +316,8 @@ compression:
|
||||
# "auto" - Best available: OpenRouter → Nous Portal → main endpoint (default)
|
||||
# "openrouter" - Force OpenRouter (requires OPENROUTER_API_KEY)
|
||||
# "nous" - Force Nous Portal (requires: hermes login)
|
||||
# "codex" - Force Codex OAuth (requires: hermes model → Codex).
|
||||
# "gemini" - Force Google AI Studio direct (requires: GOOGLE_API_KEY or GEMINI_API_KEY)
|
||||
# "codex" - Force Codex OAuth (requires: hermes model → Codex).
|
||||
# Uses gpt-5.3-codex which supports vision.
|
||||
# "main" - Use your custom endpoint (OPENAI_BASE_URL + OPENAI_API_KEY).
|
||||
# Works with OpenAI API, local models, or any OpenAI-compatible
|
||||
|
||||
+40
-20
@@ -237,6 +237,10 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None:
|
||||
else:
|
||||
delivery_content = content
|
||||
|
||||
# Extract MEDIA: tags so attachments are forwarded as files, not raw text
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content)
|
||||
|
||||
# Prefer the live adapter when the gateway is running — this supports E2EE
|
||||
# rooms (e.g. Matrix) where the standalone HTTP path cannot encrypt.
|
||||
runtime_adapter = (adapters or {}).get(platform)
|
||||
@@ -264,7 +268,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None:
|
||||
)
|
||||
|
||||
# Standalone path: run the async send in a fresh event loop (safe from any thread)
|
||||
coro = _send_to_platform(platform, pconfig, chat_id, delivery_content, thread_id=thread_id)
|
||||
coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)
|
||||
try:
|
||||
result = asyncio.run(coro)
|
||||
except RuntimeError:
|
||||
@@ -275,7 +279,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> None:
|
||||
coro.close()
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, delivery_content, thread_id=thread_id))
|
||||
future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files))
|
||||
result = future.result(timeout=30)
|
||||
except Exception as e:
|
||||
logger.error("Job '%s': delivery to %s:%s failed: %s", job["id"], platform_name, chat_id, e)
|
||||
@@ -293,8 +297,15 @@ _SCRIPT_TIMEOUT = 120 # seconds
|
||||
def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
"""Execute a cron job's data-collection script and capture its output.
|
||||
|
||||
Scripts must reside within HERMES_HOME/scripts/. Both relative and
|
||||
absolute paths are resolved and validated against this directory to
|
||||
prevent arbitrary script execution via path traversal or absolute
|
||||
path injection.
|
||||
|
||||
Args:
|
||||
script_path: Path to a Python script (resolved via HERMES_HOME/scripts/ or absolute).
|
||||
script_path: Path to a Python script. Relative paths are resolved
|
||||
against HERMES_HOME/scripts/. Absolute and ~-prefixed paths
|
||||
are also validated to ensure they stay within the scripts dir.
|
||||
|
||||
Returns:
|
||||
(success, output) — on failure *output* contains the error message so the
|
||||
@@ -302,16 +313,25 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
"""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
path = Path(script_path).expanduser()
|
||||
if not path.is_absolute():
|
||||
# Resolve relative paths against HERMES_HOME/scripts/
|
||||
scripts_dir = get_hermes_home() / "scripts"
|
||||
path = (scripts_dir / path).resolve()
|
||||
# Guard against path traversal (e.g. "../../etc/passwd")
|
||||
try:
|
||||
path.relative_to(scripts_dir.resolve())
|
||||
except ValueError:
|
||||
return False, f"Script path escapes the scripts directory: {script_path!r}"
|
||||
scripts_dir = get_hermes_home() / "scripts"
|
||||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
scripts_dir_resolved = scripts_dir.resolve()
|
||||
|
||||
raw = Path(script_path).expanduser()
|
||||
if raw.is_absolute():
|
||||
path = raw.resolve()
|
||||
else:
|
||||
path = (scripts_dir / raw).resolve()
|
||||
|
||||
# Guard against path traversal, absolute path injection, and symlink
|
||||
# escape — scripts MUST reside within HERMES_HOME/scripts/.
|
||||
try:
|
||||
path.relative_to(scripts_dir_resolved)
|
||||
except ValueError:
|
||||
return False, (
|
||||
f"Blocked: script path resolves outside the scripts directory "
|
||||
f"({scripts_dir_resolved}): {script_path!r}"
|
||||
)
|
||||
|
||||
if not path.exists():
|
||||
return False, f"Script not found: {path}"
|
||||
@@ -469,14 +489,14 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
logger.info("Running job '%s' (ID: %s)", job_name, job_id)
|
||||
logger.info("Prompt: %s", prompt[:100])
|
||||
|
||||
# Inject origin context so the agent's send_message tool knows the chat
|
||||
if origin:
|
||||
os.environ["HERMES_SESSION_PLATFORM"] = origin["platform"]
|
||||
os.environ["HERMES_SESSION_CHAT_ID"] = str(origin["chat_id"])
|
||||
if origin.get("chat_name"):
|
||||
os.environ["HERMES_SESSION_CHAT_NAME"] = origin["chat_name"]
|
||||
|
||||
try:
|
||||
# Inject origin context so the agent's send_message tool knows the chat.
|
||||
# Must be INSIDE the try block so the finally cleanup always runs.
|
||||
if origin:
|
||||
os.environ["HERMES_SESSION_PLATFORM"] = origin["platform"]
|
||||
os.environ["HERMES_SESSION_CHAT_ID"] = str(origin["chat_id"])
|
||||
if origin.get("chat_name"):
|
||||
os.environ["HERMES_SESSION_CHAT_NAME"] = origin["chat_name"]
|
||||
# Re-read .env and config.yaml fresh every run so provider/key
|
||||
# changes take effect without a gateway restart.
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -1695,6 +1695,47 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
async def slash_btw(interaction: discord.Interaction, question: str):
|
||||
await self._run_simple_slash(interaction, f"/btw {question}")
|
||||
|
||||
# Register installed skills as native slash commands (parity with
|
||||
# Telegram, which uses telegram_menu_commands() in commands.py).
|
||||
# Discord allows up to 100 application commands globally.
|
||||
_DISCORD_CMD_LIMIT = 100
|
||||
try:
|
||||
from hermes_cli.commands import discord_skill_commands
|
||||
|
||||
existing_names = {cmd.name for cmd in tree.get_commands()}
|
||||
remaining_slots = max(0, _DISCORD_CMD_LIMIT - len(existing_names))
|
||||
|
||||
skill_entries, skipped = discord_skill_commands(
|
||||
max_slots=remaining_slots,
|
||||
reserved_names=existing_names,
|
||||
)
|
||||
|
||||
for discord_name, description, cmd_key in skill_entries:
|
||||
# Closure factory to capture cmd_key per iteration
|
||||
def _make_skill_handler(_key: str):
|
||||
async def _skill_slash(interaction: discord.Interaction, args: str = ""):
|
||||
await self._run_simple_slash(interaction, f"{_key} {args}".strip())
|
||||
return _skill_slash
|
||||
|
||||
handler = _make_skill_handler(cmd_key)
|
||||
handler.__name__ = f"skill_{discord_name.replace('-', '_')}"
|
||||
|
||||
cmd = discord.app_commands.Command(
|
||||
name=discord_name,
|
||||
description=description,
|
||||
callback=handler,
|
||||
)
|
||||
discord.app_commands.describe(args="Optional arguments for the skill")(cmd)
|
||||
tree.add_command(cmd)
|
||||
|
||||
if skipped:
|
||||
logger.warning(
|
||||
"[%s] Discord slash command limit reached (%d): %d skill(s) not registered",
|
||||
self.name, _DISCORD_CMD_LIMIT, skipped,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[%s] Failed to register skill slash commands: %s", self.name, exc)
|
||||
|
||||
def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent:
|
||||
"""Build a MessageEvent from a Discord slash command interaction."""
|
||||
is_dm = isinstance(interaction.channel, discord.DMChannel)
|
||||
|
||||
@@ -717,19 +717,27 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=True)
|
||||
return SendResult(success=False, error="RPC send with attachment failed")
|
||||
|
||||
async def send_document(
|
||||
async def _send_attachment(
|
||||
self,
|
||||
chat_id: str,
|
||||
file_path: str,
|
||||
media_label: str,
|
||||
caption: Optional[str] = None,
|
||||
filename: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a document/file attachment."""
|
||||
"""Send any file as a Signal attachment via RPC.
|
||||
|
||||
Shared implementation for send_document, send_image_file, send_voice,
|
||||
and send_video — avoids duplicating the validation/routing/RPC logic.
|
||||
"""
|
||||
await self._stop_typing_indicator(chat_id)
|
||||
|
||||
if not Path(file_path).exists():
|
||||
return SendResult(success=False, error="File not found")
|
||||
try:
|
||||
file_size = Path(file_path).stat().st_size
|
||||
except FileNotFoundError:
|
||||
return SendResult(success=False, error=f"{media_label} file not found: {file_path}")
|
||||
|
||||
if file_size > SIGNAL_MAX_ATTACHMENT_SIZE:
|
||||
return SendResult(success=False, error=f"{media_label} too large ({file_size} bytes)")
|
||||
|
||||
params: Dict[str, Any] = {
|
||||
"account": self.account,
|
||||
@@ -746,7 +754,59 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if result is not None:
|
||||
self._track_sent_timestamp(result)
|
||||
return SendResult(success=True)
|
||||
return SendResult(success=False, error="RPC send document failed")
|
||||
return SendResult(success=False, error=f"RPC send {media_label.lower()} failed")
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: str,
|
||||
file_path: str,
|
||||
caption: Optional[str] = None,
|
||||
filename: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a document/file attachment."""
|
||||
return await self._send_attachment(chat_id, file_path, "File", caption)
|
||||
|
||||
async def send_image_file(
|
||||
self,
|
||||
chat_id: str,
|
||||
image_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a local image file as a native Signal attachment.
|
||||
|
||||
Called by the gateway media delivery flow when MEDIA: tags containing
|
||||
image paths are extracted from agent responses.
|
||||
"""
|
||||
return await self._send_attachment(chat_id, image_path, "Image", caption)
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
chat_id: str,
|
||||
audio_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send an audio file as a Signal attachment.
|
||||
|
||||
Signal does not distinguish voice messages from file attachments at
|
||||
the API level, so this routes through the same RPC send path.
|
||||
"""
|
||||
return await self._send_attachment(chat_id, audio_path, "Audio", caption)
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: str,
|
||||
video_path: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> SendResult:
|
||||
"""Send a video file as a Signal attachment."""
|
||||
return await self._send_attachment(chat_id, video_path, "Video", caption)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Typing Indicators
|
||||
|
||||
@@ -3252,6 +3252,18 @@ class GatewayRunner:
|
||||
logger.debug("Gateway memory flush on reset failed: %s", e)
|
||||
self._evict_cached_agent(session_key)
|
||||
|
||||
try:
|
||||
from tools.env_passthrough import clear_env_passthrough
|
||||
clear_env_passthrough()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from tools.credential_files import clear_credential_files
|
||||
clear_credential_files()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reset the session
|
||||
new_entry = self.session_store.reset_session(session_key)
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s
|
||||
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com"
|
||||
DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot"
|
||||
DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
@@ -125,6 +126,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
inference_base_url=DEFAULT_COPILOT_ACP_BASE_URL,
|
||||
base_url_env_var="COPILOT_ACP_BASE_URL",
|
||||
),
|
||||
"gemini": ProviderConfig(
|
||||
id="gemini",
|
||||
name="Google AI Studio",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
api_key_env_vars=("GOOGLE_API_KEY", "GEMINI_API_KEY"),
|
||||
base_url_env_var="GEMINI_BASE_URL",
|
||||
),
|
||||
"zai": ProviderConfig(
|
||||
id="zai",
|
||||
name="Z.AI / GLM",
|
||||
@@ -758,6 +767,7 @@ def resolve_provider(
|
||||
# Normalize provider aliases
|
||||
_PROVIDER_ALIASES = {
|
||||
"glm": "zai", "z-ai": "zai", "z.ai": "zai", "zhipu": "zai",
|
||||
"google": "gemini", "google-gemini": "gemini", "google-ai-studio": "gemini",
|
||||
"kimi": "kimi-coding", "moonshot": "kimi-coding",
|
||||
"minimax-china": "minimax-cn", "minimax_cn": "minimax-cn",
|
||||
"claude": "anthropic", "claude-code": "anthropic",
|
||||
|
||||
+196
-76
@@ -366,21 +366,46 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not _is_gateway_available(cmd, overrides):
|
||||
continue
|
||||
tg_name = cmd.name.replace("-", "_")
|
||||
result.append((tg_name, cmd.description))
|
||||
tg_name = _sanitize_telegram_name(cmd.name)
|
||||
if tg_name:
|
||||
result.append((tg_name, cmd.description))
|
||||
return result
|
||||
|
||||
|
||||
_TG_NAME_LIMIT = 32
|
||||
_CMD_NAME_LIMIT = 32
|
||||
"""Max command name length shared by Telegram and Discord."""
|
||||
|
||||
# Backward-compat alias — tests and external code may reference the old name.
|
||||
_TG_NAME_LIMIT = _CMD_NAME_LIMIT
|
||||
|
||||
# Telegram Bot API allows only lowercase a-z, 0-9, and underscores in
|
||||
# command names. This regex strips everything else after initial conversion.
|
||||
_TG_INVALID_CHARS = re.compile(r"[^a-z0-9_]")
|
||||
_TG_MULTI_UNDERSCORE = re.compile(r"_{2,}")
|
||||
|
||||
|
||||
def _clamp_telegram_names(
|
||||
def _sanitize_telegram_name(raw: str) -> str:
|
||||
"""Convert a command/skill/plugin name to a valid Telegram command name.
|
||||
|
||||
Telegram requires: 1-32 chars, lowercase a-z, digits 0-9, underscores only.
|
||||
Steps: lowercase → replace hyphens with underscores → strip all other
|
||||
invalid characters → collapse consecutive underscores → strip leading/
|
||||
trailing underscores.
|
||||
"""
|
||||
name = raw.lower().replace("-", "_")
|
||||
name = _TG_INVALID_CHARS.sub("", name)
|
||||
name = _TG_MULTI_UNDERSCORE.sub("_", name)
|
||||
return name.strip("_")
|
||||
|
||||
|
||||
def _clamp_command_names(
|
||||
entries: list[tuple[str, str]],
|
||||
reserved: set[str],
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Enforce Telegram's 32-char command name limit with collision avoidance.
|
||||
"""Enforce 32-char command name limit with collision avoidance.
|
||||
|
||||
Names exceeding 32 chars are truncated. If truncation creates a duplicate
|
||||
Both Telegram and Discord cap slash command names at 32 characters.
|
||||
Names exceeding the limit are truncated. If truncation creates a duplicate
|
||||
(against *reserved* names or earlier entries in the same batch), the name is
|
||||
shortened to 31 chars and a digit ``0``-``9`` is appended to differentiate.
|
||||
If all 10 digit slots are taken the entry is silently dropped.
|
||||
@@ -388,10 +413,10 @@ def _clamp_telegram_names(
|
||||
used: set[str] = set(reserved)
|
||||
result: list[tuple[str, str]] = []
|
||||
for name, desc in entries:
|
||||
if len(name) > _TG_NAME_LIMIT:
|
||||
candidate = name[:_TG_NAME_LIMIT]
|
||||
if len(name) > _CMD_NAME_LIMIT:
|
||||
candidate = name[:_CMD_NAME_LIMIT]
|
||||
if candidate in used:
|
||||
prefix = name[:_TG_NAME_LIMIT - 1]
|
||||
prefix = name[:_CMD_NAME_LIMIT - 1]
|
||||
for digit in range(10):
|
||||
candidate = f"{prefix}{digit}"
|
||||
if candidate not in used:
|
||||
@@ -407,6 +432,129 @@ def _clamp_telegram_names(
|
||||
return result
|
||||
|
||||
|
||||
# Backward-compat alias.
|
||||
_clamp_telegram_names = _clamp_command_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared skill/plugin collection for gateway platforms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _collect_gateway_skill_entries(
|
||||
platform: str,
|
||||
max_slots: int,
|
||||
reserved_names: set[str],
|
||||
desc_limit: int = 100,
|
||||
sanitize_name: "Callable[[str], str] | None" = None,
|
||||
) -> tuple[list[tuple[str, str, str]], int]:
|
||||
"""Collect plugin + skill entries for a gateway platform.
|
||||
|
||||
Priority order:
|
||||
1. Plugin slash commands (take precedence over skills)
|
||||
2. Built-in skill commands (fill remaining slots, alphabetical)
|
||||
|
||||
Only skills are trimmed when the cap is reached.
|
||||
Hub-installed skills are excluded. Per-platform disabled skills are
|
||||
excluded.
|
||||
|
||||
Args:
|
||||
platform: Platform identifier for per-platform skill filtering
|
||||
(``"telegram"``, ``"discord"``, etc.).
|
||||
max_slots: Maximum number of entries to return (remaining slots after
|
||||
built-in/core commands).
|
||||
reserved_names: Names already taken by built-in commands. Mutated
|
||||
in-place as new names are added.
|
||||
desc_limit: Max description length (40 for Telegram, 100 for Discord).
|
||||
sanitize_name: Optional name transform applied before clamping, e.g.
|
||||
:func:`_sanitize_telegram_name` for Telegram. May return an
|
||||
empty string to signal "skip this entry".
|
||||
|
||||
Returns:
|
||||
``(entries, hidden_count)`` where *entries* is a list of
|
||||
``(name, description, cmd_key)`` triples and *hidden_count* is the
|
||||
number of skill entries dropped due to the cap. ``cmd_key`` is the
|
||||
original ``/skill-name`` key from :func:`get_skill_commands`.
|
||||
"""
|
||||
all_entries: list[tuple[str, str, str]] = []
|
||||
|
||||
# --- Tier 1: Plugin slash commands (never trimmed) ---------------------
|
||||
plugin_pairs: list[tuple[str, str]] = []
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
pm = get_plugin_manager()
|
||||
plugin_cmds = getattr(pm, "_plugin_commands", {})
|
||||
for cmd_name in sorted(plugin_cmds):
|
||||
name = sanitize_name(cmd_name) if sanitize_name else cmd_name
|
||||
if not name:
|
||||
continue
|
||||
desc = "Plugin command"
|
||||
if len(desc) > desc_limit:
|
||||
desc = desc[:desc_limit - 3] + "..."
|
||||
plugin_pairs.append((name, desc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
plugin_pairs = _clamp_command_names(plugin_pairs, reserved_names)
|
||||
reserved_names.update(n for n, _ in plugin_pairs)
|
||||
# Plugins have no cmd_key — use empty string as placeholder
|
||||
for n, d in plugin_pairs:
|
||||
all_entries.append((n, d, ""))
|
||||
|
||||
# --- Tier 2: Built-in skill commands (trimmed at cap) -----------------
|
||||
_platform_disabled: set[str] = set()
|
||||
try:
|
||||
from agent.skill_utils import get_disabled_skill_names
|
||||
_platform_disabled = get_disabled_skill_names(platform=platform)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
skill_triples: list[tuple[str, str, str]] = []
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
_skills_dir = str(SKILLS_DIR.resolve())
|
||||
_hub_dir = str((SKILLS_DIR / ".hub").resolve())
|
||||
skill_cmds = get_skill_commands()
|
||||
for cmd_key in sorted(skill_cmds):
|
||||
info = skill_cmds[cmd_key]
|
||||
skill_path = info.get("skill_md_path", "")
|
||||
if not skill_path.startswith(_skills_dir):
|
||||
continue
|
||||
if skill_path.startswith(_hub_dir):
|
||||
continue
|
||||
skill_name = info.get("name", "")
|
||||
if skill_name in _platform_disabled:
|
||||
continue
|
||||
raw_name = cmd_key.lstrip("/")
|
||||
name = sanitize_name(raw_name) if sanitize_name else raw_name
|
||||
if not name:
|
||||
continue
|
||||
desc = info.get("description", "")
|
||||
if len(desc) > desc_limit:
|
||||
desc = desc[:desc_limit - 3] + "..."
|
||||
skill_triples.append((name, desc, cmd_key))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clamp names; _clamp_command_names works on (name, desc) pairs so we
|
||||
# need to zip/unzip.
|
||||
skill_pairs = [(n, d) for n, d, _ in skill_triples]
|
||||
key_by_pair = {(n, d): k for n, d, k in skill_triples}
|
||||
skill_pairs = _clamp_command_names(skill_pairs, reserved_names)
|
||||
|
||||
# Skills fill remaining slots — only tier that gets trimmed
|
||||
remaining = max(0, max_slots - len(all_entries))
|
||||
hidden_count = max(0, len(skill_pairs) - remaining)
|
||||
for n, d in skill_pairs[:remaining]:
|
||||
all_entries.append((n, d, key_by_pair.get((n, d), "")))
|
||||
|
||||
return all_entries[:max_slots], hidden_count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform-specific wrappers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str]], int]:
|
||||
"""Return Telegram menu commands capped to the Bot API limit.
|
||||
|
||||
@@ -425,80 +573,52 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str
|
||||
skill commands omitted due to the cap.
|
||||
"""
|
||||
core_commands = list(telegram_bot_commands())
|
||||
# Reserve core names so plugin/skill truncation can't collide with them
|
||||
reserved_names = {n for n, _ in core_commands}
|
||||
all_commands = list(core_commands)
|
||||
|
||||
# Plugin slash commands get priority over skills
|
||||
plugin_entries: list[tuple[str, str]] = []
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_manager
|
||||
pm = get_plugin_manager()
|
||||
plugin_cmds = getattr(pm, "_plugin_commands", {})
|
||||
for cmd_name in sorted(plugin_cmds):
|
||||
tg_name = cmd_name.replace("-", "_")
|
||||
desc = "Plugin command"
|
||||
if len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
plugin_entries.append((tg_name, desc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clamp plugin names to 32 chars with collision avoidance
|
||||
plugin_entries = _clamp_telegram_names(plugin_entries, reserved_names)
|
||||
reserved_names.update(n for n, _ in plugin_entries)
|
||||
all_commands.extend(plugin_entries)
|
||||
|
||||
# Load per-platform disabled skills so they don't consume menu slots.
|
||||
# get_skill_commands() already filters the *global* disabled list, but
|
||||
# per-platform overrides (skills.platform_disabled.telegram) were never
|
||||
# applied here — that's what this block fixes.
|
||||
_platform_disabled: set[str] = set()
|
||||
try:
|
||||
from agent.skill_utils import get_disabled_skill_names
|
||||
_platform_disabled = get_disabled_skill_names(platform="telegram")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remaining slots go to built-in skill commands (not hub-installed).
|
||||
skill_entries: list[tuple[str, str]] = []
|
||||
try:
|
||||
from agent.skill_commands import get_skill_commands
|
||||
from tools.skills_tool import SKILLS_DIR
|
||||
_skills_dir = str(SKILLS_DIR.resolve())
|
||||
_hub_dir = str((SKILLS_DIR / ".hub").resolve())
|
||||
skill_cmds = get_skill_commands()
|
||||
for cmd_key in sorted(skill_cmds):
|
||||
info = skill_cmds[cmd_key]
|
||||
skill_path = info.get("skill_md_path", "")
|
||||
if not skill_path.startswith(_skills_dir):
|
||||
continue
|
||||
if skill_path.startswith(_hub_dir):
|
||||
continue
|
||||
# Skip skills disabled for telegram
|
||||
skill_name = info.get("name", "")
|
||||
if skill_name in _platform_disabled:
|
||||
continue
|
||||
name = cmd_key.lstrip("/").replace("-", "_")
|
||||
desc = info.get("description", "")
|
||||
# Keep descriptions short — setMyCommands has an undocumented
|
||||
# total payload limit. 40 chars fits 100 commands safely.
|
||||
if len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
skill_entries.append((name, desc))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clamp skill names to 32 chars with collision avoidance
|
||||
skill_entries = _clamp_telegram_names(skill_entries, reserved_names)
|
||||
|
||||
# Skills fill remaining slots — they're the only tier that gets trimmed
|
||||
remaining_slots = max(0, max_commands - len(all_commands))
|
||||
hidden_count = max(0, len(skill_entries) - remaining_slots)
|
||||
all_commands.extend(skill_entries[:remaining_slots])
|
||||
entries, hidden_count = _collect_gateway_skill_entries(
|
||||
platform="telegram",
|
||||
max_slots=remaining_slots,
|
||||
reserved_names=reserved_names,
|
||||
desc_limit=40,
|
||||
sanitize_name=_sanitize_telegram_name,
|
||||
)
|
||||
# Drop the cmd_key — Telegram only needs (name, desc) pairs.
|
||||
all_commands.extend((n, d) for n, d, _k in entries)
|
||||
return all_commands[:max_commands], hidden_count
|
||||
|
||||
|
||||
def discord_skill_commands(
|
||||
max_slots: int,
|
||||
reserved_names: set[str],
|
||||
) -> tuple[list[tuple[str, str, str]], int]:
|
||||
"""Return skill entries for Discord slash command registration.
|
||||
|
||||
Same priority and filtering logic as :func:`telegram_menu_commands`
|
||||
(plugins > skills, hub excluded, per-platform disabled excluded), but
|
||||
adapted for Discord's constraints:
|
||||
|
||||
- Hyphens are allowed in names (no ``-`` → ``_`` sanitization)
|
||||
- Descriptions capped at 100 chars (Discord's per-field max)
|
||||
|
||||
Args:
|
||||
max_slots: Available command slots (100 minus existing built-in count).
|
||||
reserved_names: Names of already-registered built-in commands.
|
||||
|
||||
Returns:
|
||||
``(entries, hidden_count)`` where *entries* is a list of
|
||||
``(discord_name, description, cmd_key)`` triples. ``cmd_key`` is
|
||||
the original ``/skill-name`` key needed for the slash handler callback.
|
||||
"""
|
||||
return _collect_gateway_skill_entries(
|
||||
platform="discord",
|
||||
max_slots=max_slots,
|
||||
reserved_names=set(reserved_names), # copy — don't mutate caller's set
|
||||
desc_limit=100,
|
||||
)
|
||||
|
||||
|
||||
def slack_subcommand_map() -> dict[str, str]:
|
||||
"""Return subcommand -> /command mapping for Slack /hermes handler.
|
||||
|
||||
|
||||
@@ -590,6 +590,30 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"GOOGLE_API_KEY": {
|
||||
"description": "Google AI Studio API key (also recognized as GEMINI_API_KEY)",
|
||||
"prompt": "Google AI Studio API key",
|
||||
"url": "https://aistudio.google.com/app/apikey",
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"GEMINI_API_KEY": {
|
||||
"description": "Google AI Studio API key (alias for GOOGLE_API_KEY)",
|
||||
"prompt": "Gemini API key",
|
||||
"url": "https://aistudio.google.com/app/apikey",
|
||||
"password": True,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"GEMINI_BASE_URL": {
|
||||
"description": "Google AI Studio base URL override",
|
||||
"prompt": "Gemini base URL (leave empty for default)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "provider",
|
||||
"advanced": True,
|
||||
},
|
||||
"GLM_API_KEY": {
|
||||
"description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)",
|
||||
"prompt": "Z.AI / GLM API key",
|
||||
@@ -844,6 +868,13 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"FIRECRAWL_BROWSER_TTL": {
|
||||
"description": "Firecrawl browser session TTL in seconds (optional, default 300)",
|
||||
"prompt": "Browser session TTL (seconds)",
|
||||
"tools": ["browser_navigate", "browser_click"],
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"CAMOFOX_URL": {
|
||||
"description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)",
|
||||
"prompt": "Camofox server URL",
|
||||
|
||||
+30
-15
@@ -921,6 +921,7 @@ def select_provider_and_model(args=None):
|
||||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"copilot": "GitHub Copilot",
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Google AI Studio",
|
||||
"zai": "Z.AI / GLM",
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
"minimax": "MiniMax",
|
||||
@@ -952,6 +953,7 @@ def select_provider_and_model(args=None):
|
||||
|
||||
extended_providers = [
|
||||
("copilot-acp", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
|
||||
("gemini", "Google AI Studio (Gemini models — OpenAI-compatible endpoint)"),
|
||||
("zai", "Z.AI / GLM (Zhipu AI direct API)"),
|
||||
("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"),
|
||||
("minimax", "MiniMax (global direct API)"),
|
||||
@@ -1055,7 +1057,7 @@ def select_provider_and_model(args=None):
|
||||
_model_flow_anthropic(config, current_model)
|
||||
elif selected_provider == "kimi-coding":
|
||||
_model_flow_kimi(config, current_model)
|
||||
elif selected_provider in ("zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface"):
|
||||
elif selected_provider in ("gemini", "zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface"):
|
||||
_model_flow_api_key_provider(config, selected_provider, current_model)
|
||||
|
||||
|
||||
@@ -2209,24 +2211,37 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
|
||||
save_env_value(base_url_env, override)
|
||||
effective_base = override
|
||||
|
||||
# Model selection — try live /models endpoint first, fall back to defaults.
|
||||
# Providers with large live catalogs (100+ models) use a curated list instead
|
||||
# so users see familiar model names rather than an overwhelming dump.
|
||||
# Model selection — resolution order:
|
||||
# 1. models.dev registry (cached, filtered for agentic/tool-capable models)
|
||||
# 2. Curated static fallback list (offline insurance)
|
||||
# 3. Live /models endpoint probe (small providers without models.dev data)
|
||||
curated = _PROVIDER_MODELS.get(provider_id, [])
|
||||
if curated and len(curated) >= 8:
|
||||
|
||||
# Try models.dev first — returns tool-capable models, filtered for noise
|
||||
mdev_models: list = []
|
||||
try:
|
||||
from agent.models_dev import list_agentic_models
|
||||
mdev_models = list_agentic_models(provider_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if mdev_models:
|
||||
model_list = mdev_models
|
||||
print(f" Found {len(model_list)} model(s) from models.dev registry")
|
||||
elif curated and len(curated) >= 8:
|
||||
# Curated list is substantial — use it directly, skip live probe
|
||||
live_models = None
|
||||
model_list = curated
|
||||
print(f" Showing {len(model_list)} curated models — use \"Enter custom model name\" for others.")
|
||||
else:
|
||||
api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "")
|
||||
live_models = fetch_api_models(api_key_for_probe, effective_base)
|
||||
|
||||
if live_models and len(live_models) >= len(curated):
|
||||
model_list = live_models
|
||||
print(f" Found {len(model_list)} model(s) from {pconfig.name} API")
|
||||
else:
|
||||
model_list = curated
|
||||
if model_list:
|
||||
print(f" Showing {len(model_list)} curated models — use \"Enter custom model name\" for others.")
|
||||
if live_models and len(live_models) >= len(curated):
|
||||
model_list = live_models
|
||||
print(f" Found {len(model_list)} model(s) from {pconfig.name} API")
|
||||
else:
|
||||
model_list = curated
|
||||
if model_list:
|
||||
print(f" Showing {len(model_list)} curated models — use \"Enter custom model name\" for others.")
|
||||
# else: no defaults either, will fall through to raw input
|
||||
|
||||
if provider_id in {"opencode-zen", "opencode-go"}:
|
||||
@@ -4182,7 +4197,7 @@ For more help on a command:
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--provider",
|
||||
choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode"],
|
||||
choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode"],
|
||||
default=None,
|
||||
help="Inference provider (default: auto)"
|
||||
)
|
||||
|
||||
@@ -8,8 +8,9 @@ Different LLM providers expect model identifiers in different formats:
|
||||
hyphens: ``claude-sonnet-4-6``.
|
||||
- **Copilot** expects bare names *with* dots preserved:
|
||||
``claude-sonnet-4.6``.
|
||||
- **OpenCode** (Zen & Go) follows the same dot-to-hyphen convention as
|
||||
- **OpenCode Zen** follows the same dot-to-hyphen convention as
|
||||
Anthropic: ``claude-sonnet-4-6``.
|
||||
- **OpenCode Go** preserves dots in model names: ``minimax-m2.7``.
|
||||
- **DeepSeek** only accepts two model identifiers:
|
||||
``deepseek-chat`` and ``deepseek-reasoner``.
|
||||
- **Custom** and remaining providers pass the name through as-is.
|
||||
@@ -41,6 +42,7 @@ _VENDOR_PREFIXES: dict[str, str] = {
|
||||
"o3": "openai",
|
||||
"o4": "openai",
|
||||
"gemini": "google",
|
||||
"gemma": "google",
|
||||
"deepseek": "deepseek",
|
||||
"glm": "z-ai",
|
||||
"kimi": "moonshotai",
|
||||
@@ -66,7 +68,6 @@ _AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({
|
||||
_DOT_TO_HYPHEN_PROVIDERS: frozenset[str] = frozenset({
|
||||
"anthropic",
|
||||
"opencode-zen",
|
||||
"opencode-go",
|
||||
})
|
||||
|
||||
# Providers that want bare names with dots preserved.
|
||||
@@ -77,6 +78,7 @@ _STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
|
||||
|
||||
# Providers whose own naming is authoritative -- pass through unchanged.
|
||||
_PASSTHROUGH_PROVIDERS: frozenset[str] = frozenset({
|
||||
"gemini",
|
||||
"zai",
|
||||
"kimi-coding",
|
||||
"minimax",
|
||||
|
||||
+17
-1
@@ -111,6 +111,17 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"gemini-2.5-pro",
|
||||
"grok-code-fast-1",
|
||||
],
|
||||
"gemini": [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.1-flash-lite-preview",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
# Gemma open models (also served via AI Studio)
|
||||
"gemma-4-31b-it",
|
||||
"gemma-4-26b-it",
|
||||
],
|
||||
"zai": [
|
||||
"glm-5",
|
||||
"glm-5-turbo",
|
||||
@@ -260,6 +271,7 @@ _PROVIDER_LABELS = {
|
||||
"copilot-acp": "GitHub Copilot ACP",
|
||||
"nous": "Nous Portal",
|
||||
"copilot": "GitHub Copilot",
|
||||
"gemini": "Google AI Studio",
|
||||
"zai": "Z.AI / GLM",
|
||||
"kimi-coding": "Kimi / Moonshot",
|
||||
"minimax": "MiniMax",
|
||||
@@ -286,6 +298,9 @@ _PROVIDER_ALIASES = {
|
||||
"github-model": "copilot",
|
||||
"github-copilot-acp": "copilot-acp",
|
||||
"copilot-acp-agent": "copilot-acp",
|
||||
"google": "gemini",
|
||||
"google-gemini": "gemini",
|
||||
"google-ai-studio": "gemini",
|
||||
"kimi": "kimi-coding",
|
||||
"moonshot": "kimi-coding",
|
||||
"minimax-china": "minimax-cn",
|
||||
@@ -550,7 +565,8 @@ def list_available_providers() -> list[dict[str, str]]:
|
||||
# Canonical providers in display order
|
||||
_PROVIDER_ORDER = [
|
||||
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||
"huggingface", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
|
||||
"gemini", "huggingface",
|
||||
"zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "alibaba",
|
||||
"opencode-zen", "opencode-go",
|
||||
"ai-gateway", "deepseek", "custom",
|
||||
]
|
||||
|
||||
@@ -131,6 +131,7 @@ def _browser_label(current_provider: str) -> str:
|
||||
mapping = {
|
||||
"browserbase": "Browserbase",
|
||||
"browser-use": "Browser Use",
|
||||
"firecrawl": "Firecrawl",
|
||||
"camofox": "Camofox",
|
||||
"local": "Local browser",
|
||||
}
|
||||
@@ -156,6 +157,7 @@ def _resolve_browser_feature_state(
|
||||
direct_camofox: bool,
|
||||
direct_browserbase: bool,
|
||||
direct_browser_use: bool,
|
||||
direct_firecrawl: bool,
|
||||
managed_browser_available: bool,
|
||||
) -> tuple[str, bool, bool, bool]:
|
||||
"""Resolve browser availability using the same precedence as runtime."""
|
||||
@@ -179,6 +181,10 @@ def _resolve_browser_feature_state(
|
||||
available = bool(browser_local_available and direct_browser_use)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return current_provider, available, active, False
|
||||
if current_provider == "firecrawl":
|
||||
available = bool(browser_local_available and direct_firecrawl)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return current_provider, available, active, False
|
||||
if current_provider == "camofox":
|
||||
return current_provider, False, False, False
|
||||
|
||||
@@ -315,6 +321,7 @@ def get_nous_subscription_features(
|
||||
direct_camofox=direct_camofox,
|
||||
direct_browserbase=direct_browserbase,
|
||||
direct_browser_use=direct_browser_use,
|
||||
direct_firecrawl=direct_firecrawl,
|
||||
managed_browser_available=managed_browser_available,
|
||||
)
|
||||
|
||||
|
||||
@@ -495,7 +495,11 @@ def _resolve_explicit_runtime(
|
||||
explicit_base_url
|
||||
or str(state.get("inference_base_url") or auth_mod.DEFAULT_NOUS_INFERENCE_URL).strip().rstrip("/")
|
||||
)
|
||||
api_key = explicit_api_key or str(state.get("agent_key") or state.get("access_token") or "").strip()
|
||||
# Only use agent_key for inference — access_token is an OAuth token for the
|
||||
# portal API (minting keys, refreshing tokens), not for the inference API.
|
||||
# Falling back to access_token sends an OAuth bearer token to the inference
|
||||
# endpoint, which returns 404 because it is not a valid inference credential.
|
||||
api_key = explicit_api_key or str(state.get("agent_key") or "").strip()
|
||||
expires_at = state.get("agent_key_expires_at") or state.get("expires_at")
|
||||
if not api_key:
|
||||
creds = resolve_nous_runtime_credentials(
|
||||
|
||||
+6
-1
@@ -111,6 +111,11 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||
"gemini-2.5-pro",
|
||||
"grok-code-fast-1",
|
||||
],
|
||||
"gemini": [
|
||||
"gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.1-flash-lite-preview",
|
||||
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite",
|
||||
"gemma-4-31b-it", "gemma-4-26b-it",
|
||||
],
|
||||
"zai": ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
|
||||
"kimi-coding": ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"minimax": ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"],
|
||||
@@ -652,7 +657,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
else:
|
||||
tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, or TAVILY_API_KEY"))
|
||||
|
||||
# Browser tools (local Chromium, Camofox, Browserbase, or Browser Use)
|
||||
# Browser tools (local Chromium, Camofox, Browserbase, Browser Use, or Firecrawl)
|
||||
browser_provider = subscription_features.browser.current_provider
|
||||
if subscription_features.browser.managed_by_nous:
|
||||
tool_status.append(("Browser Automation (Nous Browserbase)", True, None))
|
||||
|
||||
@@ -315,6 +315,15 @@ TOOL_CATEGORIES = {
|
||||
"browser_provider": "browser-use",
|
||||
"post_setup": "browserbase",
|
||||
},
|
||||
{
|
||||
"name": "Firecrawl",
|
||||
"tag": "Cloud browser with remote execution",
|
||||
"env_vars": [
|
||||
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
|
||||
],
|
||||
"browser_provider": "firecrawl",
|
||||
"post_setup": "browserbase",
|
||||
},
|
||||
{
|
||||
"name": "Camofox",
|
||||
"tag": "Local anti-detection browser (Firefox/Camoufox)",
|
||||
|
||||
+11
-3
@@ -5224,11 +5224,13 @@ class AIAgent:
|
||||
return transformed
|
||||
|
||||
def _anthropic_preserve_dots(self) -> bool:
|
||||
"""True when using Alibaba/DashScope anthropic-compatible endpoint (model names keep dots, e.g. qwen3.5-plus)."""
|
||||
if (getattr(self, "provider", "") or "").lower() == "alibaba":
|
||||
"""True when using an anthropic-compatible endpoint that preserves dots in model names.
|
||||
Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).
|
||||
OpenCode Go keeps dots (e.g. minimax-m2.7)."""
|
||||
if (getattr(self, "provider", "") or "").lower() in {"alibaba", "opencode-go"}:
|
||||
return True
|
||||
base = (getattr(self, "base_url", "") or "").lower()
|
||||
return "dashscope" in base or "aliyuncs" in base
|
||||
return "dashscope" in base or "aliyuncs" in base or "opencode.ai/zen/go" in base
|
||||
|
||||
def _build_api_kwargs(self, api_messages: list) -> dict:
|
||||
"""Build the keyword arguments dict for the active API mode."""
|
||||
@@ -5436,6 +5438,12 @@ class AIAgent:
|
||||
if extra_body:
|
||||
api_kwargs["extra_body"] = extra_body
|
||||
|
||||
# xAI prompt caching: send x-grok-conv-id header to route requests
|
||||
# to the same server, maximizing automatic cache hits.
|
||||
# https://docs.x.ai/developers/advanced-api-usage/prompt-caching
|
||||
if "x.ai" in self._base_url_lower and hasattr(self, "session_id") and self.session_id:
|
||||
api_kwargs["extra_headers"] = {"x-grok-conv-id": self.session_id}
|
||||
|
||||
return api_kwargs
|
||||
|
||||
def _supports_reasoning_extra_body(self) -> bool:
|
||||
|
||||
@@ -234,3 +234,8 @@ Always iterate at `-ql`. Only render `-qh` for final output.
|
||||
| `references/scene-planning.md` | Narrative arcs, layout templates, scene transitions, planning template |
|
||||
| `references/rendering.md` | CLI reference, quality presets, ffmpeg, voiceover workflow, GIF export |
|
||||
| `references/troubleshooting.md` | LaTeX errors, animation errors, common mistakes, debugging |
|
||||
| `references/animation-design-thinking.md` | When to animate vs show static, decomposition, pacing, narration sync |
|
||||
| `references/updaters-and-trackers.md` | ValueTracker, add_updater, always_redraw, time-based updaters, patterns |
|
||||
| `references/paper-explainer.md` | Turning research papers into animations — workflow, templates, domain patterns |
|
||||
| `references/decorations.md` | SurroundingRectangle, Brace, arrows, DashedLine, Angle, annotation lifecycle |
|
||||
| `references/production-quality.md` | Pre-code, pre-render, post-render checklists, spatial layout, color, tempo |
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Animation Design Thinking
|
||||
|
||||
How to decide WHAT to animate and HOW to structure it — before writing any code.
|
||||
|
||||
## Should I animate this?
|
||||
|
||||
Not everything benefits from animation. Motion adds cognitive load. Bad animation is worse than a good static diagram.
|
||||
|
||||
**Animate when:**
|
||||
- A sequence unfolds over time (algorithm steps, derivation, pipeline stages)
|
||||
- Spatial relationships change (transformation, deformation, rotation)
|
||||
- Something is built from parts (construction, assembly, accumulation)
|
||||
- You're comparing states (before/after, method A vs method B)
|
||||
- Temporal evolution is the point (training curves, wave propagation, gradient descent)
|
||||
|
||||
**Show static when:**
|
||||
- The concept is a single labeled diagram (circuit, anatomy, architecture overview)
|
||||
- Motion would distract from spatial layout
|
||||
- The viewer needs to study it carefully (dense table, reference chart)
|
||||
- The concept is already intuitive from a well-labeled figure
|
||||
|
||||
**Rule of thumb:** If you'd explain it with "first X, then Y, then Z" — animate it. If you'd explain it by pointing at parts of one picture — show it static.
|
||||
|
||||
## Decomposing a concept into animation
|
||||
|
||||
### Step 1: Write the narration first
|
||||
|
||||
Before any code, write what the narrator would say. This determines:
|
||||
- **Order** — what concept comes first
|
||||
- **Duration** — how long each idea gets
|
||||
- **Visuals** — what the viewer must SEE when they HEAR each sentence
|
||||
|
||||
A scene where the narration says "the gradient points uphill" must show a gradient arrow at that moment. If the visual doesn't match the audio, the viewer's brain splits attention and both tracks are lost.
|
||||
|
||||
### Step 2: Identify visual beats
|
||||
|
||||
A "beat" is a moment where something changes on screen. Mark each beat in your narration:
|
||||
|
||||
```
|
||||
"Consider a function f of x." → [BEAT: axes + curve appear]
|
||||
"At this point..." → [BEAT: dot appears on curve]
|
||||
"...the slope is positive." → [BEAT: tangent line drawn]
|
||||
"So the gradient tells us to go left." → [BEAT: arrow points left, dot moves]
|
||||
```
|
||||
|
||||
Each beat is one `self.play()` call or a small group of simultaneous animations.
|
||||
|
||||
### Step 3: Choose the right tool per beat
|
||||
|
||||
| Visual need | Manim approach |
|
||||
|-------------|----------------|
|
||||
| Object appears for first time | `Create`, `Write`, `FadeIn`, `GrowFromCenter` |
|
||||
| Object transforms into another | `Transform`, `ReplacementTransform`, `FadeTransform` |
|
||||
| Attention drawn to existing object | `Indicate`, `Circumscribe`, `Flash`, `ShowPassingFlash` |
|
||||
| Continuous relationship maintained | `add_updater`, `always_redraw`, `ValueTracker` |
|
||||
| Object leaves the scene | `FadeOut`, `Uncreate`, `ShrinkToCenter` |
|
||||
| Static context that stays visible | `self.add()` (no animation) |
|
||||
|
||||
## Pacing: the universal mistake is too fast
|
||||
|
||||
### Timing rules
|
||||
|
||||
| Content type | Minimum on-screen time |
|
||||
|-------------|----------------------|
|
||||
| New equation appearing | 2.0s animation + 2.0s pause |
|
||||
| New concept label | 1.0s animation + 1.0s pause |
|
||||
| Key insight ("aha moment") | 2.5s animation + 3.0s pause |
|
||||
| Supporting annotation | 0.8s animation + 0.5s pause |
|
||||
| Scene transition (FadeOut all) | 0.5s animation + 0.3s pause |
|
||||
|
||||
### Breathing room
|
||||
|
||||
After every reveal, add `self.wait()`. The viewer needs time to:
|
||||
1. Read the new text
|
||||
2. Connect it to what's already on screen
|
||||
3. Form an expectation about what comes next
|
||||
|
||||
**No wait = the viewer is always behind you.** They're still reading the equation when you've already started transforming it.
|
||||
|
||||
### Tempo variation
|
||||
|
||||
Monotonous pacing feels like a lecture. Vary the tempo:
|
||||
- **Slow build** for core concepts (long run_time, long pauses)
|
||||
- **Quick succession** for supporting details (short run_time, minimal pauses)
|
||||
- **Dramatic pause** before the key reveal (extra `self.wait(2.0)` before the "aha")
|
||||
- **Rapid montage** for "and this applies to X, Y, Z..." sequences (`LaggedStart` with tight lag_ratio)
|
||||
|
||||
## Narration synchronization
|
||||
|
||||
### The "see then hear" principle
|
||||
|
||||
The visual should appear slightly BEFORE the narration describes it. When the viewer sees a circle appear and THEN hears "consider a circle," the visual primes their brain for the concept. The reverse — hearing first, seeing second — creates confusion because they're searching the screen for something that isn't there yet.
|
||||
|
||||
### Practical timing
|
||||
|
||||
```python
|
||||
# Scene duration should match narration duration.
|
||||
# If narration for this scene is 8 seconds:
|
||||
# Total animation run_times + total self.wait() times = ~8 seconds.
|
||||
|
||||
# Use manim-voiceover for automatic sync:
|
||||
with self.voiceover(text="The gradient points downhill") as tracker:
|
||||
self.play(GrowArrow(gradient_arrow), run_time=tracker.duration)
|
||||
```
|
||||
|
||||
## Equation decomposition strategy
|
||||
|
||||
### The "dim and reveal" pattern
|
||||
|
||||
When building a complex equation step by step:
|
||||
1. Show the full equation dimmed at `opacity=0.2` (sets expectation for where you're going)
|
||||
2. Highlight the first term at full opacity
|
||||
3. Explain it
|
||||
4. Highlight the next term, dim the first to `0.5` (it's now context)
|
||||
5. Repeat until the full equation is bright
|
||||
|
||||
This is better than building left-to-right because the viewer always sees the destination.
|
||||
|
||||
### Term ordering
|
||||
|
||||
Animate terms in the order the viewer needs to understand them, not in the order they appear in the equation. For `E = mc²`:
|
||||
- Show `E` (the thing we want to know)
|
||||
- Then `m` (the input)
|
||||
- Then `c²` (the constant that makes it work)
|
||||
- Then the `=` (connecting them)
|
||||
|
||||
## Architecture and pipeline diagrams
|
||||
|
||||
### Box granularity
|
||||
|
||||
The most common mistake: too many boxes. Each box is a concept the viewer must track. Five boxes with clear labels beats twelve boxes with abbreviations.
|
||||
|
||||
**Rule:** If two consecutive boxes could be labeled "X" and "process X output," merge them into one box.
|
||||
|
||||
### Animation strategy
|
||||
|
||||
Build pipelines left-to-right (or top-to-bottom) with arrows connecting them:
|
||||
1. First box appears alone → explain it
|
||||
2. Arrow grows from first to second → "the output feeds into..."
|
||||
3. Second box appears → explain it
|
||||
4. Repeat
|
||||
|
||||
Then show data flowing through: `ShowPassingFlash` along the arrows, or a colored dot traversing the path.
|
||||
|
||||
### The zoom-and-return pattern
|
||||
|
||||
For complex systems:
|
||||
1. Show the full overview (all boxes, small)
|
||||
2. Zoom into one box (`MovingCameraScene.camera.frame.animate`)
|
||||
3. Expand that box into its internal components
|
||||
4. Zoom back out to the overview
|
||||
5. Zoom into the next box
|
||||
|
||||
## Common design mistakes
|
||||
|
||||
1. **Animating everything at once.** The viewer can track 1-2 simultaneous animations. More than that and nothing registers.
|
||||
2. **No visual hierarchy.** Everything at the same opacity/size/color means nothing stands out. Use opacity layering.
|
||||
3. **Equations without context.** An equation appearing alone means nothing. Always show the geometric/visual interpretation first or simultaneously.
|
||||
4. **Skipping the "why."** Showing HOW a transformation works without WHY it matters. Add a sentence/label explaining the purpose.
|
||||
5. **Identical pacing throughout.** Every animation at run_time=1.5, every wait at 1.0. Vary it.
|
||||
6. **Forgetting the audience.** A video for high schoolers needs different pacing and complexity than one for PhD students. Decide the audience in the planning phase.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Decorations and Visual Polish
|
||||
|
||||
Decorations are mobjects that annotate, highlight, or frame other mobjects. They turn a technically correct animation into a visually polished one.
|
||||
|
||||
## SurroundingRectangle
|
||||
|
||||
Draws a rectangle around any mobject. The go-to for highlighting:
|
||||
|
||||
```python
|
||||
highlight = SurroundingRectangle(
|
||||
equation[2], # the term to highlight
|
||||
color=YELLOW,
|
||||
buff=0.15, # padding between content and border
|
||||
corner_radius=0.1, # rounded corners
|
||||
stroke_width=2
|
||||
)
|
||||
self.play(Create(highlight))
|
||||
self.wait(1)
|
||||
self.play(FadeOut(highlight))
|
||||
```
|
||||
|
||||
### Around part of an equation
|
||||
|
||||
```python
|
||||
eq = MathTex(r"E", r"=", r"m", r"c^2")
|
||||
box = SurroundingRectangle(eq[2:], color=YELLOW, buff=0.1) # highlight "mc²"
|
||||
label = Text("mass-energy", font_size=18, font="Menlo", color=YELLOW)
|
||||
label.next_to(box, DOWN, buff=0.2)
|
||||
self.play(Create(box), FadeIn(label))
|
||||
```
|
||||
|
||||
## BackgroundRectangle
|
||||
|
||||
Semi-transparent background behind text for readability over complex scenes:
|
||||
|
||||
```python
|
||||
bg = BackgroundRectangle(equation, fill_opacity=0.7, buff=0.2, color=BLACK)
|
||||
self.play(FadeIn(bg), Write(equation))
|
||||
|
||||
# Or using set_stroke for a "backdrop" effect on the text itself:
|
||||
label.set_stroke(BLACK, width=5, background=True)
|
||||
```
|
||||
|
||||
The `set_stroke(background=True)` approach is cleaner for text labels over graphs/diagrams.
|
||||
|
||||
## Brace and BraceLabel
|
||||
|
||||
Curly braces that annotate sections of a diagram or equation:
|
||||
|
||||
```python
|
||||
brace = Brace(equation[2:4], DOWN, color=YELLOW)
|
||||
brace_label = brace.get_text("these terms", font_size=20)
|
||||
self.play(GrowFromCenter(brace), FadeIn(brace_label))
|
||||
|
||||
# Between two specific points
|
||||
brace = BraceBetweenPoints(point_a, point_b, direction=UP)
|
||||
```
|
||||
|
||||
### Brace placement
|
||||
|
||||
```python
|
||||
# Below a group
|
||||
Brace(group, DOWN)
|
||||
# Above a group
|
||||
Brace(group, UP)
|
||||
# Left of a group
|
||||
Brace(group, LEFT)
|
||||
# Right of a group
|
||||
Brace(group, RIGHT)
|
||||
```
|
||||
|
||||
## Arrows for Annotation
|
||||
|
||||
### Straight arrows pointing to mobjects
|
||||
|
||||
```python
|
||||
arrow = Arrow(
|
||||
start=label.get_bottom(),
|
||||
end=target.get_top(),
|
||||
color=YELLOW,
|
||||
stroke_width=2,
|
||||
buff=0.1, # gap between arrow tip and target
|
||||
max_tip_length_to_length_ratio=0.15 # small arrowhead
|
||||
)
|
||||
self.play(GrowArrow(arrow), FadeIn(label))
|
||||
```
|
||||
|
||||
### Curved arrows
|
||||
|
||||
```python
|
||||
arrow = CurvedArrow(
|
||||
start_point=source.get_right(),
|
||||
end_point=target.get_left(),
|
||||
angle=PI/4, # curve angle
|
||||
color=PRIMARY
|
||||
)
|
||||
```
|
||||
|
||||
### Labeling with arrows
|
||||
|
||||
```python
|
||||
# LabeledArrow: arrow with built-in text label
|
||||
arr = LabeledArrow(
|
||||
Text("gradient", font_size=16, font="Menlo"),
|
||||
start=point_a, end=point_b, color=RED
|
||||
)
|
||||
```
|
||||
|
||||
## DashedLine and DashedVMobject
|
||||
|
||||
```python
|
||||
# Dashed line (for asymptotes, construction lines, implied connections)
|
||||
asymptote = DashedLine(
|
||||
axes.c2p(2, -3), axes.c2p(2, 3),
|
||||
color=YELLOW, dash_length=0.15
|
||||
)
|
||||
|
||||
# Make any VMobject dashed
|
||||
dashed_circle = DashedVMobject(Circle(radius=2, color=BLUE), num_dashes=30)
|
||||
```
|
||||
|
||||
## Angle and RightAngle Markers
|
||||
|
||||
```python
|
||||
line1 = Line(ORIGIN, RIGHT * 2)
|
||||
line2 = Line(ORIGIN, UP * 2 + RIGHT)
|
||||
|
||||
# Angle arc between two lines
|
||||
angle = Angle(line1, line2, radius=0.5, color=YELLOW)
|
||||
angle_value = angle.get_value() # radians
|
||||
|
||||
# Right angle marker (the small square)
|
||||
right_angle = RightAngle(line1, Line(ORIGIN, UP * 2), length=0.3, color=WHITE)
|
||||
```
|
||||
|
||||
## Cross (strikethrough)
|
||||
|
||||
Mark something as wrong or deprecated:
|
||||
|
||||
```python
|
||||
cross = Cross(old_equation, color=RED, stroke_width=4)
|
||||
self.play(Create(cross))
|
||||
# Then show the correct version
|
||||
```
|
||||
|
||||
## Underline
|
||||
|
||||
```python
|
||||
underline = Underline(important_text, color=ACCENT, stroke_width=3)
|
||||
self.play(Create(underline))
|
||||
```
|
||||
|
||||
## Color Highlighting Workflow
|
||||
|
||||
### Method 1: At creation with t2c
|
||||
|
||||
```python
|
||||
text = Text("The gradient is negative here", t2c={"gradient": BLUE, "negative": RED})
|
||||
```
|
||||
|
||||
### Method 2: set_color_by_tex after creation
|
||||
|
||||
```python
|
||||
eq = MathTex(r"\nabla L = -\frac{\partial L}{\partial w}")
|
||||
eq.set_color_by_tex(r"\nabla", BLUE)
|
||||
eq.set_color_by_tex(r"\partial", RED)
|
||||
```
|
||||
|
||||
### Method 3: Index into submobjects
|
||||
|
||||
```python
|
||||
eq = MathTex(r"a", r"+", r"b", r"=", r"c")
|
||||
eq[0].set_color(RED) # "a"
|
||||
eq[2].set_color(BLUE) # "b"
|
||||
eq[4].set_color(GREEN) # "c"
|
||||
```
|
||||
|
||||
## Combining Annotations
|
||||
|
||||
Layer multiple annotations for emphasis:
|
||||
|
||||
```python
|
||||
# Highlight a term, add a brace, and an arrow — in sequence
|
||||
box = SurroundingRectangle(eq[2], color=YELLOW, buff=0.1)
|
||||
brace = Brace(eq[2], DOWN, color=YELLOW)
|
||||
label = brace.get_text("learning rate", font_size=18)
|
||||
|
||||
self.play(Create(box))
|
||||
self.wait(0.5)
|
||||
self.play(FadeOut(box), GrowFromCenter(brace), FadeIn(label))
|
||||
self.wait(1.5)
|
||||
self.play(FadeOut(brace), FadeOut(label))
|
||||
```
|
||||
|
||||
### The annotation lifecycle
|
||||
|
||||
Annotations should follow a rhythm:
|
||||
1. **Appear** — draw attention (Create, GrowFromCenter)
|
||||
2. **Hold** — viewer reads and understands (self.wait)
|
||||
3. **Disappear** — clear the stage for the next thing (FadeOut)
|
||||
|
||||
Never leave annotations on screen indefinitely — they become visual noise once their purpose is served.
|
||||
@@ -0,0 +1,255 @@
|
||||
# Paper Explainer Workflow
|
||||
|
||||
How to turn a research paper into an animated explainer video.
|
||||
|
||||
## Why animate a paper?
|
||||
|
||||
A research paper is optimized for precision and completeness. A video is optimized for understanding and retention. The translation is NOT "read the paper aloud with pictures" — it's "extract the core insight and make it feel obvious through visual storytelling."
|
||||
|
||||
The paper has one job: prove the claim is true. The video has a different job: make the viewer understand WHY the claim is true, and WHY it matters.
|
||||
|
||||
## Who is watching?
|
||||
|
||||
Before anything, decide the audience:
|
||||
|
||||
| Audience | Prerequisites | Pacing | Depth |
|
||||
|----------|--------------|--------|-------|
|
||||
| General public | None | Slow, many analogies | Intuition only, skip proofs |
|
||||
| Undergrad students | Basic math/CS | Medium, some formalism | Key equations, skip derivations |
|
||||
| Grad students / researchers | Domain knowledge | Faster, more notation | Full equations, sketch proofs |
|
||||
|
||||
This determines everything: vocabulary, pacing, which sections to animate, how much math to show.
|
||||
|
||||
## The 5-minute template
|
||||
|
||||
Most paper explainers fit this structure (scale times proportionally for longer videos):
|
||||
|
||||
| Section | Duration | Purpose |
|
||||
|---------|----------|---------|
|
||||
| **Hook** | 0:00-0:30 | Surprising result or provocative question |
|
||||
| **Problem** | 0:30-1:30 | What was broken/missing before this paper |
|
||||
| **Key insight** | 1:30-3:00 | The core idea, explained visually |
|
||||
| **How it works** | 3:00-4:00 | Method/algorithm, simplified |
|
||||
| **Evidence** | 4:00-4:30 | Key result that proves it works |
|
||||
| **Implications** | 4:30-5:00 | Why it matters, what it enables |
|
||||
|
||||
### What to skip
|
||||
|
||||
- Related work survey → one sentence: "Previous approaches did X, which had problem Y"
|
||||
- Implementation details → skip unless they're the contribution
|
||||
- Ablation studies → show one chart at most
|
||||
- Proofs → show the key step, not the full proof
|
||||
- Hyperparameter tuning → skip entirely
|
||||
|
||||
### What to expand
|
||||
|
||||
- The core insight → this gets the most screen time
|
||||
- Geometric/visual intuition → if the paper has math, show what it MEANS
|
||||
- Before/after comparison → the most compelling evidence
|
||||
|
||||
## Pre-code workflow
|
||||
|
||||
### Gate 1: Narration script
|
||||
|
||||
Write the full narration before any code. Every sentence maps to a visual beat. If you can't write the narration, you don't understand the paper well enough to animate it.
|
||||
|
||||
```markdown
|
||||
## Hook (30s)
|
||||
"What if I told you that a model with 7 billion parameters can outperform
|
||||
one with 70 billion — if you train it on the right data?"
|
||||
|
||||
## Problem (60s)
|
||||
"The standard approach is to scale up. More parameters, more compute.
|
||||
[VISUAL: bar chart showing model sizes growing exponentially]
|
||||
But Chinchilla showed us that most models are undertrained..."
|
||||
```
|
||||
|
||||
### Gate 2: Scene list
|
||||
|
||||
After the narration, break it into scenes. Each scene is one Manim class.
|
||||
|
||||
```markdown
|
||||
Scene 1: Hook — surprising stat with animated counter
|
||||
Scene 2: Problem — model size bar chart growing
|
||||
Scene 3: Key insight — training data vs parameters, animated 2D plot
|
||||
Scene 4: Method — pipeline diagram building left to right
|
||||
Scene 5: Results — before/after comparison with animated bars
|
||||
Scene 6: Closing — implications text
|
||||
```
|
||||
|
||||
### Gate 3: Style constants
|
||||
|
||||
Before coding scenes, define the visual language:
|
||||
|
||||
```python
|
||||
# style.py — import in every scene file
|
||||
BG = "#0D1117"
|
||||
PRIMARY = "#58C4DD"
|
||||
SECONDARY = "#83C167"
|
||||
ACCENT = "#FFFF00"
|
||||
HIGHLIGHT = "#FF6B6B"
|
||||
MONO = "Menlo"
|
||||
|
||||
# Color meanings for THIS paper
|
||||
MODEL_COLOR = PRIMARY # "the model"
|
||||
DATA_COLOR = SECONDARY # "training data"
|
||||
BASELINE_COLOR = HIGHLIGHT # "previous approach"
|
||||
RESULT_COLOR = ACCENT # "our result"
|
||||
```
|
||||
|
||||
## First-principles equation explanation
|
||||
|
||||
When the paper has a key equation, don't just show it — build it from intuition:
|
||||
|
||||
### The "what would you do?" pattern
|
||||
|
||||
1. Pose the problem in plain language
|
||||
2. Ask what the simplest solution would be
|
||||
3. Show why it doesn't work (animate the failure)
|
||||
4. Introduce the paper's solution as the fix
|
||||
5. THEN show the equation — it now feels earned
|
||||
|
||||
```python
|
||||
# Scene: Why we need attention (for a Transformer paper)
|
||||
# Step 1: "How do we let each word look at every other word?"
|
||||
# Step 2: Show naive approach (fully connected = O(n²) everything)
|
||||
# Step 3: Show it breaks (information overload, no selectivity)
|
||||
# Step 4: "What if each word could CHOOSE which words to attend to?"
|
||||
# Step 5: Show attention equation — Q, K, V now mean something
|
||||
```
|
||||
|
||||
### Equation reveal strategy
|
||||
|
||||
```python
|
||||
# Show equation dimmed first (full destination)
|
||||
eq = MathTex(r"Attention(Q,K,V) = softmax\left(\frac{QK^T}{\sqrt{d_k}}\right)V")
|
||||
eq.set_opacity(0.15)
|
||||
self.play(FadeIn(eq))
|
||||
|
||||
# Highlight Q, K, V one at a time with color + label
|
||||
for part, color, label_text in [
|
||||
(r"Q", PRIMARY, "Query: what am I looking for?"),
|
||||
(r"K", SECONDARY, "Key: what do I contain?"),
|
||||
(r"V", ACCENT, "Value: what do I output?"),
|
||||
]:
|
||||
eq.set_color_by_tex(part, color)
|
||||
label = Text(label_text, font_size=18, color=color, font=MONO)
|
||||
# position label, animate it, wait, then dim it
|
||||
```
|
||||
|
||||
## Building architecture diagrams
|
||||
|
||||
### The progressive build pattern
|
||||
|
||||
Don't show the full architecture at once. Build it:
|
||||
|
||||
1. First component appears alone → explain
|
||||
2. Arrow grows → "this feeds into..."
|
||||
3. Second component appears → explain
|
||||
4. Repeat until complete
|
||||
|
||||
```python
|
||||
# Component factory
|
||||
def make_box(label, color, width=2.0, height=0.8):
|
||||
box = RoundedRectangle(corner_radius=0.1, width=width, height=height,
|
||||
color=color, fill_opacity=0.1, stroke_width=1.5)
|
||||
text = Text(label, font_size=18, font=MONO, color=color).move_to(box)
|
||||
return Group(box, text)
|
||||
|
||||
encoder = make_box("Encoder", PRIMARY)
|
||||
decoder = make_box("Decoder", SECONDARY).next_to(encoder, RIGHT, buff=1.5)
|
||||
arrow = Arrow(encoder.get_right(), decoder.get_left(), color=DIM, stroke_width=1.5)
|
||||
|
||||
self.play(FadeIn(encoder))
|
||||
self.wait(1) # explain encoder
|
||||
self.play(GrowArrow(arrow))
|
||||
self.play(FadeIn(decoder))
|
||||
self.wait(1) # explain decoder
|
||||
```
|
||||
|
||||
### Data flow animation
|
||||
|
||||
After building the diagram, show data moving through it:
|
||||
|
||||
```python
|
||||
# Dot traveling along the pipeline
|
||||
data_dot = Dot(color=ACCENT, radius=0.1).move_to(encoder)
|
||||
self.play(FadeIn(data_dot))
|
||||
self.play(MoveAlongPath(data_dot, arrow), run_time=1)
|
||||
self.play(data_dot.animate.move_to(decoder), run_time=0.5)
|
||||
self.play(Flash(data_dot.get_center(), color=ACCENT), run_time=0.3)
|
||||
```
|
||||
|
||||
## Animating results
|
||||
|
||||
### Bar chart comparison (most common)
|
||||
|
||||
```python
|
||||
# Before/after bars
|
||||
before_data = [45, 52, 38, 61]
|
||||
after_data = [78, 85, 72, 91]
|
||||
labels = ["Task A", "Task B", "Task C", "Task D"]
|
||||
|
||||
before_chart = BarChart(before_data, bar_names=labels,
|
||||
y_range=[0, 100, 20], bar_colors=[HIGHLIGHT]*4).scale(0.6).shift(LEFT*3)
|
||||
after_chart = BarChart(after_data, bar_names=labels,
|
||||
y_range=[0, 100, 20], bar_colors=[SECONDARY]*4).scale(0.6).shift(RIGHT*3)
|
||||
|
||||
before_label = Text("Baseline", font_size=20, color=HIGHLIGHT, font=MONO)
|
||||
after_label = Text("Ours", font_size=20, color=SECONDARY, font=MONO)
|
||||
|
||||
# Reveal baseline first, then ours (dramatic comparison)
|
||||
self.play(Create(before_chart), FadeIn(before_label))
|
||||
self.wait(1.5)
|
||||
self.play(Create(after_chart), FadeIn(after_label))
|
||||
self.wait(0.5)
|
||||
|
||||
# Highlight the improvement
|
||||
improvement = Text("+35% avg", font_size=24, color=ACCENT, font=MONO)
|
||||
self.play(FadeIn(improvement))
|
||||
```
|
||||
|
||||
### Training curve (for ML papers)
|
||||
|
||||
```python
|
||||
tracker = ValueTracker(0)
|
||||
curve = always_redraw(lambda: axes.plot(
|
||||
lambda x: 1 - 0.8 * np.exp(-x / 3),
|
||||
x_range=[0, tracker.get_value()], color=PRIMARY
|
||||
))
|
||||
epoch_label = always_redraw(lambda: Text(
|
||||
f"Epoch {int(tracker.get_value())}", font_size=18, font=MONO
|
||||
).to_corner(UR))
|
||||
|
||||
self.add(curve, epoch_label)
|
||||
self.play(tracker.animate.set_value(10), run_time=5, rate_func=linear)
|
||||
```
|
||||
|
||||
## Domain-specific patterns
|
||||
|
||||
### ML papers
|
||||
- Show data flow through the model (animated pipeline)
|
||||
- Training curves with `ValueTracker`
|
||||
- Attention heatmaps as colored grids
|
||||
- Embedding space as 2D scatter (PCA/t-SNE visualization)
|
||||
- Loss landscape as 3D surface with gradient descent dot
|
||||
|
||||
### Physics/math papers
|
||||
- Use `LinearTransformationScene` for linear algebra
|
||||
- Vector fields with `ArrowVectorField` / `StreamLines`
|
||||
- Phase spaces with `NumberPlane` + trajectories
|
||||
- Wave equations with time-parameterized plots
|
||||
|
||||
### Systems/architecture papers
|
||||
- Pipeline diagrams built progressively
|
||||
- `ShowPassingFlash` for data flow along arrows
|
||||
- `ZoomedScene` for zooming into components
|
||||
- Before/after latency/throughput comparisons
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **Trying to cover the whole paper.** A 5-minute video can explain ONE core insight well. Covering everything means explaining nothing.
|
||||
2. **Reading the abstract as narration.** Academic writing is designed for readers, not listeners. Rewrite in conversational language.
|
||||
3. **Showing notation without meaning.** Never show a symbol without first showing what it represents visually.
|
||||
4. **Skipping the motivation.** Jumping straight to "here's our method" without showing why the problem matters. The Problem section is what makes the viewer care.
|
||||
5. **Identical pacing throughout.** The hook and key insight need the most visual energy. The method section can be faster. Evidence should land with impact (pause after showing the big number).
|
||||
@@ -0,0 +1,190 @@
|
||||
# Production Quality Checklist
|
||||
|
||||
Standards and checks for ensuring animation output is publication-ready.
|
||||
|
||||
## Pre-Code Checklist
|
||||
|
||||
Before writing any Manim code:
|
||||
|
||||
- [ ] Narration script written with visual beats marked
|
||||
- [ ] Scene list with purpose, duration, and layout for each
|
||||
- [ ] Color palette defined with meaning assignments (`PRIMARY` = main concept, etc.)
|
||||
- [ ] `MONO = "Menlo"` set as the font constant
|
||||
- [ ] Target resolution and aspect ratio decided
|
||||
|
||||
## Text Quality
|
||||
|
||||
### Overlap prevention
|
||||
|
||||
```python
|
||||
# RULE: buff >= 0.5 for edge text
|
||||
label.to_edge(DOWN, buff=0.5) # GOOD
|
||||
label.to_edge(DOWN, buff=0.3) # BAD — may clip
|
||||
|
||||
# RULE: FadeOut previous before adding new at same position
|
||||
self.play(ReplacementTransform(note1, note2)) # GOOD
|
||||
self.play(Write(note2)) # BAD — overlaps note1
|
||||
|
||||
# RULE: Reduce font size for dense scenes
|
||||
# When > 4 text elements visible, use font_size=20 not 28
|
||||
```
|
||||
|
||||
### Width enforcement
|
||||
|
||||
Long text strings overflow the frame:
|
||||
|
||||
```python
|
||||
# RULE: Set max width for any text that might be long
|
||||
text = Text("This is a potentially long description", font_size=22, font=MONO)
|
||||
if text.width > config.frame_width - 1.0:
|
||||
text.set_width(config.frame_width - 1.0)
|
||||
```
|
||||
|
||||
### Font consistency
|
||||
|
||||
```python
|
||||
# RULE: Define MONO once, use everywhere
|
||||
MONO = "Menlo"
|
||||
|
||||
# WRONG: mixing fonts
|
||||
Text("Title", font="Helvetica")
|
||||
Text("Label", font="Arial")
|
||||
Text("Code", font="Courier")
|
||||
|
||||
# RIGHT: one font
|
||||
Text("Title", font=MONO, weight=BOLD, font_size=48)
|
||||
Text("Label", font=MONO, font_size=20)
|
||||
Text("Code", font=MONO, font_size=18)
|
||||
```
|
||||
|
||||
## Spatial Layout
|
||||
|
||||
### The coordinate budget
|
||||
|
||||
The visible frame is approximately 14.2 wide × 8.0 tall (default 16:9). With mandatory margins:
|
||||
|
||||
```
|
||||
Usable area: x ∈ [-6.5, 6.5], y ∈ [-3.5, 3.5]
|
||||
Top title zone: y ∈ [2.5, 3.5]
|
||||
Bottom note zone: y ∈ [-3.5, -2.5]
|
||||
Main content: y ∈ [-2.5, 2.5], x ∈ [-6.0, 6.0]
|
||||
```
|
||||
|
||||
### Fill the frame
|
||||
|
||||
Empty scenes look unfinished. If the main content is small, add context:
|
||||
- A dimmed grid/axes behind the content
|
||||
- A title/subtitle at the top
|
||||
- A source citation at the bottom
|
||||
- Decorative geometry at low opacity
|
||||
|
||||
### Maximum simultaneous elements
|
||||
|
||||
**Hard limit: 6 actively visible elements.** Beyond that, the viewer can't track everything. If you need more:
|
||||
- Dim old elements to opacity 0.3
|
||||
- Remove elements that have served their purpose
|
||||
- Split into two scenes
|
||||
|
||||
## Animation Quality
|
||||
|
||||
### Variety audit
|
||||
|
||||
Check that no two consecutive scenes use the exact same:
|
||||
- Animation type (if Scene 3 uses Write for everything, Scene 4 should use FadeIn or Create)
|
||||
- Color emphasis (rotate through palette colors)
|
||||
- Layout (center, left-right, grid — alternate)
|
||||
- Pacing (if Scene 2 was slow and deliberate, Scene 3 can be faster)
|
||||
|
||||
### Tempo curve
|
||||
|
||||
A good video follows a tempo curve:
|
||||
|
||||
```
|
||||
Slow ──→ Medium ──→ FAST (climax) ──→ Slow (conclusion)
|
||||
|
||||
Scene 1: Slow (introduction, setup)
|
||||
Scene 2: Medium (building understanding)
|
||||
Scene 3: Medium-Fast (core content, lots of animation)
|
||||
Scene 4: FAST (montage of applications/results)
|
||||
Scene 5: Slow (conclusion, key takeaway)
|
||||
```
|
||||
|
||||
### Transition quality
|
||||
|
||||
Between scenes:
|
||||
- **Clean exit**: `self.play(FadeOut(Group(*self.mobjects)), run_time=0.5)`
|
||||
- **Brief pause**: `self.wait(0.3)` after fadeout, before next scene's first animation
|
||||
- **Never hard-cut**: always animate the transition
|
||||
|
||||
## Color Quality
|
||||
|
||||
### Dimming on dark backgrounds
|
||||
|
||||
Colors that look vibrant on white look muddy on dark backgrounds (#0D1117, #1C1C1C). Test your palette:
|
||||
|
||||
```python
|
||||
# Colors that work well on dark backgrounds:
|
||||
# Bright and saturated: #58C4DD, #83C167, #FFFF00, #FF6B6B
|
||||
# Colors that DON'T work: #666666 (invisible), #2244AA (too dark)
|
||||
|
||||
# RULE: Structural elements (axes, grids) at opacity 0.15
|
||||
# Context elements at 0.3-0.4
|
||||
# Primary elements at 1.0
|
||||
```
|
||||
|
||||
### Color meaning consistency
|
||||
|
||||
Once a color is assigned a meaning, it keeps that meaning for the entire video:
|
||||
|
||||
```python
|
||||
# If PRIMARY (#58C4DD) means "the model" in Scene 1,
|
||||
# it means "the model" in every scene.
|
||||
# Never reuse PRIMARY for a different concept later.
|
||||
```
|
||||
|
||||
## Data Visualization Quality
|
||||
|
||||
### Minimum requirements for charts
|
||||
|
||||
- Axis labels on every axis
|
||||
- Y-axis range starts at 0 (or has a clear break indicator)
|
||||
- Bar/line colors match the legend
|
||||
- Numbers on notable data points (at least the maximum and the comparison point)
|
||||
|
||||
### Animated counters
|
||||
|
||||
When showing a number changing:
|
||||
```python
|
||||
# GOOD: DecimalNumber with smooth animation
|
||||
counter = DecimalNumber(0, font_size=48, num_decimal_places=0, font="Menlo")
|
||||
self.play(counter.animate.set_value(1000), run_time=3, rate_func=rush_from)
|
||||
|
||||
# BAD: Text that jumps between values
|
||||
```
|
||||
|
||||
## Pre-Render Checklist
|
||||
|
||||
Before running `manim -qh`:
|
||||
|
||||
- [ ] All scenes render without errors at `-ql`
|
||||
- [ ] Preview stills at `-qm` for text-heavy scenes (check kerning)
|
||||
- [ ] Background color set in every scene (`self.camera.background_color = BG`)
|
||||
- [ ] `add_subcaption()` or `subcaption=` on every significant animation
|
||||
- [ ] No text smaller than font_size=18
|
||||
- [ ] No text using proportional fonts (use monospace)
|
||||
- [ ] buff >= 0.5 on all `.to_edge()` calls
|
||||
- [ ] Clean exit (FadeOut all) at end of every scene
|
||||
- [ ] `self.wait()` after every reveal
|
||||
- [ ] Color constants used (no hardcoded hex strings in scene code)
|
||||
- [ ] All scenes use the same quality flag (don't mix `-ql` and `-qh`)
|
||||
|
||||
## Post-Render Checklist
|
||||
|
||||
After stitching the final video:
|
||||
|
||||
- [ ] Watch the complete video at 1x speed — does it feel rushed anywhere?
|
||||
- [ ] Is there a moment where two things animate simultaneously and it's confusing?
|
||||
- [ ] Does every text label have enough time to be read?
|
||||
- [ ] Are transitions between scenes smooth (no black frames, no jarring cuts)?
|
||||
- [ ] Is the audio in sync with the visuals (if using voiceover)?
|
||||
- [ ] Is the Gibbs-like "first impression" good? The first 5 seconds determine if someone keeps watching
|
||||
@@ -0,0 +1,260 @@
|
||||
# Updaters and Value Trackers
|
||||
|
||||
## The problem updaters solve
|
||||
|
||||
Normal animations are discrete: `self.play()` goes from state A to state B. But what if you need continuous relationships — a label that always hovers above a moving dot, or a line that always connects two points?
|
||||
|
||||
Without updaters, you'd manually reposition every dependent object before every `self.play()`. Five animations that move a dot means five manual repositioning calls for the label. Miss one and it freezes in the wrong spot.
|
||||
|
||||
Updaters let you declare a relationship ONCE. Manim calls the updater function EVERY FRAME (15-60 fps depending on quality) to enforce that relationship, no matter what else is happening.
|
||||
|
||||
## ValueTracker: an invisible steering wheel
|
||||
|
||||
A ValueTracker is an invisible Mobject that holds a single float. It never appears on screen. It exists so you can ANIMATE it while other objects REACT to its value.
|
||||
|
||||
Think of it as a slider: drag the slider from 0 to 5, and every object wired to it responds in real time.
|
||||
|
||||
```python
|
||||
tracker = ValueTracker(0) # invisible, stores 0.0
|
||||
tracker.get_value() # read: 0.0
|
||||
tracker.set_value(5) # write: jump to 5.0 instantly
|
||||
tracker.animate.set_value(5) # animate: smoothly interpolate to 5.0
|
||||
```
|
||||
|
||||
### The three-step pattern
|
||||
|
||||
Every ValueTracker usage follows this:
|
||||
|
||||
1. **Create the tracker** (the invisible slider)
|
||||
2. **Create visible objects that READ the tracker** via updaters
|
||||
3. **Animate the tracker** — all dependents update automatically
|
||||
|
||||
```python
|
||||
# Step 1: Create tracker
|
||||
x_tracker = ValueTracker(1)
|
||||
|
||||
# Step 2: Create dependent objects
|
||||
dot = always_redraw(lambda: Dot(axes.c2p(x_tracker.get_value(), 0), color=YELLOW))
|
||||
v_line = always_redraw(lambda: axes.get_vertical_line(
|
||||
axes.c2p(x_tracker.get_value(), func(x_tracker.get_value())), color=BLUE
|
||||
))
|
||||
label = always_redraw(lambda: DecimalNumber(x_tracker.get_value(), font_size=24)
|
||||
.next_to(dot, UP))
|
||||
|
||||
self.add(dot, v_line, label)
|
||||
|
||||
# Step 3: Animate the tracker — everything follows
|
||||
self.play(x_tracker.animate.set_value(5), run_time=3)
|
||||
```
|
||||
|
||||
## Types of updaters
|
||||
|
||||
### Lambda updater (most common)
|
||||
|
||||
Runs a function every frame, passing the mobject itself:
|
||||
|
||||
```python
|
||||
# Label always stays above the dot
|
||||
label.add_updater(lambda m: m.next_to(dot, UP, buff=0.2))
|
||||
|
||||
# Line always connects two points
|
||||
line.add_updater(lambda m: m.put_start_and_end_on(
|
||||
point_a.get_center(), point_b.get_center()
|
||||
))
|
||||
```
|
||||
|
||||
### Time-based updater (with dt)
|
||||
|
||||
The second argument `dt` is the time since the last frame (~0.017s at 60fps):
|
||||
|
||||
```python
|
||||
# Continuous rotation
|
||||
square.add_updater(lambda m, dt: m.rotate(0.5 * dt))
|
||||
|
||||
# Continuous rightward drift
|
||||
dot.add_updater(lambda m, dt: m.shift(RIGHT * 0.3 * dt))
|
||||
|
||||
# Oscillation
|
||||
dot.add_updater(lambda m, dt: m.move_to(
|
||||
axes.c2p(m.get_center()[0], np.sin(self.time))
|
||||
))
|
||||
```
|
||||
|
||||
Use `dt` updaters for physics simulations, continuous motion, and time-dependent effects.
|
||||
|
||||
### always_redraw: full rebuild every frame
|
||||
|
||||
Creates a new mobject from scratch each frame. More expensive than `add_updater` but handles cases where the mobject's structure changes (not just position/color):
|
||||
|
||||
```python
|
||||
# Brace that follows a resizing square
|
||||
brace = always_redraw(Brace, square, UP)
|
||||
|
||||
# Area under curve that updates as function changes
|
||||
area = always_redraw(lambda: axes.get_area(
|
||||
graph, x_range=[0, x_tracker.get_value()], color=BLUE, opacity=0.3
|
||||
))
|
||||
|
||||
# Label that reconstructs its text
|
||||
counter = always_redraw(lambda: Text(
|
||||
f"n = {int(x_tracker.get_value())}", font_size=24, font="Menlo"
|
||||
).to_corner(UR))
|
||||
```
|
||||
|
||||
**When to use which:**
|
||||
- `add_updater` — position, color, opacity changes (cheap, preferred)
|
||||
- `always_redraw` — when the shape/structure itself changes (expensive, use sparingly)
|
||||
|
||||
## DecimalNumber: showing live values
|
||||
|
||||
```python
|
||||
# Counter that tracks a ValueTracker
|
||||
tracker = ValueTracker(0)
|
||||
number = DecimalNumber(0, font_size=48, num_decimal_places=1, color=PRIMARY)
|
||||
number.add_updater(lambda m: m.set_value(tracker.get_value()))
|
||||
number.add_updater(lambda m: m.next_to(dot, RIGHT, buff=0.3))
|
||||
|
||||
self.add(number)
|
||||
self.play(tracker.animate.set_value(100), run_time=3)
|
||||
```
|
||||
|
||||
### Variable: the labeled version
|
||||
|
||||
```python
|
||||
var = Variable(0, Text("x", font_size=24, font="Menlo"), num_decimal_places=2)
|
||||
self.add(var)
|
||||
self.play(var.tracker.animate.set_value(PI), run_time=2)
|
||||
# Displays: x = 3.14
|
||||
```
|
||||
|
||||
## Removing updaters
|
||||
|
||||
```python
|
||||
# Remove all updaters
|
||||
mobject.clear_updaters()
|
||||
|
||||
# Suspend temporarily (during an animation that would fight the updater)
|
||||
mobject.suspend_updating()
|
||||
self.play(mobject.animate.shift(RIGHT))
|
||||
mobject.resume_updating()
|
||||
|
||||
# Remove specific updater (if you stored a reference)
|
||||
def my_updater(m):
|
||||
m.next_to(dot, UP)
|
||||
label.add_updater(my_updater)
|
||||
# ... later ...
|
||||
label.remove_updater(my_updater)
|
||||
```
|
||||
|
||||
## Animation-based updaters
|
||||
|
||||
### UpdateFromFunc / UpdateFromAlphaFunc
|
||||
|
||||
These are ANIMATIONS (passed to `self.play`), not persistent updaters:
|
||||
|
||||
```python
|
||||
# Call a function on each frame of the animation
|
||||
self.play(UpdateFromFunc(mobject, lambda m: m.next_to(moving_target, UP)), run_time=3)
|
||||
|
||||
# With alpha (0 to 1) — useful for custom interpolation
|
||||
self.play(UpdateFromAlphaFunc(circle, lambda m, a: m.set_fill(opacity=a)), run_time=2)
|
||||
```
|
||||
|
||||
### turn_animation_into_updater
|
||||
|
||||
Convert a one-shot animation into a continuous updater:
|
||||
|
||||
```python
|
||||
from manim import turn_animation_into_updater
|
||||
|
||||
# This would normally play once — now it loops forever
|
||||
turn_animation_into_updater(Rotating(gear, rate=PI/4))
|
||||
self.add(gear)
|
||||
self.wait(5) # gear rotates for 5 seconds
|
||||
```
|
||||
|
||||
## Practical patterns
|
||||
|
||||
### Pattern 1: Dot tracing a function
|
||||
|
||||
```python
|
||||
tracker = ValueTracker(0)
|
||||
graph = axes.plot(np.sin, x_range=[0, 2*PI], color=PRIMARY)
|
||||
dot = always_redraw(lambda: Dot(
|
||||
axes.c2p(tracker.get_value(), np.sin(tracker.get_value())),
|
||||
color=YELLOW
|
||||
))
|
||||
tangent = always_redraw(lambda: axes.get_secant_slope_group(
|
||||
x=tracker.get_value(), graph=graph, dx=0.01,
|
||||
secant_line_color=HIGHLIGHT, secant_line_length=3
|
||||
))
|
||||
|
||||
self.add(graph, dot, tangent)
|
||||
self.play(tracker.animate.set_value(2*PI), run_time=6, rate_func=linear)
|
||||
```
|
||||
|
||||
### Pattern 2: Live area under curve
|
||||
|
||||
```python
|
||||
tracker = ValueTracker(0.5)
|
||||
area = always_redraw(lambda: axes.get_area(
|
||||
graph, x_range=[0, tracker.get_value()],
|
||||
color=PRIMARY, opacity=0.3
|
||||
))
|
||||
area_label = always_redraw(lambda: DecimalNumber(
|
||||
# Numerical integration
|
||||
sum(func(x) * 0.01 for x in np.arange(0, tracker.get_value(), 0.01)),
|
||||
font_size=24
|
||||
).next_to(axes, RIGHT))
|
||||
|
||||
self.add(area, area_label)
|
||||
self.play(tracker.animate.set_value(4), run_time=5)
|
||||
```
|
||||
|
||||
### Pattern 3: Connected diagram
|
||||
|
||||
```python
|
||||
# Nodes that can be moved, with edges that auto-follow
|
||||
node_a = Dot(LEFT * 2, color=PRIMARY)
|
||||
node_b = Dot(RIGHT * 2, color=SECONDARY)
|
||||
edge = Line().add_updater(lambda m: m.put_start_and_end_on(
|
||||
node_a.get_center(), node_b.get_center()
|
||||
))
|
||||
label = Text("edge", font_size=18, font="Menlo").add_updater(
|
||||
lambda m: m.move_to(edge.get_center() + UP * 0.3)
|
||||
)
|
||||
|
||||
self.add(node_a, node_b, edge, label)
|
||||
self.play(node_a.animate.shift(UP * 2), run_time=2)
|
||||
self.play(node_b.animate.shift(DOWN + RIGHT), run_time=2)
|
||||
# Edge and label follow automatically
|
||||
```
|
||||
|
||||
### Pattern 4: Parameter exploration
|
||||
|
||||
```python
|
||||
# Explore how a parameter changes a curve
|
||||
a_tracker = ValueTracker(1)
|
||||
curve = always_redraw(lambda: axes.plot(
|
||||
lambda x: a_tracker.get_value() * np.sin(x),
|
||||
x_range=[0, 2*PI], color=PRIMARY
|
||||
))
|
||||
param_label = always_redraw(lambda: Text(
|
||||
f"a = {a_tracker.get_value():.1f}", font_size=24, font="Menlo"
|
||||
).to_corner(UR))
|
||||
|
||||
self.add(curve, param_label)
|
||||
self.play(a_tracker.animate.set_value(3), run_time=3)
|
||||
self.play(a_tracker.animate.set_value(0.5), run_time=2)
|
||||
self.play(a_tracker.animate.set_value(1), run_time=1)
|
||||
```
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **Updater fights animation:** If a mobject has an updater that sets its position, and you try to animate it elsewhere, the updater wins every frame. Suspend updating first.
|
||||
|
||||
2. **always_redraw for simple moves:** If you only need to reposition, use `add_updater`. `always_redraw` reconstructs the entire mobject every frame — expensive and unnecessary for position tracking.
|
||||
|
||||
3. **Forgetting to add to scene:** Updaters only run on mobjects that are in the scene. `always_redraw` creates the mobject but you still need `self.add()`.
|
||||
|
||||
4. **Updater creates new mobjects without cleanup:** If your updater creates Text objects every frame, they accumulate. Use `always_redraw` (which handles cleanup) or update properties in-place.
|
||||
@@ -0,0 +1,64 @@
|
||||
# p5.js Skill
|
||||
|
||||
Production pipeline for interactive and generative visual art using [p5.js](https://p5js.org/).
|
||||
|
||||
## What it does
|
||||
|
||||
Creates browser-based visual art from text prompts. The agent handles the full pipeline: creative concept, code generation, preview, export, and iterative refinement. Output is a single self-contained HTML file that runs in any browser — no build step, no server, no dependencies beyond a CDN script tag.
|
||||
|
||||
The output is real interactive art. Not tutorial exercises. Generative systems, particle physics, noise fields, shader effects, kinetic typography — composed with intentional color palettes, layered composition, and visual hierarchy.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Input | Output |
|
||||
|------|-------|--------|
|
||||
| **Generative art** | Seed / parameters | Procedural visual composition |
|
||||
| **Data visualization** | Dataset / API | Interactive charts, custom data displays |
|
||||
| **Interactive experience** | None (user drives) | Mouse/keyboard/touch-driven sketch |
|
||||
| **Animation / motion graphics** | Timeline / storyboard | Timed sequences, kinetic typography |
|
||||
| **3D scene** | Concept description | WebGL geometry, lighting, shaders |
|
||||
| **Image processing** | Image file(s) | Pixel manipulation, filters, pointillism |
|
||||
| **Audio-reactive** | Audio file / mic | Sound-driven generative visuals |
|
||||
|
||||
## Export Formats
|
||||
|
||||
| Format | Method |
|
||||
|--------|--------|
|
||||
| **HTML** | Self-contained file, opens in any browser |
|
||||
| **PNG** | `saveCanvas()` — press 's' to capture |
|
||||
| **GIF** | `saveGif()` — press 'g' to capture |
|
||||
| **MP4** | Frame sequence + ffmpeg via `scripts/render.sh` |
|
||||
| **SVG** | p5.js-svg renderer for vector output |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
A modern browser. That's it for basic use.
|
||||
|
||||
For headless export: Node.js, Puppeteer, ffmpeg.
|
||||
|
||||
```bash
|
||||
bash skills/creative/p5js/scripts/setup.sh
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
├── SKILL.md # Modes, workflow, creative direction, critical notes
|
||||
├── README.md # This file
|
||||
├── references/
|
||||
│ ├── core-api.md # Canvas, draw loop, transforms, offscreen buffers, math
|
||||
│ ├── shapes-and-geometry.md # Primitives, vertices, curves, vectors, SDFs, clipping
|
||||
│ ├── visual-effects.md # Noise, flow fields, particles, pixels, textures, feedback
|
||||
│ ├── animation.md # Easing, springs, state machines, timelines, transitions
|
||||
│ ├── typography.md # Fonts, textToPoints, kinetic text, text masks
|
||||
│ ├── color-systems.md # HSB/RGB, palettes, gradients, blend modes, curated colors
|
||||
│ ├── webgl-and-3d.md # 3D primitives, camera, lighting, shaders, framebuffers
|
||||
│ ├── interaction.md # Mouse, keyboard, touch, DOM, audio, scroll
|
||||
│ ├── export-pipeline.md # PNG, GIF, MP4, SVG, headless, tiling, batch export
|
||||
│ └── troubleshooting.md # Performance, common mistakes, browser issues, debugging
|
||||
└── scripts/
|
||||
├── setup.sh # Dependency verification
|
||||
├── serve.sh # Local dev server (for loading local assets)
|
||||
├── render.sh # Headless render pipeline (HTML → frames → MP4)
|
||||
└── export-frames.js # Puppeteer frame capture (Node.js)
|
||||
```
|
||||
@@ -0,0 +1,513 @@
|
||||
---
|
||||
name: p5js
|
||||
description: "Production pipeline for interactive and generative visual art using p5.js. Creates browser-based sketches, generative art, data visualizations, interactive experiences, 3D scenes, audio-reactive visuals, and motion graphics — exported as HTML, PNG, GIF, MP4, or SVG. Covers: 2D/3D rendering, noise and particle systems, flow fields, shaders (GLSL), pixel manipulation, kinetic typography, WebGL scenes, audio analysis, mouse/keyboard interaction, and headless high-res export. Use when users request: p5.js sketches, creative coding, generative art, interactive visualizations, canvas animations, browser-based visual art, data viz, shader effects, or any p5.js project."
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [creative-coding, generative-art, p5js, canvas, interactive, visualization, webgl, shaders, animation]
|
||||
related_skills: [ascii-video, manim-video, excalidraw]
|
||||
---
|
||||
|
||||
# p5.js Production Pipeline
|
||||
|
||||
## Creative Standard
|
||||
|
||||
This is visual art rendered in the browser. The canvas is the medium; the algorithm is the brush.
|
||||
|
||||
**Before writing a single line of code**, articulate the creative concept. What does this piece communicate? What makes the viewer stop scrolling? What separates this from a code tutorial example? The user's prompt is a starting point — interpret it with creative ambition.
|
||||
|
||||
**First-render excellence is non-negotiable.** The output must be visually striking on first load. If it looks like a p5.js tutorial exercise, a default configuration, or "AI-generated creative coding," it is wrong. Rethink before shipping.
|
||||
|
||||
**Go beyond the reference vocabulary.** The noise functions, particle systems, color palettes, and shader effects in the references are a starting vocabulary. For every project, combine, layer, and invent. The catalog is a palette of paints — you write the painting.
|
||||
|
||||
**Be proactively creative.** If the user asks for "a particle system," deliver a particle system with emergent flocking behavior, trailing ghost echoes, palette-shifted depth fog, and a background noise field that breathes. Include at least one visual detail the user didn't ask for but will appreciate.
|
||||
|
||||
**Dense, layered, considered.** Every frame should reward viewing. Never flat white backgrounds. Always compositional hierarchy. Always intentional color. Always micro-detail that only appears on close inspection.
|
||||
|
||||
**Cohesive aesthetic over feature count.** All elements must serve a unified visual language — shared color temperature, consistent stroke weight vocabulary, harmonious motion speeds. A sketch with ten unrelated effects is worse than one with three that belong together.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Input | Output | Reference |
|
||||
|------|-------|--------|-----------|
|
||||
| **Generative art** | Seed / parameters | Procedural visual composition (still or animated) | `references/visual-effects.md` |
|
||||
| **Data visualization** | Dataset / API | Interactive charts, graphs, custom data displays | `references/interaction.md` |
|
||||
| **Interactive experience** | None (user drives) | Mouse/keyboard/touch-driven sketch | `references/interaction.md` |
|
||||
| **Animation / motion graphics** | Timeline / storyboard | Timed sequences, kinetic typography, transitions | `references/animation.md` |
|
||||
| **3D scene** | Concept description | WebGL geometry, lighting, camera, materials | `references/webgl-and-3d.md` |
|
||||
| **Image processing** | Image file(s) | Pixel manipulation, filters, mosaic, pointillism | `references/visual-effects.md` § Pixel Manipulation |
|
||||
| **Audio-reactive** | Audio file / mic | Sound-driven generative visuals | `references/interaction.md` § Audio Input |
|
||||
|
||||
## Stack
|
||||
|
||||
Single self-contained HTML file per project. No build step required.
|
||||
|
||||
| Layer | Tool | Purpose |
|
||||
|-------|------|---------|
|
||||
| Core | p5.js 1.11.3 (CDN) | Canvas rendering, math, transforms, event handling |
|
||||
| 3D | p5.js WebGL mode | 3D geometry, camera, lighting, GLSL shaders |
|
||||
| Audio | p5.sound.js (CDN) | FFT analysis, amplitude, mic input, oscillators |
|
||||
| Export | Built-in `saveCanvas()` / `saveGif()` / `saveFrames()` | PNG, GIF, frame sequence output |
|
||||
| Capture | CCapture.js (optional) | Deterministic framerate video capture (WebM, GIF) |
|
||||
| Headless | Puppeteer + Node.js (optional) | Automated high-res rendering, MP4 via ffmpeg |
|
||||
| SVG | p5.js-svg 1.6.0 (optional) | Vector output for print — requires p5.js 1.x |
|
||||
| Natural media | p5.brush (optional) | Watercolor, charcoal, pen — requires p5.js 2.x + WEBGL |
|
||||
| Texture | p5.grain (optional) | Film grain, texture overlays |
|
||||
| Fonts | Google Fonts / `loadFont()` | Custom typography via OTF/TTF/WOFF2 |
|
||||
|
||||
### Version Note
|
||||
|
||||
**p5.js 1.x** (1.11.3) is the default — stable, well-documented, broadest library compatibility. Use this unless a project requires 2.x features.
|
||||
|
||||
**p5.js 2.x** (2.2+) adds: `async setup()` replacing `preload()`, OKLCH/OKLAB color modes, `splineVertex()`, shader `.modify()` API, variable fonts, `textToContours()`, pointer events. Required for p5.brush. See `references/core-api.md` § p5.js 2.0.
|
||||
|
||||
## Pipeline
|
||||
|
||||
Every project follows the same 6-stage path:
|
||||
|
||||
```
|
||||
CONCEPT → DESIGN → CODE → PREVIEW → EXPORT → VERIFY
|
||||
```
|
||||
|
||||
1. **CONCEPT** — Articulate the creative vision: mood, color world, motion vocabulary, what makes this unique
|
||||
2. **DESIGN** — Choose mode, canvas size, interaction model, color system, export format. Map concept to technical decisions
|
||||
3. **CODE** — Write single HTML file with inline p5.js. Structure: globals → `preload()` → `setup()` → `draw()` → helpers → classes → event handlers
|
||||
4. **PREVIEW** — Open in browser, verify visual quality. Test at target resolution. Check performance
|
||||
5. **EXPORT** — Capture output: `saveCanvas()` for PNG, `saveGif()` for GIF, `saveFrames()` + ffmpeg for MP4, Puppeteer for headless batch
|
||||
6. **VERIFY** — Does the output match the concept? Is it visually striking at the intended display size? Would you frame it?
|
||||
|
||||
## Creative Direction
|
||||
|
||||
### Aesthetic Dimensions
|
||||
|
||||
| Dimension | Options | Reference |
|
||||
|-----------|---------|-----------|
|
||||
| **Color system** | HSB/HSL, RGB, named palettes, procedural harmony, gradient interpolation | `references/color-systems.md` |
|
||||
| **Noise vocabulary** | Perlin noise, simplex, fractal (octaved), domain warping, curl noise | `references/visual-effects.md` § Noise |
|
||||
| **Particle systems** | Physics-based, flocking, trail-drawing, attractor-driven, flow-field following | `references/visual-effects.md` § Particles |
|
||||
| **Shape language** | Geometric primitives, custom vertices, bezier curves, SVG paths | `references/shapes-and-geometry.md` |
|
||||
| **Motion style** | Eased, spring-based, noise-driven, physics sim, lerped, stepped | `references/animation.md` |
|
||||
| **Typography** | System fonts, loaded OTF, `textToPoints()` particle text, kinetic | `references/typography.md` |
|
||||
| **Shader effects** | GLSL fragment/vertex, filter shaders, post-processing, feedback loops | `references/webgl-and-3d.md` § Shaders |
|
||||
| **Composition** | Grid, radial, golden ratio, rule of thirds, organic scatter, tiled | `references/core-api.md` § Composition |
|
||||
| **Interaction model** | Mouse follow, click spawn, drag, keyboard state, scroll-driven, mic input | `references/interaction.md` |
|
||||
| **Blend modes** | `BLEND`, `ADD`, `MULTIPLY`, `SCREEN`, `DIFFERENCE`, `EXCLUSION`, `OVERLAY` | `references/color-systems.md` § Blend Modes |
|
||||
| **Layering** | `createGraphics()` offscreen buffers, alpha compositing, masking | `references/core-api.md` § Offscreen Buffers |
|
||||
| **Texture** | Perlin surface, stippling, hatching, halftone, pixel sorting | `references/visual-effects.md` § Texture Generation |
|
||||
|
||||
### Per-Project Variation Rules
|
||||
|
||||
Never use default configurations. For every project:
|
||||
- **Custom color palette** — never raw `fill(255, 0, 0)`. Always a designed palette with 3-7 colors
|
||||
- **Custom stroke weight vocabulary** — thin accents (0.5), medium structure (1-2), bold emphasis (3-5)
|
||||
- **Background treatment** — never plain `background(0)` or `background(255)`. Always textured, gradient, or layered
|
||||
- **Motion variety** — different speeds for different elements. Primary at 1x, secondary at 0.3x, ambient at 0.1x
|
||||
- **At least one invented element** — a custom particle behavior, a novel noise application, a unique interaction response
|
||||
|
||||
### Project-Specific Invention
|
||||
|
||||
For every project, invent at least one of:
|
||||
- A custom color palette matching the mood (not a preset)
|
||||
- A novel noise field combination (e.g., curl noise + domain warp + feedback)
|
||||
- A unique particle behavior (custom forces, custom trails, custom spawning)
|
||||
- An interaction mechanic the user didn't request but that elevates the piece
|
||||
- A compositional technique that creates visual hierarchy
|
||||
|
||||
### Parameter Design Philosophy
|
||||
|
||||
Parameters should emerge from the algorithm, not from a generic menu. Ask: "What properties of *this* system should be tunable?"
|
||||
|
||||
**Good parameters** expose the algorithm's character:
|
||||
- **Quantities** — how many particles, branches, cells (controls density)
|
||||
- **Scales** — noise frequency, element size, spacing (controls texture)
|
||||
- **Rates** — speed, growth rate, decay (controls energy)
|
||||
- **Thresholds** — when does behavior change? (controls drama)
|
||||
- **Ratios** — proportions, balance between forces (controls harmony)
|
||||
|
||||
**Bad parameters** are generic controls unrelated to the algorithm:
|
||||
- "color1", "color2", "size" — meaningless without context
|
||||
- Toggle switches for unrelated effects
|
||||
- Parameters that only change cosmetics, not behavior
|
||||
|
||||
Every parameter should change how the algorithm *thinks*, not just how it *looks*. A "turbulence" parameter that changes noise octaves is good. A "particle size" slider that only changes `ellipse()` radius is shallow.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Creative Vision
|
||||
|
||||
Before any code, articulate:
|
||||
|
||||
- **Mood / atmosphere**: What should the viewer feel? Contemplative? Energized? Unsettled? Playful?
|
||||
- **Visual story**: What happens over time (or on interaction)? Build? Decay? Transform? Oscillate?
|
||||
- **Color world**: Warm/cool? Monochrome? Complementary? What's the dominant hue? The accent?
|
||||
- **Shape language**: Organic curves? Sharp geometry? Dots? Lines? Mixed?
|
||||
- **Motion vocabulary**: Slow drift? Explosive burst? Breathing pulse? Mechanical precision?
|
||||
- **What makes THIS different**: What is the one thing that makes this sketch unique?
|
||||
|
||||
Map the user's prompt to aesthetic choices. "Relaxing generative background" demands different everything from "glitch data visualization."
|
||||
|
||||
### Step 2: Technical Design
|
||||
|
||||
- **Mode** — which of the 7 modes from the table above
|
||||
- **Canvas size** — landscape 1920x1080, portrait 1080x1920, square 1080x1080, or responsive `windowWidth/windowHeight`
|
||||
- **Renderer** — `P2D` (default) or `WEBGL` (for 3D, shaders, advanced blend modes)
|
||||
- **Frame rate** — 60fps (interactive), 30fps (ambient animation), or `noLoop()` (static generative)
|
||||
- **Export target** — browser display, PNG still, GIF loop, MP4 video, SVG vector
|
||||
- **Interaction model** — passive (no input), mouse-driven, keyboard-driven, audio-reactive, scroll-driven
|
||||
- **Viewer UI** — for interactive generative art, start from `templates/viewer.html` which provides seed navigation, parameter sliders, and download. For simple sketches or video export, use bare HTML
|
||||
|
||||
### Step 3: Code the Sketch
|
||||
|
||||
For **interactive generative art** (seed exploration, parameter tuning): start from `templates/viewer.html`. Read the template first, keep the fixed sections (seed nav, actions), replace the algorithm and parameter controls. This gives the user seed prev/next/random/jump, parameter sliders with live update, and PNG download — all wired up.
|
||||
|
||||
For **animations, video export, or simple sketches**: use bare HTML:
|
||||
|
||||
Single HTML file. Structure:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Project Name</title>
|
||||
<script>p5.disableFriendlyErrors = true;</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.3/p5.min.js"></script>
|
||||
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.3/addons/p5.sound.min.js"></script> -->
|
||||
<!-- <script src="https://unpkg.com/p5.js-svg@1.6.0"></script> --> <!-- SVG export -->
|
||||
<!-- <script src="https://cdn.jsdelivr.net/npm/ccapture.js-npmfixed/build/CCapture.all.min.js"></script> --> <!-- video capture -->
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; overflow: hidden; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// === Configuration ===
|
||||
const CONFIG = {
|
||||
seed: 42,
|
||||
// ... project-specific params
|
||||
};
|
||||
|
||||
// === Color Palette ===
|
||||
const PALETTE = {
|
||||
bg: '#0a0a0f',
|
||||
primary: '#e8d5b7',
|
||||
// ...
|
||||
};
|
||||
|
||||
// === Global State ===
|
||||
let particles = [];
|
||||
|
||||
// === Preload (fonts, images, data) ===
|
||||
function preload() {
|
||||
// font = loadFont('...');
|
||||
}
|
||||
|
||||
// === Setup ===
|
||||
function setup() {
|
||||
createCanvas(1920, 1080);
|
||||
randomSeed(CONFIG.seed);
|
||||
noiseSeed(CONFIG.seed);
|
||||
colorMode(HSB, 360, 100, 100, 100);
|
||||
// Initialize state...
|
||||
}
|
||||
|
||||
// === Draw Loop ===
|
||||
function draw() {
|
||||
// Render frame...
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
// ...
|
||||
|
||||
// === Classes ===
|
||||
class Particle {
|
||||
// ...
|
||||
}
|
||||
|
||||
// === Event Handlers ===
|
||||
function mousePressed() { /* ... */ }
|
||||
function keyPressed() { /* ... */ }
|
||||
function windowResized() { resizeCanvas(windowWidth, windowHeight); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Key implementation patterns:
|
||||
- **Seeded randomness**: Always `randomSeed()` + `noiseSeed()` for reproducibility
|
||||
- **Color mode**: Use `colorMode(HSB, 360, 100, 100, 100)` for intuitive color control
|
||||
- **State separation**: CONFIG for parameters, PALETTE for colors, globals for mutable state
|
||||
- **Class-based entities**: Particles, agents, shapes as classes with `update()` + `display()` methods
|
||||
- **Offscreen buffers**: `createGraphics()` for layered composition, trails, masks
|
||||
|
||||
### Step 4: Preview & Iterate
|
||||
|
||||
- Open HTML file directly in browser — no server needed for basic sketches
|
||||
- For `loadImage()`/`loadFont()` from local files: use `scripts/serve.sh` or `python3 -m http.server`
|
||||
- Chrome DevTools Performance tab to verify 60fps
|
||||
- Test at target export resolution, not just the window size
|
||||
- Adjust parameters until the visual matches the concept from Step 1
|
||||
|
||||
### Step 5: Export
|
||||
|
||||
| Format | Method | Command |
|
||||
|--------|--------|---------|
|
||||
| **PNG** | `saveCanvas('output', 'png')` in `keyPressed()` | Press 's' to save |
|
||||
| **High-res PNG** | Puppeteer headless capture | `node scripts/export-frames.js sketch.html --width 3840 --height 2160 --frames 1` |
|
||||
| **GIF** | `saveGif('output', 5)` — captures N seconds | Press 'g' to save |
|
||||
| **Frame sequence** | `saveFrames('frame', 'png', 10, 30)` — 10s at 30fps | Then `ffmpeg -i frame-%04d.png -c:v libx264 output.mp4` |
|
||||
| **MP4** | Puppeteer frame capture + ffmpeg | `bash scripts/render.sh sketch.html output.mp4 --duration 30 --fps 30` |
|
||||
| **SVG** | `createCanvas(w, h, SVG)` with p5.js-svg | `save('output.svg')` |
|
||||
|
||||
### Step 6: Quality Verification
|
||||
|
||||
- **Does it match the vision?** Compare output to the creative concept. If it looks generic, go back to Step 1
|
||||
- **Resolution check**: Is it sharp at the target display size? No aliasing artifacts?
|
||||
- **Performance check**: Does it hold 60fps in browser? (30fps minimum for animations)
|
||||
- **Color check**: Do the colors work together? Test on both light and dark monitors
|
||||
- **Edge cases**: What happens at canvas edges? On resize? After running for 10 minutes?
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
### Performance — Disable FES First
|
||||
|
||||
The Friendly Error System (FES) adds up to 10x overhead. Disable it in every production sketch:
|
||||
|
||||
```javascript
|
||||
p5.disableFriendlyErrors = true; // BEFORE setup()
|
||||
|
||||
function setup() {
|
||||
pixelDensity(1); // prevent 2x-4x overdraw on retina
|
||||
createCanvas(1920, 1080);
|
||||
}
|
||||
```
|
||||
|
||||
In hot loops (particles, pixel ops), use `Math.*` instead of p5 wrappers — measurably faster:
|
||||
|
||||
```javascript
|
||||
// In draw() or update() hot paths:
|
||||
let a = Math.sin(t); // not sin(t)
|
||||
let r = Math.sqrt(dx*dx+dy*dy); // not dist() — or better: skip sqrt, compare magSq
|
||||
let v = Math.random(); // not random() — when seed not needed
|
||||
let m = Math.min(a, b); // not min(a, b)
|
||||
```
|
||||
|
||||
Never `console.log()` inside `draw()`. Never manipulate DOM in `draw()`. See `references/troubleshooting.md` § Performance.
|
||||
|
||||
### Seeded Randomness — Always
|
||||
|
||||
Every generative sketch must be reproducible. Same seed, same output.
|
||||
|
||||
```javascript
|
||||
function setup() {
|
||||
randomSeed(CONFIG.seed);
|
||||
noiseSeed(CONFIG.seed);
|
||||
// All random() and noise() calls now deterministic
|
||||
}
|
||||
```
|
||||
|
||||
Never use `Math.random()` for generative content — only for performance-critical non-visual code. Always `random()` for visual elements. If you need a random seed: `CONFIG.seed = floor(random(99999))`.
|
||||
|
||||
### Generative Art Platform Support (fxhash / Art Blocks)
|
||||
|
||||
For generative art platforms, replace p5's PRNG with the platform's deterministic random:
|
||||
|
||||
```javascript
|
||||
// fxhash convention
|
||||
const SEED = $fx.hash; // unique per mint
|
||||
const rng = $fx.rand; // deterministic PRNG
|
||||
$fx.features({ palette: 'warm', complexity: 'high' });
|
||||
|
||||
// In setup():
|
||||
randomSeed(SEED); // for p5's noise()
|
||||
noiseSeed(SEED);
|
||||
|
||||
// Replace random() with rng() for platform determinism
|
||||
let x = rng() * width; // instead of random(width)
|
||||
```
|
||||
|
||||
See `references/export-pipeline.md` § Platform Export.
|
||||
|
||||
### Color Mode — Use HSB
|
||||
|
||||
HSB (Hue, Saturation, Brightness) is dramatically easier to work with than RGB for generative art:
|
||||
|
||||
```javascript
|
||||
colorMode(HSB, 360, 100, 100, 100);
|
||||
// Now: fill(hue, sat, bri, alpha)
|
||||
// Rotate hue: fill((baseHue + offset) % 360, 80, 90)
|
||||
// Desaturate: fill(hue, sat * 0.3, bri)
|
||||
// Darken: fill(hue, sat, bri * 0.5)
|
||||
```
|
||||
|
||||
Never hardcode raw RGB values. Define a palette object, derive variations procedurally. See `references/color-systems.md`.
|
||||
|
||||
### Noise — Multi-Octave, Not Raw
|
||||
|
||||
Raw `noise(x, y)` looks like smooth blobs. Layer octaves for natural texture:
|
||||
|
||||
```javascript
|
||||
function fbm(x, y, octaves = 4) {
|
||||
let val = 0, amp = 1, freq = 1, sum = 0;
|
||||
for (let i = 0; i < octaves; i++) {
|
||||
val += noise(x * freq, y * freq) * amp;
|
||||
sum += amp;
|
||||
amp *= 0.5;
|
||||
freq *= 2;
|
||||
}
|
||||
return val / sum;
|
||||
}
|
||||
```
|
||||
|
||||
For flowing organic forms, use **domain warping**: feed noise output back as noise input coordinates. See `references/visual-effects.md`.
|
||||
|
||||
### createGraphics() for Layers — Not Optional
|
||||
|
||||
Flat single-pass rendering looks flat. Use offscreen buffers for composition:
|
||||
|
||||
```javascript
|
||||
let bgLayer, fgLayer, trailLayer;
|
||||
function setup() {
|
||||
createCanvas(1920, 1080);
|
||||
bgLayer = createGraphics(width, height);
|
||||
fgLayer = createGraphics(width, height);
|
||||
trailLayer = createGraphics(width, height);
|
||||
}
|
||||
function draw() {
|
||||
renderBackground(bgLayer);
|
||||
renderTrails(trailLayer); // persistent, fading
|
||||
renderForeground(fgLayer); // cleared each frame
|
||||
image(bgLayer, 0, 0);
|
||||
image(trailLayer, 0, 0);
|
||||
image(fgLayer, 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
### Performance — Vectorize Where Possible
|
||||
|
||||
p5.js draw calls are expensive. For thousands of particles:
|
||||
|
||||
```javascript
|
||||
// SLOW: individual shapes
|
||||
for (let p of particles) {
|
||||
ellipse(p.x, p.y, p.size);
|
||||
}
|
||||
|
||||
// FAST: single shape with beginShape()
|
||||
beginShape(POINTS);
|
||||
for (let p of particles) {
|
||||
vertex(p.x, p.y);
|
||||
}
|
||||
endShape();
|
||||
|
||||
// FASTEST: pixel buffer for massive counts
|
||||
loadPixels();
|
||||
for (let p of particles) {
|
||||
let idx = 4 * (floor(p.y) * width + floor(p.x));
|
||||
pixels[idx] = r; pixels[idx+1] = g; pixels[idx+2] = b; pixels[idx+3] = 255;
|
||||
}
|
||||
updatePixels();
|
||||
```
|
||||
|
||||
See `references/troubleshooting.md` § Performance.
|
||||
|
||||
### Instance Mode for Multiple Sketches
|
||||
|
||||
Global mode pollutes `window`. For production, use instance mode:
|
||||
|
||||
```javascript
|
||||
const sketch = (p) => {
|
||||
p.setup = function() {
|
||||
p.createCanvas(800, 800);
|
||||
};
|
||||
p.draw = function() {
|
||||
p.background(0);
|
||||
p.ellipse(p.mouseX, p.mouseY, 50);
|
||||
};
|
||||
};
|
||||
new p5(sketch, 'canvas-container');
|
||||
```
|
||||
|
||||
Required when embedding multiple sketches on one page or integrating with frameworks.
|
||||
|
||||
### WebGL Mode Gotchas
|
||||
|
||||
- `createCanvas(w, h, WEBGL)` — origin is center, not top-left
|
||||
- Y-axis is inverted (positive Y goes up in WEBGL, down in P2D)
|
||||
- `translate(-width/2, -height/2)` to get P2D-like coordinates
|
||||
- `push()`/`pop()` around every transform — matrix stack overflows silently
|
||||
- `texture()` before `rect()`/`plane()` — not after
|
||||
- Custom shaders: `createShader(vert, frag)` — test on multiple browsers
|
||||
|
||||
### Export — Key Bindings Convention
|
||||
|
||||
Every sketch should include these in `keyPressed()`:
|
||||
|
||||
```javascript
|
||||
function keyPressed() {
|
||||
if (key === 's' || key === 'S') saveCanvas('output', 'png');
|
||||
if (key === 'g' || key === 'G') saveGif('output', 5);
|
||||
if (key === 'r' || key === 'R') { randomSeed(millis()); noiseSeed(millis()); }
|
||||
if (key === ' ') CONFIG.paused = !CONFIG.paused;
|
||||
}
|
||||
```
|
||||
|
||||
### Headless Video Export — Use noLoop()
|
||||
|
||||
For headless rendering via Puppeteer, the sketch **must** use `noLoop()` in setup. Without it, p5's draw loop runs freely while screenshots are slow — the sketch races ahead and you get skipped/duplicate frames.
|
||||
|
||||
```javascript
|
||||
function setup() {
|
||||
createCanvas(1920, 1080);
|
||||
pixelDensity(1);
|
||||
noLoop(); // capture script controls frame advance
|
||||
window._p5Ready = true; // signal readiness to capture script
|
||||
}
|
||||
```
|
||||
|
||||
The bundled `scripts/export-frames.js` detects `_p5Ready` and calls `redraw()` once per capture for exact 1:1 frame correspondence. See `references/export-pipeline.md` § Deterministic Capture.
|
||||
|
||||
For multi-scene videos, use the per-clip architecture: one HTML per scene, render independently, stitch with `ffmpeg -f concat`. See `references/export-pipeline.md` § Per-Clip Architecture.
|
||||
|
||||
### Agent Workflow
|
||||
|
||||
When building p5.js sketches:
|
||||
|
||||
1. **Write the HTML file** — single self-contained file, all code inline
|
||||
2. **Open in browser** — `open sketch.html` (macOS) or `xdg-open sketch.html` (Linux)
|
||||
3. **Local assets** (fonts, images) require a server: `python3 -m http.server 8080` in the project directory, then open `http://localhost:8080/sketch.html`
|
||||
4. **Export PNG/GIF** — add `keyPressed()` shortcuts as shown above, tell the user which key to press
|
||||
5. **Headless export** — `node scripts/export-frames.js sketch.html --frames 300` for automated frame capture (sketch must use `noLoop()` + `_p5Ready`)
|
||||
6. **MP4 rendering** — `bash scripts/render.sh sketch.html output.mp4 --duration 30`
|
||||
7. **Iterative refinement** — edit the HTML file, user refreshes browser to see changes
|
||||
8. **Load references on demand** — use `skill_view(name="p5js", file_path="references/...")` to load specific reference files as needed during implementation
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Frame rate (interactive) | 60fps sustained |
|
||||
| Frame rate (animated export) | 30fps minimum |
|
||||
| Particle count (P2D shapes) | 5,000-10,000 at 60fps |
|
||||
| Particle count (pixel buffer) | 50,000-100,000 at 60fps |
|
||||
| Canvas resolution | Up to 3840x2160 (export), 1920x1080 (interactive) |
|
||||
| File size (HTML) | < 100KB (excluding CDN libraries) |
|
||||
| Load time | < 2s to first frame |
|
||||
|
||||
## References
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `references/core-api.md` | Canvas setup, coordinate system, draw loop, `push()`/`pop()`, offscreen buffers, composition patterns, `pixelDensity()`, responsive design |
|
||||
| `references/shapes-and-geometry.md` | 2D primitives, `beginShape()`/`endShape()`, Bezier/Catmull-Rom curves, `vertex()` systems, custom shapes, `p5.Vector`, signed distance fields, SVG path conversion |
|
||||
| `references/visual-effects.md` | Noise (Perlin, fractal, domain warp, curl), flow fields, particle systems (physics, flocking, trails), pixel manipulation, texture generation (stipple, hatch, halftone), feedback loops, reaction-diffusion |
|
||||
| `references/animation.md` | Frame-based animation, easing functions, `lerp()`/`map()`, spring physics, state machines, timeline sequencing, `millis()`-based timing, transition patterns |
|
||||
| `references/typography.md` | `text()`, `loadFont()`, `textToPoints()`, kinetic typography, text masks, font metrics, responsive text sizing |
|
||||
| `references/color-systems.md` | `colorMode()`, HSB/HSL/RGB, `lerpColor()`, `paletteLerp()`, procedural palettes, color harmony, `blendMode()`, gradient rendering, curated palette library |
|
||||
| `references/webgl-and-3d.md` | WEBGL renderer, 3D primitives, camera, lighting, materials, custom geometry, GLSL shaders (`createShader()`, `createFilterShader()`), framebuffers, post-processing |
|
||||
| `references/interaction.md` | Mouse events, keyboard state, touch input, DOM elements, `createSlider()`/`createButton()`, audio input (p5.sound FFT/amplitude), scroll-driven animation, responsive events |
|
||||
| `references/export-pipeline.md` | `saveCanvas()`, `saveGif()`, `saveFrames()`, deterministic headless capture, ffmpeg frame-to-video, CCapture.js, SVG export, per-clip architecture, platform export (fxhash), video gotchas |
|
||||
| `references/troubleshooting.md` | Performance profiling, per-pixel budgets, common mistakes, browser compatibility, WebGL debugging, font loading issues, pixel density traps, memory leaks, CORS |
|
||||
| `templates/viewer.html` | Interactive viewer template: seed navigation (prev/next/random/jump), parameter sliders, download PNG, responsive canvas. Start from this for explorable generative art |
|
||||
@@ -0,0 +1,439 @@
|
||||
# Animation
|
||||
|
||||
## Frame-Based Animation
|
||||
|
||||
### The Draw Loop
|
||||
|
||||
```javascript
|
||||
function draw() {
|
||||
// Called ~60 times/sec by default
|
||||
// frameCount — integer, starts at 1
|
||||
// deltaTime — ms since last frame (use for framerate-independent motion)
|
||||
// millis() — ms since sketch start
|
||||
}
|
||||
```
|
||||
|
||||
### Time-Based vs Frame-Based
|
||||
|
||||
```javascript
|
||||
// Frame-based (speed varies with framerate)
|
||||
x += speed;
|
||||
|
||||
// Time-based (consistent speed regardless of framerate)
|
||||
x += speed * (deltaTime / 16.67); // normalized to 60fps
|
||||
```
|
||||
|
||||
### Normalized Time
|
||||
|
||||
```javascript
|
||||
// Progress from 0 to 1 over N seconds
|
||||
let duration = 5000; // 5 seconds in ms
|
||||
let t = constrain(millis() / duration, 0, 1);
|
||||
|
||||
// Looping progress (0 → 1 → 0 → 1...)
|
||||
let period = 3000; // 3 second loop
|
||||
let t = (millis() % period) / period;
|
||||
|
||||
// Ping-pong (0 → 1 → 0 → 1...)
|
||||
let raw = (millis() % (period * 2)) / period;
|
||||
let t = raw <= 1 ? raw : 2 - raw;
|
||||
```
|
||||
|
||||
## Easing Functions
|
||||
|
||||
### Built-in Lerp
|
||||
|
||||
```javascript
|
||||
// Linear interpolation — smooth but mechanical
|
||||
let x = lerp(startX, endX, t);
|
||||
|
||||
// Map for non-0-1 ranges
|
||||
let y = map(t, 0, 1, startY, endY);
|
||||
```
|
||||
|
||||
### Common Easing Curves
|
||||
|
||||
```javascript
|
||||
// Ease in (slow start)
|
||||
function easeInQuad(t) { return t * t; }
|
||||
function easeInCubic(t) { return t * t * t; }
|
||||
function easeInExpo(t) { return t === 0 ? 0 : pow(2, 10 * (t - 1)); }
|
||||
|
||||
// Ease out (slow end)
|
||||
function easeOutQuad(t) { return 1 - (1 - t) * (1 - t); }
|
||||
function easeOutCubic(t) { return 1 - pow(1 - t, 3); }
|
||||
function easeOutExpo(t) { return t === 1 ? 1 : 1 - pow(2, -10 * t); }
|
||||
|
||||
// Ease in-out (slow both ends)
|
||||
function easeInOutCubic(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
function easeInOutQuint(t) {
|
||||
return t < 0.5 ? 16 * t * t * t * t * t : 1 - pow(-2 * t + 2, 5) / 2;
|
||||
}
|
||||
|
||||
// Elastic (spring overshoot)
|
||||
function easeOutElastic(t) {
|
||||
if (t === 0 || t === 1) return t;
|
||||
return pow(2, -10 * t) * sin((t * 10 - 0.75) * (2 * PI / 3)) + 1;
|
||||
}
|
||||
|
||||
// Bounce
|
||||
function easeOutBounce(t) {
|
||||
if (t < 1/2.75) return 7.5625 * t * t;
|
||||
else if (t < 2/2.75) { t -= 1.5/2.75; return 7.5625 * t * t + 0.75; }
|
||||
else if (t < 2.5/2.75) { t -= 2.25/2.75; return 7.5625 * t * t + 0.9375; }
|
||||
else { t -= 2.625/2.75; return 7.5625 * t * t + 0.984375; }
|
||||
}
|
||||
|
||||
// Smooth step (Hermite interpolation — great default)
|
||||
function smoothstep(t) { return t * t * (3 - 2 * t); }
|
||||
|
||||
// Smoother step (Ken Perlin)
|
||||
function smootherstep(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
|
||||
```
|
||||
|
||||
### Applying Easing
|
||||
|
||||
```javascript
|
||||
// Animate from startVal to endVal over duration ms
|
||||
function easedValue(startVal, endVal, startTime, duration, easeFn) {
|
||||
let t = constrain((millis() - startTime) / duration, 0, 1);
|
||||
return lerp(startVal, endVal, easeFn(t));
|
||||
}
|
||||
|
||||
// Usage
|
||||
let x = easedValue(100, 700, animStartTime, 2000, easeOutCubic);
|
||||
```
|
||||
|
||||
## Spring Physics
|
||||
|
||||
More natural than easing — responds to force, overshoots, settles.
|
||||
|
||||
```javascript
|
||||
class Spring {
|
||||
constructor(value, target, stiffness = 0.1, damping = 0.7) {
|
||||
this.value = value;
|
||||
this.target = target;
|
||||
this.velocity = 0;
|
||||
this.stiffness = stiffness;
|
||||
this.damping = damping;
|
||||
}
|
||||
|
||||
update() {
|
||||
let force = (this.target - this.value) * this.stiffness;
|
||||
this.velocity += force;
|
||||
this.velocity *= this.damping;
|
||||
this.value += this.velocity;
|
||||
return this.value;
|
||||
}
|
||||
|
||||
setTarget(t) { this.target = t; }
|
||||
isSettled(threshold = 0.01) {
|
||||
return abs(this.velocity) < threshold && abs(this.value - this.target) < threshold;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
let springX = new Spring(0, 0, 0.08, 0.85);
|
||||
function draw() {
|
||||
springX.setTarget(mouseX);
|
||||
let x = springX.update();
|
||||
ellipse(x, height/2, 50);
|
||||
}
|
||||
```
|
||||
|
||||
### 2D Spring
|
||||
|
||||
```javascript
|
||||
class Spring2D {
|
||||
constructor(x, y) {
|
||||
this.pos = createVector(x, y);
|
||||
this.target = createVector(x, y);
|
||||
this.vel = createVector(0, 0);
|
||||
this.stiffness = 0.08;
|
||||
this.damping = 0.85;
|
||||
}
|
||||
|
||||
update() {
|
||||
let force = p5.Vector.sub(this.target, this.pos).mult(this.stiffness);
|
||||
this.vel.add(force).mult(this.damping);
|
||||
this.pos.add(this.vel);
|
||||
return this.pos;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## State Machines
|
||||
|
||||
For complex multi-phase animations.
|
||||
|
||||
```javascript
|
||||
const STATES = { IDLE: 0, ENTER: 1, ACTIVE: 2, EXIT: 3 };
|
||||
let state = STATES.IDLE;
|
||||
let stateStart = 0;
|
||||
|
||||
function setState(newState) {
|
||||
state = newState;
|
||||
stateStart = millis();
|
||||
}
|
||||
|
||||
function stateTime() {
|
||||
return millis() - stateStart;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
switch (state) {
|
||||
case STATES.IDLE:
|
||||
// waiting...
|
||||
break;
|
||||
case STATES.ENTER:
|
||||
let t = constrain(stateTime() / 1000, 0, 1);
|
||||
let alpha = easeOutCubic(t) * 255;
|
||||
// fade in...
|
||||
if (t >= 1) setState(STATES.ACTIVE);
|
||||
break;
|
||||
case STATES.ACTIVE:
|
||||
// main animation...
|
||||
break;
|
||||
case STATES.EXIT:
|
||||
let t2 = constrain(stateTime() / 500, 0, 1);
|
||||
// fade out...
|
||||
if (t2 >= 1) setState(STATES.IDLE);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Timeline Sequencing
|
||||
|
||||
For timed multi-scene animations (motion graphics, title sequences).
|
||||
|
||||
```javascript
|
||||
class Timeline {
|
||||
constructor() {
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
at(timeMs, duration, fn) {
|
||||
this.events.push({ start: timeMs, end: timeMs + duration, fn });
|
||||
return this;
|
||||
}
|
||||
|
||||
update() {
|
||||
let now = millis();
|
||||
for (let e of this.events) {
|
||||
if (now >= e.start && now < e.end) {
|
||||
let t = (now - e.start) / (e.end - e.start);
|
||||
e.fn(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
let timeline = new Timeline();
|
||||
timeline
|
||||
.at(0, 2000, (t) => {
|
||||
// Scene 1: title fade in (0-2s)
|
||||
let alpha = easeOutCubic(t) * 255;
|
||||
fill(255, alpha);
|
||||
textSize(48);
|
||||
text("Hello", width/2, height/2);
|
||||
})
|
||||
.at(2000, 1000, (t) => {
|
||||
// Scene 2: title fade out (2-3s)
|
||||
let alpha = (1 - easeInCubic(t)) * 255;
|
||||
fill(255, alpha);
|
||||
textSize(48);
|
||||
text("Hello", width/2, height/2);
|
||||
})
|
||||
.at(3000, 5000, (t) => {
|
||||
// Scene 3: main content (3-8s)
|
||||
renderMainContent(t);
|
||||
});
|
||||
|
||||
function draw() {
|
||||
background(0);
|
||||
timeline.update();
|
||||
}
|
||||
```
|
||||
|
||||
## Noise-Driven Motion
|
||||
|
||||
More organic than deterministic animation.
|
||||
|
||||
```javascript
|
||||
// Smooth wandering position
|
||||
let x = map(noise(frameCount * 0.005, 0), 0, 1, 0, width);
|
||||
let y = map(noise(0, frameCount * 0.005), 0, 1, 0, height);
|
||||
|
||||
// Noise-driven rotation
|
||||
let angle = noise(frameCount * 0.01) * TWO_PI;
|
||||
|
||||
// Noise-driven scale (breathing effect)
|
||||
let s = map(noise(frameCount * 0.02), 0, 1, 0.8, 1.2);
|
||||
|
||||
// Noise-driven color shift
|
||||
let hue = map(noise(frameCount * 0.003), 0, 1, 0, 360);
|
||||
```
|
||||
|
||||
## Transition Patterns
|
||||
|
||||
### Fade In/Out
|
||||
|
||||
```javascript
|
||||
function fadeIn(t) { return constrain(t, 0, 1); }
|
||||
function fadeOut(t) { return constrain(1 - t, 0, 1); }
|
||||
```
|
||||
|
||||
### Slide
|
||||
|
||||
```javascript
|
||||
function slideIn(t, direction = 'left') {
|
||||
let et = easeOutCubic(t);
|
||||
switch (direction) {
|
||||
case 'left': return lerp(-width, 0, et);
|
||||
case 'right': return lerp(width, 0, et);
|
||||
case 'up': return lerp(-height, 0, et);
|
||||
case 'down': return lerp(height, 0, et);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scale Reveal
|
||||
|
||||
```javascript
|
||||
function scaleReveal(t) {
|
||||
let et = easeOutElastic(constrain(t, 0, 1));
|
||||
push();
|
||||
translate(width/2, height/2);
|
||||
scale(et);
|
||||
translate(-width/2, -height/2);
|
||||
// draw content...
|
||||
pop();
|
||||
}
|
||||
```
|
||||
|
||||
### Staggered Entry
|
||||
|
||||
```javascript
|
||||
// N elements appear one after another
|
||||
let staggerDelay = 100; // ms between each
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
let itemStart = baseTime + i * staggerDelay;
|
||||
let t = constrain((millis() - itemStart) / 500, 0, 1);
|
||||
let alpha = easeOutCubic(t) * 255;
|
||||
let yOffset = lerp(30, 0, easeOutCubic(t));
|
||||
// draw element with alpha and yOffset
|
||||
}
|
||||
```
|
||||
|
||||
## Recording Deterministic Animations
|
||||
|
||||
For frame-perfect export, use frame count instead of millis():
|
||||
|
||||
```javascript
|
||||
const TOTAL_FRAMES = 300; // 10 seconds at 30fps
|
||||
const FPS = 30;
|
||||
|
||||
function draw() {
|
||||
let t = frameCount / TOTAL_FRAMES; // 0 to 1 over full duration
|
||||
if (t > 1) { noLoop(); return; }
|
||||
|
||||
// Use t for all animation timing — deterministic
|
||||
renderFrame(t);
|
||||
|
||||
// Export
|
||||
if (CONFIG.recording) {
|
||||
saveCanvas('frame-' + nf(frameCount, 4), 'png');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Scene Fade Envelopes (Video)
|
||||
|
||||
Every scene in a multi-scene video needs fade-in and fade-out. Hard cuts between visually different generative scenes are jarring.
|
||||
|
||||
```javascript
|
||||
const SCENE_FRAMES = 150; // 5 seconds at 30fps
|
||||
const FADE = 15; // half-second fade
|
||||
|
||||
function draw() {
|
||||
let lf = frameCount - 1; // 0-indexed local frame
|
||||
let t = lf / SCENE_FRAMES; // 0..1 normalized progress
|
||||
|
||||
// Fade envelope: ramp up at start, ramp down at end
|
||||
let fade = 1;
|
||||
if (lf < FADE) fade = lf / FADE;
|
||||
if (lf > SCENE_FRAMES - FADE) fade = (SCENE_FRAMES - lf) / FADE;
|
||||
fade = fade * fade * (3 - 2 * fade); // smoothstep for organic feel
|
||||
|
||||
// Apply fade to all visual output
|
||||
// Option 1: multiply alpha values by fade
|
||||
fill(r, g, b, alpha * fade);
|
||||
|
||||
// Option 2: tint entire composited image
|
||||
tint(255, fade * 255);
|
||||
image(sceneBuffer, 0, 0);
|
||||
noTint();
|
||||
|
||||
// Option 3: multiply pixel brightness (for pixel-level scenes)
|
||||
pixels[i] = r * fade;
|
||||
}
|
||||
```
|
||||
|
||||
## Animating Static Algorithms
|
||||
|
||||
Some generative algorithms produce a single static result (attractors, circle packing, Voronoi). In video, static content reads as frozen/broken. Techniques to add motion:
|
||||
|
||||
### Progressive Reveal
|
||||
|
||||
Expand a mask from center outward to reveal the precomputed result:
|
||||
|
||||
```javascript
|
||||
let revealRadius = easeOutCubic(min(t * 1.5, 1)) * (width * 0.8);
|
||||
// In the render loop, skip pixels beyond revealRadius from center
|
||||
let dx = x - width/2, dy = y - height/2;
|
||||
if (sqrt(dx*dx + dy*dy) > revealRadius) continue;
|
||||
// Soft edge:
|
||||
let edgeFade = constrain((revealRadius - dist) / 40, 0, 1);
|
||||
```
|
||||
|
||||
### Parameter Sweep
|
||||
|
||||
Slowly change a parameter to show the algorithm evolving:
|
||||
|
||||
```javascript
|
||||
// Attractor with drifting parameters
|
||||
let a = -1.7 + sin(t * 0.5) * 0.2; // oscillate around base value
|
||||
let b = 1.3 + cos(t * 0.3) * 0.15;
|
||||
```
|
||||
|
||||
### Slow Camera Motion
|
||||
|
||||
Apply subtle zoom or rotation to the final image:
|
||||
|
||||
```javascript
|
||||
push();
|
||||
translate(width/2, height/2);
|
||||
scale(1 + t * 0.05); // slow 5% zoom over scene duration
|
||||
rotate(t * 0.1); // gentle rotation
|
||||
translate(-width/2, -height/2);
|
||||
image(precomputedResult, 0, 0);
|
||||
pop();
|
||||
```
|
||||
|
||||
### Overlay Dynamic Elements
|
||||
|
||||
Add particles, grain, or subtle noise on top of static content:
|
||||
|
||||
```javascript
|
||||
// Static background
|
||||
image(staticResult, 0, 0);
|
||||
// Dynamic overlay
|
||||
for (let p of ambientParticles) {
|
||||
p.update();
|
||||
p.display(); // slow-moving specks add life
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,352 @@
|
||||
# Color Systems
|
||||
|
||||
## Color Modes
|
||||
|
||||
### HSB (Recommended for Generative Art)
|
||||
|
||||
```javascript
|
||||
colorMode(HSB, 360, 100, 100, 100);
|
||||
// Hue: 0-360 (color wheel position)
|
||||
// Saturation: 0-100 (gray to vivid)
|
||||
// Brightness: 0-100 (black to full)
|
||||
// Alpha: 0-100
|
||||
|
||||
fill(200, 80, 90); // blue, vivid, bright
|
||||
fill(200, 80, 90, 50); // 50% transparent
|
||||
```
|
||||
|
||||
HSB advantages:
|
||||
- Rotate hue: `(baseHue + offset) % 360`
|
||||
- Desaturate: reduce S
|
||||
- Darken: reduce B
|
||||
- Monochrome variations: fix H, vary S and B
|
||||
- Complementary: `(hue + 180) % 360`
|
||||
- Analogous: `hue +/- 30`
|
||||
|
||||
### HSL
|
||||
|
||||
```javascript
|
||||
colorMode(HSL, 360, 100, 100, 100);
|
||||
// Lightness 50 = pure color, 0 = black, 100 = white
|
||||
// More intuitive for tints (L > 50) and shades (L < 50)
|
||||
```
|
||||
|
||||
### RGB
|
||||
|
||||
```javascript
|
||||
colorMode(RGB, 255, 255, 255, 255); // default
|
||||
// Direct channel control, less intuitive for procedural palettes
|
||||
```
|
||||
|
||||
## Color Objects
|
||||
|
||||
```javascript
|
||||
let c = color(200, 80, 90); // create color object
|
||||
fill(c);
|
||||
|
||||
// Extract components
|
||||
let h = hue(c);
|
||||
let s = saturation(c);
|
||||
let b = brightness(c);
|
||||
let r = red(c);
|
||||
let g = green(c);
|
||||
let bl = blue(c);
|
||||
let a = alpha(c);
|
||||
|
||||
// Hex colors work everywhere
|
||||
fill('#e8d5b7');
|
||||
fill('#e8d5b7cc'); // with alpha
|
||||
|
||||
// Modify via setters
|
||||
c.setAlpha(128);
|
||||
c.setRed(200);
|
||||
```
|
||||
|
||||
## Color Interpolation
|
||||
|
||||
### lerpColor
|
||||
|
||||
```javascript
|
||||
let c1 = color(0, 80, 100); // red
|
||||
let c2 = color(200, 80, 100); // blue
|
||||
let mixed = lerpColor(c1, c2, 0.5); // midpoint blend
|
||||
// Works in current colorMode
|
||||
```
|
||||
|
||||
### paletteLerp (p5.js 1.11+)
|
||||
|
||||
Interpolate through multiple colors at once.
|
||||
|
||||
```javascript
|
||||
let colors = [
|
||||
color('#2E0854'),
|
||||
color('#850E35'),
|
||||
color('#EE6C4D'),
|
||||
color('#F5E663')
|
||||
];
|
||||
let c = paletteLerp(colors, t); // t = 0..1, interpolates through all
|
||||
```
|
||||
|
||||
### Manual Multi-Stop Gradient
|
||||
|
||||
```javascript
|
||||
function multiLerp(colors, t) {
|
||||
t = constrain(t, 0, 1);
|
||||
let segment = t * (colors.length - 1);
|
||||
let idx = floor(segment);
|
||||
let frac = segment - idx;
|
||||
idx = min(idx, colors.length - 2);
|
||||
return lerpColor(colors[idx], colors[idx + 1], frac);
|
||||
}
|
||||
```
|
||||
|
||||
## Gradient Rendering
|
||||
|
||||
### Linear Gradient
|
||||
|
||||
```javascript
|
||||
function linearGradient(x1, y1, x2, y2, c1, c2) {
|
||||
let steps = dist(x1, y1, x2, y2);
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
let t = i / steps;
|
||||
let c = lerpColor(c1, c2, t);
|
||||
stroke(c);
|
||||
let x = lerp(x1, x2, t);
|
||||
let y = lerp(y1, y2, t);
|
||||
// Draw perpendicular line at each point
|
||||
let dx = -(y2 - y1) / steps * 1000;
|
||||
let dy = (x2 - x1) / steps * 1000;
|
||||
line(x - dx, y - dy, x + dx, y + dy);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Radial Gradient
|
||||
|
||||
```javascript
|
||||
function radialGradient(cx, cy, r, innerColor, outerColor) {
|
||||
noStroke();
|
||||
for (let i = r; i > 0; i--) {
|
||||
let t = 1 - i / r;
|
||||
fill(lerpColor(innerColor, outerColor, t));
|
||||
ellipse(cx, cy, i * 2);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Noise-Based Gradient
|
||||
|
||||
```javascript
|
||||
function noiseGradient(colors, noiseScale, time) {
|
||||
loadPixels();
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let n = noise(x * noiseScale, y * noiseScale, time);
|
||||
let c = multiLerp(colors, n);
|
||||
let idx = 4 * (y * width + x);
|
||||
pixels[idx] = red(c);
|
||||
pixels[idx+1] = green(c);
|
||||
pixels[idx+2] = blue(c);
|
||||
pixels[idx+3] = 255;
|
||||
}
|
||||
}
|
||||
updatePixels();
|
||||
}
|
||||
```
|
||||
|
||||
## Procedural Palette Generation
|
||||
|
||||
### Complementary
|
||||
|
||||
```javascript
|
||||
function complementary(baseHue) {
|
||||
return [baseHue, (baseHue + 180) % 360];
|
||||
}
|
||||
```
|
||||
|
||||
### Analogous
|
||||
|
||||
```javascript
|
||||
function analogous(baseHue, spread = 30) {
|
||||
return [
|
||||
(baseHue - spread + 360) % 360,
|
||||
baseHue,
|
||||
(baseHue + spread) % 360
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Triadic
|
||||
|
||||
```javascript
|
||||
function triadic(baseHue) {
|
||||
return [baseHue, (baseHue + 120) % 360, (baseHue + 240) % 360];
|
||||
}
|
||||
```
|
||||
|
||||
### Split Complementary
|
||||
|
||||
```javascript
|
||||
function splitComplementary(baseHue) {
|
||||
return [baseHue, (baseHue + 150) % 360, (baseHue + 210) % 360];
|
||||
}
|
||||
```
|
||||
|
||||
### Tetradic (Rectangle)
|
||||
|
||||
```javascript
|
||||
function tetradic(baseHue) {
|
||||
return [baseHue, (baseHue + 60) % 360, (baseHue + 180) % 360, (baseHue + 240) % 360];
|
||||
}
|
||||
```
|
||||
|
||||
### Monochromatic Variations
|
||||
|
||||
```javascript
|
||||
function monoVariations(hue, count = 5) {
|
||||
let colors = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
let s = map(i, 0, count - 1, 20, 90);
|
||||
let b = map(i, 0, count - 1, 95, 40);
|
||||
colors.push(color(hue, s, b));
|
||||
}
|
||||
return colors;
|
||||
}
|
||||
```
|
||||
|
||||
## Curated Palette Library
|
||||
|
||||
### Warm Palettes
|
||||
|
||||
```javascript
|
||||
const SUNSET = ['#2E0854', '#850E35', '#EE6C4D', '#F5E663'];
|
||||
const EMBER = ['#1a0000', '#4a0000', '#8b2500', '#cd5c00', '#ffd700'];
|
||||
const PEACH = ['#fff5eb', '#ffdab9', '#ff9a76', '#ff6b6b', '#c94c4c'];
|
||||
const COPPER = ['#1c1108', '#3d2b1f', '#7b4b2a', '#b87333', '#daa06d'];
|
||||
```
|
||||
|
||||
### Cool Palettes
|
||||
|
||||
```javascript
|
||||
const OCEAN = ['#0a0e27', '#1a1b4b', '#2a4a7f', '#3d7cb8', '#87ceeb'];
|
||||
const ARCTIC = ['#0d1b2a', '#1b263b', '#415a77', '#778da9', '#e0e1dd'];
|
||||
const FOREST = ['#0b1a0b', '#1a3a1a', '#2d5a2d', '#4a8c4a', '#90c990'];
|
||||
const DEEP_SEA = ['#000814', '#001d3d', '#003566', '#006d77', '#83c5be'];
|
||||
```
|
||||
|
||||
### Neutral Palettes
|
||||
|
||||
```javascript
|
||||
const GRAPHITE = ['#1a1a1a', '#333333', '#555555', '#888888', '#cccccc'];
|
||||
const CREAM = ['#f4f0e8', '#e8dcc8', '#c9b99a', '#a89070', '#7a6450'];
|
||||
const SLATE = ['#1e293b', '#334155', '#475569', '#64748b', '#94a3b8'];
|
||||
```
|
||||
|
||||
### Vivid Palettes
|
||||
|
||||
```javascript
|
||||
const NEON = ['#ff00ff', '#00ffff', '#ff0080', '#80ff00', '#0080ff'];
|
||||
const RAINBOW = ['#ff0000', '#ff8000', '#ffff00', '#00ff00', '#0000ff', '#8000ff'];
|
||||
const VAPOR = ['#ff71ce', '#01cdfe', '#05ffa1', '#b967ff', '#fffb96'];
|
||||
const CYBER = ['#0f0f0f', '#00ff41', '#ff0090', '#00d4ff', '#ffd000'];
|
||||
```
|
||||
|
||||
### Earth Tones
|
||||
|
||||
```javascript
|
||||
const TERRA = ['#2c1810', '#5c3a2a', '#8b6b4a', '#c4a672', '#e8d5b7'];
|
||||
const MOSS = ['#1a1f16', '#3d4a2e', '#6b7c4f', '#9aab7a', '#c8d4a9'];
|
||||
const CLAY = ['#3b2f2f', '#6b4c4c', '#9e7676', '#c9a0a0', '#e8caca'];
|
||||
```
|
||||
|
||||
## Blend Modes
|
||||
|
||||
```javascript
|
||||
blendMode(BLEND); // default — alpha compositing
|
||||
blendMode(ADD); // additive — bright glow effects
|
||||
blendMode(MULTIPLY); // darkening — shadows, texture overlay
|
||||
blendMode(SCREEN); // lightening — soft glow
|
||||
blendMode(OVERLAY); // contrast boost — high/low emphasis
|
||||
blendMode(DIFFERENCE); // color subtraction — psychedelic
|
||||
blendMode(EXCLUSION); // softer difference
|
||||
blendMode(REPLACE); // overwrite (no alpha blending)
|
||||
blendMode(REMOVE); // subtract alpha
|
||||
blendMode(LIGHTEST); // keep brighter pixel
|
||||
blendMode(DARKEST); // keep darker pixel
|
||||
blendMode(BURN); // darken + saturate
|
||||
blendMode(DODGE); // lighten + saturate
|
||||
blendMode(SOFT_LIGHT); // subtle overlay
|
||||
blendMode(HARD_LIGHT); // strong overlay
|
||||
|
||||
// ALWAYS reset after use
|
||||
blendMode(BLEND);
|
||||
```
|
||||
|
||||
### Blend Mode Recipes
|
||||
|
||||
| Effect | Mode | Use case |
|
||||
|--------|------|----------|
|
||||
| Additive glow | `ADD` | Light beams, fire, particles |
|
||||
| Shadow overlay | `MULTIPLY` | Texture, vignette |
|
||||
| Soft light mix | `SCREEN` | Fog, mist, backlight |
|
||||
| High contrast | `OVERLAY` | Dramatic compositing |
|
||||
| Color negative | `DIFFERENCE` | Glitch, psychedelic |
|
||||
| Layer compositing | `BLEND` | Standard alpha layering |
|
||||
|
||||
## Background Techniques
|
||||
|
||||
### Textured Background
|
||||
|
||||
```javascript
|
||||
function texturedBackground(baseColor, noiseScale, noiseAmount) {
|
||||
loadPixels();
|
||||
let r = red(baseColor), g = green(baseColor), b = blue(baseColor);
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
let x = (i / 4) % width;
|
||||
let y = floor((i / 4) / width);
|
||||
let n = (noise(x * noiseScale, y * noiseScale) - 0.5) * noiseAmount;
|
||||
pixels[i] = constrain(r + n, 0, 255);
|
||||
pixels[i+1] = constrain(g + n, 0, 255);
|
||||
pixels[i+2] = constrain(b + n, 0, 255);
|
||||
pixels[i+3] = 255;
|
||||
}
|
||||
updatePixels();
|
||||
}
|
||||
```
|
||||
|
||||
### Vignette
|
||||
|
||||
```javascript
|
||||
function vignette(strength = 0.5, radius = 0.7) {
|
||||
loadPixels();
|
||||
let cx = width / 2, cy = height / 2;
|
||||
let maxDist = dist(0, 0, cx, cy);
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
let x = (i / 4) % width;
|
||||
let y = floor((i / 4) / width);
|
||||
let d = dist(x, y, cx, cy) / maxDist;
|
||||
let factor = 1.0 - smoothstep(constrain((d - radius) / (1 - radius), 0, 1)) * strength;
|
||||
pixels[i] *= factor;
|
||||
pixels[i+1] *= factor;
|
||||
pixels[i+2] *= factor;
|
||||
}
|
||||
updatePixels();
|
||||
}
|
||||
|
||||
function smoothstep(t) { return t * t * (3 - 2 * t); }
|
||||
```
|
||||
|
||||
### Film Grain
|
||||
|
||||
```javascript
|
||||
function filmGrain(amount = 30) {
|
||||
loadPixels();
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
let grain = random(-amount, amount);
|
||||
pixels[i] = constrain(pixels[i] + grain, 0, 255);
|
||||
pixels[i+1] = constrain(pixels[i+1] + grain, 0, 255);
|
||||
pixels[i+2] = constrain(pixels[i+2] + grain, 0, 255);
|
||||
}
|
||||
updatePixels();
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,410 @@
|
||||
# Core API Reference
|
||||
|
||||
## Canvas Setup
|
||||
|
||||
### createCanvas()
|
||||
|
||||
```javascript
|
||||
// 2D (default renderer)
|
||||
createCanvas(1920, 1080);
|
||||
|
||||
// WebGL (3D, shaders)
|
||||
createCanvas(1920, 1080, WEBGL);
|
||||
|
||||
// Responsive
|
||||
createCanvas(windowWidth, windowHeight);
|
||||
```
|
||||
|
||||
### Pixel Density
|
||||
|
||||
High-DPI displays render at 2x by default. This doubles memory usage and halves performance.
|
||||
|
||||
```javascript
|
||||
// Force 1x for consistent export and performance
|
||||
pixelDensity(1);
|
||||
|
||||
// Match display (default) — sharp on retina but expensive
|
||||
pixelDensity(displayDensity());
|
||||
|
||||
// ALWAYS call before createCanvas()
|
||||
function setup() {
|
||||
pixelDensity(1); // first
|
||||
createCanvas(1920, 1080); // second
|
||||
}
|
||||
```
|
||||
|
||||
For export, always `pixelDensity(1)` and use the exact target resolution. Never rely on device scaling for final output.
|
||||
|
||||
### Responsive Resize
|
||||
|
||||
```javascript
|
||||
function windowResized() {
|
||||
resizeCanvas(windowWidth, windowHeight);
|
||||
// Recreate offscreen buffers at new size
|
||||
bgLayer = createGraphics(width, height);
|
||||
// Reinitialize any size-dependent state
|
||||
}
|
||||
```
|
||||
|
||||
## Coordinate System
|
||||
|
||||
### P2D (Default)
|
||||
- Origin: top-left (0, 0)
|
||||
- X increases rightward
|
||||
- Y increases downward
|
||||
- Angles: radians by default, `angleMode(DEGREES)` to switch
|
||||
|
||||
### WEBGL
|
||||
- Origin: center of canvas
|
||||
- X increases rightward, Y increases **upward**, Z increases toward viewer
|
||||
- To get P2D-like coordinates in WEBGL: `translate(-width/2, -height/2)`
|
||||
|
||||
## Draw Loop
|
||||
|
||||
```javascript
|
||||
function preload() {
|
||||
// Load assets before setup — fonts, images, JSON, CSV
|
||||
// Blocks execution until all loads complete
|
||||
font = loadFont('font.otf');
|
||||
img = loadImage('texture.png');
|
||||
data = loadJSON('data.json');
|
||||
}
|
||||
|
||||
function setup() {
|
||||
// Runs once. Create canvas, initialize state.
|
||||
createCanvas(1920, 1080);
|
||||
colorMode(HSB, 360, 100, 100, 100);
|
||||
randomSeed(CONFIG.seed);
|
||||
noiseSeed(CONFIG.seed);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Runs every frame (default 60fps).
|
||||
// Set frameRate(30) in setup() to change.
|
||||
// Call noLoop() for static sketches (render once).
|
||||
}
|
||||
```
|
||||
|
||||
### Frame Control
|
||||
|
||||
```javascript
|
||||
frameRate(30); // set target FPS
|
||||
noLoop(); // stop draw loop (static pieces)
|
||||
loop(); // restart draw loop
|
||||
redraw(); // call draw() once (manual refresh)
|
||||
frameCount // frames since start (integer)
|
||||
deltaTime // milliseconds since last frame (float)
|
||||
millis() // milliseconds since sketch started
|
||||
```
|
||||
|
||||
## Transform Stack
|
||||
|
||||
Every transform is cumulative. Use `push()`/`pop()` to isolate.
|
||||
|
||||
```javascript
|
||||
push();
|
||||
translate(width / 2, height / 2);
|
||||
rotate(angle);
|
||||
scale(1.5);
|
||||
// draw something at transformed position
|
||||
ellipse(0, 0, 100, 100);
|
||||
pop();
|
||||
// back to original coordinate system
|
||||
```
|
||||
|
||||
### Transform Functions
|
||||
|
||||
| Function | Effect |
|
||||
|----------|--------|
|
||||
| `translate(x, y)` | Move origin |
|
||||
| `rotate(angle)` | Rotate around origin (radians) |
|
||||
| `scale(s)` / `scale(sx, sy)` | Scale from origin |
|
||||
| `shearX(angle)` | Skew X axis |
|
||||
| `shearY(angle)` | Skew Y axis |
|
||||
| `applyMatrix(a, b, c, d, e, f)` | Arbitrary 2D affine transform |
|
||||
| `resetMatrix()` | Clear all transforms |
|
||||
|
||||
### Composition Pattern: Rotate Around Center
|
||||
|
||||
```javascript
|
||||
push();
|
||||
translate(cx, cy); // move origin to center
|
||||
rotate(angle); // rotate around that center
|
||||
translate(-cx, -cy); // move origin back
|
||||
// draw at original coordinates, but rotated around (cx, cy)
|
||||
rect(cx - 50, cy - 50, 100, 100);
|
||||
pop();
|
||||
```
|
||||
|
||||
## Offscreen Buffers (createGraphics)
|
||||
|
||||
Offscreen buffers are separate canvases you can draw to and composite. Essential for:
|
||||
- **Layered composition** — background, midground, foreground
|
||||
- **Persistent trails** — draw to buffer, fade with semi-transparent rect, never clear
|
||||
- **Masking** — draw mask to buffer, apply with `image()` or pixel operations
|
||||
- **Post-processing** — render scene to buffer, apply effects, draw to main canvas
|
||||
|
||||
```javascript
|
||||
let layer;
|
||||
|
||||
function setup() {
|
||||
createCanvas(1920, 1080);
|
||||
layer = createGraphics(width, height);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Draw to offscreen buffer
|
||||
layer.background(0, 10); // semi-transparent clear = trails
|
||||
layer.fill(255);
|
||||
layer.ellipse(mouseX, mouseY, 20);
|
||||
|
||||
// Composite to main canvas
|
||||
image(layer, 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
### Trail Effect Pattern
|
||||
|
||||
```javascript
|
||||
let trailBuffer;
|
||||
|
||||
function setup() {
|
||||
createCanvas(1920, 1080);
|
||||
trailBuffer = createGraphics(width, height);
|
||||
trailBuffer.background(0);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Fade previous frame (lower alpha = longer trails)
|
||||
trailBuffer.noStroke();
|
||||
trailBuffer.fill(0, 0, 0, 15); // RGBA — 15/255 alpha
|
||||
trailBuffer.rect(0, 0, width, height);
|
||||
|
||||
// Draw new content
|
||||
trailBuffer.fill(255);
|
||||
trailBuffer.ellipse(mouseX, mouseY, 10);
|
||||
|
||||
// Show
|
||||
image(trailBuffer, 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Layer Composition
|
||||
|
||||
```javascript
|
||||
let bgLayer, contentLayer, fxLayer;
|
||||
|
||||
function setup() {
|
||||
createCanvas(1920, 1080);
|
||||
bgLayer = createGraphics(width, height);
|
||||
contentLayer = createGraphics(width, height);
|
||||
fxLayer = createGraphics(width, height);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Background — drawn once or slowly evolving
|
||||
renderBackground(bgLayer);
|
||||
|
||||
// Content — main visual elements
|
||||
contentLayer.clear();
|
||||
renderContent(contentLayer);
|
||||
|
||||
// FX — overlays, vignettes, grain
|
||||
fxLayer.clear();
|
||||
renderEffects(fxLayer);
|
||||
|
||||
// Composite with blend modes
|
||||
image(bgLayer, 0, 0);
|
||||
blendMode(ADD);
|
||||
image(contentLayer, 0, 0);
|
||||
blendMode(MULTIPLY);
|
||||
image(fxLayer, 0, 0);
|
||||
blendMode(BLEND); // reset
|
||||
}
|
||||
```
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
### Grid Layout
|
||||
|
||||
```javascript
|
||||
let cols = 10, rows = 10;
|
||||
let cellW = width / cols;
|
||||
let cellH = height / rows;
|
||||
for (let i = 0; i < cols; i++) {
|
||||
for (let j = 0; j < rows; j++) {
|
||||
let cx = cellW * (i + 0.5);
|
||||
let cy = cellH * (j + 0.5);
|
||||
// draw element at (cx, cy) within cell size (cellW, cellH)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Radial Layout
|
||||
|
||||
```javascript
|
||||
let n = 12;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let angle = TWO_PI * i / n;
|
||||
let r = 300;
|
||||
let x = width/2 + cos(angle) * r;
|
||||
let y = height/2 + sin(angle) * r;
|
||||
// draw element at (x, y)
|
||||
}
|
||||
```
|
||||
|
||||
### Golden Ratio Spiral
|
||||
|
||||
```javascript
|
||||
let phi = (1 + sqrt(5)) / 2;
|
||||
let n = 500;
|
||||
for (let i = 0; i < n; i++) {
|
||||
let angle = i * TWO_PI / (phi * phi);
|
||||
let r = sqrt(i) * 10;
|
||||
let x = width/2 + cos(angle) * r;
|
||||
let y = height/2 + sin(angle) * r;
|
||||
let size = map(i, 0, n, 8, 2);
|
||||
ellipse(x, y, size);
|
||||
}
|
||||
```
|
||||
|
||||
### Margin-Aware Composition
|
||||
|
||||
```javascript
|
||||
const MARGIN = 80; // pixels from edge
|
||||
const drawW = width - 2 * MARGIN;
|
||||
const drawH = height - 2 * MARGIN;
|
||||
|
||||
// Map normalized [0,1] coordinates to drawable area
|
||||
function mapX(t) { return MARGIN + t * drawW; }
|
||||
function mapY(t) { return MARGIN + t * drawH; }
|
||||
```
|
||||
|
||||
## Random and Noise
|
||||
|
||||
### Seeded Random
|
||||
|
||||
```javascript
|
||||
randomSeed(42);
|
||||
let x = random(100); // always same value for seed 42
|
||||
let y = random(-1, 1); // range
|
||||
let item = random(myArray); // random element
|
||||
```
|
||||
|
||||
### Gaussian Random
|
||||
|
||||
```javascript
|
||||
let x = randomGaussian(0, 1); // mean=0, stddev=1
|
||||
// Useful for natural-looking distributions
|
||||
```
|
||||
|
||||
### Perlin Noise
|
||||
|
||||
```javascript
|
||||
noiseSeed(42);
|
||||
noiseDetail(4, 0.5); // 4 octaves, 0.5 falloff
|
||||
|
||||
let v = noise(x * 0.01, y * 0.01); // returns 0.0 to 1.0
|
||||
// Scale factor (0.01) controls feature size — smaller = smoother
|
||||
```
|
||||
|
||||
## Math Utilities
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `map(v, lo1, hi1, lo2, hi2)` | Remap value between ranges |
|
||||
| `constrain(v, lo, hi)` | Clamp to range |
|
||||
| `lerp(a, b, t)` | Linear interpolation |
|
||||
| `norm(v, lo, hi)` | Normalize to 0-1 |
|
||||
| `dist(x1, y1, x2, y2)` | Euclidean distance |
|
||||
| `mag(x, y)` | Vector magnitude |
|
||||
| `abs()`, `ceil()`, `floor()`, `round()` | Standard math |
|
||||
| `sq(n)`, `sqrt(n)`, `pow(b, e)` | Powers |
|
||||
| `sin()`, `cos()`, `tan()`, `atan2()` | Trig (radians) |
|
||||
| `degrees(r)`, `radians(d)` | Angle conversion |
|
||||
| `fract(n)` | Fractional part |
|
||||
|
||||
## p5.js 2.0 Changes
|
||||
|
||||
p5.js 2.0 (released Apr 2025, current: 2.2) introduces breaking changes. The p5.js editor defaults to 1.x until Aug 2026. Use 2.x only when you need its features.
|
||||
|
||||
### async setup() replaces preload()
|
||||
|
||||
```javascript
|
||||
// p5.js 1.x
|
||||
let img;
|
||||
function preload() { img = loadImage('cat.jpg'); }
|
||||
function setup() { createCanvas(800, 800); }
|
||||
|
||||
// p5.js 2.x
|
||||
let img;
|
||||
async function setup() {
|
||||
createCanvas(800, 800);
|
||||
img = await loadImage('cat.jpg');
|
||||
}
|
||||
```
|
||||
|
||||
### New Color Modes
|
||||
|
||||
```javascript
|
||||
colorMode(OKLCH); // perceptually uniform — better gradients
|
||||
// L: 0-1 (lightness), C: 0-0.4 (chroma), H: 0-360 (hue)
|
||||
fill(0.7, 0.15, 200); // medium-bright saturated blue
|
||||
|
||||
colorMode(OKLAB); // perceptually uniform, no hue angle
|
||||
colorMode(HWB); // Hue-Whiteness-Blackness
|
||||
```
|
||||
|
||||
### splineVertex() replaces curveVertex()
|
||||
|
||||
No more doubling first/last control points:
|
||||
|
||||
```javascript
|
||||
// p5.js 1.x — must repeat first and last
|
||||
beginShape();
|
||||
curveVertex(pts[0].x, pts[0].y); // doubled
|
||||
for (let p of pts) curveVertex(p.x, p.y);
|
||||
curveVertex(pts[pts.length-1].x, pts[pts.length-1].y); // doubled
|
||||
endShape();
|
||||
|
||||
// p5.js 2.x — clean
|
||||
beginShape();
|
||||
for (let p of pts) splineVertex(p.x, p.y);
|
||||
endShape();
|
||||
```
|
||||
|
||||
### Shader .modify() API
|
||||
|
||||
Modify built-in shaders without writing full GLSL:
|
||||
|
||||
```javascript
|
||||
let myShader = baseMaterialShader().modify({
|
||||
vertexDeclarations: 'uniform float uTime;',
|
||||
'vec4 getWorldPosition': `(vec4 pos) {
|
||||
pos.y += sin(pos.x * 0.1 + uTime) * 20.0;
|
||||
return pos;
|
||||
}`
|
||||
});
|
||||
```
|
||||
|
||||
### Variable Fonts
|
||||
|
||||
```javascript
|
||||
textWeight(700); // dynamic weight without loading multiple files
|
||||
```
|
||||
|
||||
### textToContours() and textToModel()
|
||||
|
||||
```javascript
|
||||
let contours = font.textToContours('HELLO', 0, 0, 200);
|
||||
// Returns array of contour arrays (closed paths)
|
||||
|
||||
let geo = font.textToModel('HELLO', 0, 0, 200);
|
||||
// Returns p5.Geometry for 3D extruded text
|
||||
```
|
||||
|
||||
### CDN for p5.js 2.x
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/p5@2/lib/p5.min.js"></script>
|
||||
```
|
||||
@@ -0,0 +1,566 @@
|
||||
# Export Pipeline
|
||||
|
||||
## PNG Export
|
||||
|
||||
### In-Sketch (Keyboard Shortcut)
|
||||
|
||||
```javascript
|
||||
function keyPressed() {
|
||||
if (key === 's' || key === 'S') {
|
||||
saveCanvas('output', 'png');
|
||||
// Downloads output.png immediately
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Timed Export (Static Generative)
|
||||
|
||||
```javascript
|
||||
function setup() {
|
||||
createCanvas(3840, 2160);
|
||||
pixelDensity(1);
|
||||
randomSeed(CONFIG.seed);
|
||||
noiseSeed(CONFIG.seed);
|
||||
noLoop();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// ... render everything ...
|
||||
saveCanvas('output-seed-' + CONFIG.seed, 'png');
|
||||
}
|
||||
```
|
||||
|
||||
### High-Resolution Export
|
||||
|
||||
For resolutions beyond screen size, use `pixelDensity()` or a large offscreen buffer:
|
||||
|
||||
```javascript
|
||||
function exportHighRes(scale) {
|
||||
let buffer = createGraphics(width * scale, height * scale);
|
||||
buffer.scale(scale);
|
||||
// Re-render everything to buffer at higher resolution
|
||||
renderScene(buffer);
|
||||
buffer.save('highres-output.png');
|
||||
}
|
||||
```
|
||||
|
||||
### Batch Seed Export
|
||||
|
||||
```javascript
|
||||
function exportBatch(startSeed, count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
CONFIG.seed = startSeed + i;
|
||||
randomSeed(CONFIG.seed);
|
||||
noiseSeed(CONFIG.seed);
|
||||
// Render
|
||||
background(0);
|
||||
renderScene();
|
||||
saveCanvas('seed-' + nf(CONFIG.seed, 5), 'png');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## GIF Export
|
||||
|
||||
### saveGif()
|
||||
|
||||
```javascript
|
||||
function keyPressed() {
|
||||
if (key === 'g' || key === 'G') {
|
||||
saveGif('output', 5);
|
||||
// Captures 5 seconds of animation
|
||||
// Options: saveGif(filename, duration, options)
|
||||
}
|
||||
}
|
||||
|
||||
// With options
|
||||
saveGif('output', 5, {
|
||||
delay: 0, // delay before starting capture (seconds)
|
||||
units: 'seconds' // or 'frames'
|
||||
});
|
||||
```
|
||||
|
||||
Limitations:
|
||||
- GIF is 256 colors max — dithering artifacts on gradients
|
||||
- Large canvases produce huge files
|
||||
- Use a smaller canvas (640x360) for GIF, higher for PNG/MP4
|
||||
- Frame rate is approximate
|
||||
|
||||
### Optimal GIF Settings
|
||||
|
||||
```javascript
|
||||
// For GIF output, use smaller canvas and lower framerate
|
||||
function setup() {
|
||||
createCanvas(640, 360);
|
||||
frameRate(15); // GIF standard
|
||||
pixelDensity(1);
|
||||
}
|
||||
```
|
||||
|
||||
## Frame Sequence Export
|
||||
|
||||
### saveFrames()
|
||||
|
||||
```javascript
|
||||
function keyPressed() {
|
||||
if (key === 'f') {
|
||||
saveFrames('frame', 'png', 10, 30);
|
||||
// 10 seconds, 30 fps → 300 PNG files
|
||||
// Downloads as individual files (browser may block bulk downloads)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Frame Export (More Control)
|
||||
|
||||
```javascript
|
||||
let recording = false;
|
||||
let frameNum = 0;
|
||||
const TOTAL_FRAMES = 300;
|
||||
|
||||
function keyPressed() {
|
||||
if (key === 'r') recording = !recording;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// ... render frame ...
|
||||
|
||||
if (recording) {
|
||||
saveCanvas('frame-' + nf(frameNum, 4), 'png');
|
||||
frameNum++;
|
||||
if (frameNum >= TOTAL_FRAMES) {
|
||||
recording = false;
|
||||
noLoop();
|
||||
console.log('Recording complete: ' + frameNum + ' frames');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deterministic Capture (Critical for Video)
|
||||
|
||||
The `noLoop()` + `redraw()` pattern is **required** for frame-perfect headless capture. Without it, p5's draw loop runs freely in Chrome while Puppeteer screenshots are slow — the sketch runs ahead and you get duplicate/missing frames.
|
||||
|
||||
```javascript
|
||||
function setup() {
|
||||
createCanvas(1920, 1080);
|
||||
pixelDensity(1);
|
||||
noLoop(); // STOP the automatic draw loop
|
||||
window._p5Ready = true; // Signal to capture script
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// This only runs when redraw() is called by the capture script
|
||||
// frameCount increments exactly once per redraw()
|
||||
}
|
||||
```
|
||||
|
||||
The bundled `scripts/export-frames.js` detects `window._p5Ready` and switches to deterministic mode automatically. Without it, falls back to timed capture (less precise).
|
||||
|
||||
### ffmpeg: Frames to MP4
|
||||
|
||||
```bash
|
||||
# Basic encoding
|
||||
ffmpeg -framerate 30 -i frame-%04d.png -c:v libx264 -pix_fmt yuv420p output.mp4
|
||||
|
||||
# High quality
|
||||
ffmpeg -framerate 30 -i frame-%04d.png \
|
||||
-c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p \
|
||||
output.mp4
|
||||
|
||||
# With audio
|
||||
ffmpeg -framerate 30 -i frame-%04d.png -i audio.mp3 \
|
||||
-c:v libx264 -c:a aac -shortest \
|
||||
output.mp4
|
||||
|
||||
# Loop for social media (3 loops)
|
||||
ffmpeg -stream_loop 2 -i output.mp4 -c copy output-looped.mp4
|
||||
```
|
||||
|
||||
### Video Export Gotchas
|
||||
|
||||
**YUV420 clips dark values.** H.264 encodes in YUV420 color space, which rounds dark RGB values. Content below RGB(8,8,8) may become pure black. Subtle dark details (dim particle trails, faint noise textures) disappear in the encoded video even though they're visible in the PNG frames.
|
||||
|
||||
**Fix:** Ensure minimum brightness of ~10 for any visible content. Test by encoding a few frames and comparing the MP4 frame vs the source PNG.
|
||||
|
||||
```bash
|
||||
# Extract a frame from MP4 for comparison
|
||||
ffmpeg -i output.mp4 -vf "select=eq(n\,100)" -vframes 1 check.png
|
||||
```
|
||||
|
||||
**Static frames look broken in video.** If an algorithm produces a single static image (like a pre-computed attractor heatmap), it reads as a freeze/glitch in video. Always add animation even to static content:
|
||||
- Progressive reveal (expand from center, sweep across)
|
||||
- Slow parameter drift (rotate color mapping, shift noise offset)
|
||||
- Camera-like motion (slow zoom, slight pan)
|
||||
- Overlay animated particles or grain
|
||||
|
||||
**Scene transitions are mandatory.** Hard cuts between visually different scenes are jarring. Use fade envelopes:
|
||||
|
||||
```javascript
|
||||
const FADE_FRAMES = 15; // half-second at 30fps
|
||||
let fade = 1;
|
||||
if (localFrame < FADE_FRAMES) fade = localFrame / FADE_FRAMES;
|
||||
if (localFrame > SCENE_FRAMES - FADE_FRAMES) fade = (SCENE_FRAMES - localFrame) / FADE_FRAMES;
|
||||
fade = fade * fade * (3 - 2 * fade); // smoothstep
|
||||
// Apply: multiply all alpha/brightness by fade
|
||||
```
|
||||
|
||||
### Per-Clip Architecture (Multi-Scene Videos)
|
||||
|
||||
For videos with multiple scenes, render each as a separate HTML file + MP4 clip, then stitch with ffmpeg. This enables re-rendering individual scenes without touching the rest.
|
||||
|
||||
**Directory structure:**
|
||||
```
|
||||
project/
|
||||
├── capture-scene.js # Shared: node capture-scene.js <html> <outdir> <frames>
|
||||
├── render-all.sh # Renders all + stitches
|
||||
├── scenes/
|
||||
│ ├── 00-intro.html # Each scene is self-contained
|
||||
│ ├── 01-particles.html
|
||||
│ ├── 02-noise.html
|
||||
│ └── 03-outro.html
|
||||
└── clips/
|
||||
├── 00-intro.mp4 # Each clip rendered independently
|
||||
├── 01-particles.mp4
|
||||
├── 02-noise.mp4
|
||||
├── 03-outro.mp4
|
||||
└── concat.txt
|
||||
```
|
||||
|
||||
**Stitch clips with ffmpeg concat:**
|
||||
```bash
|
||||
# concat.txt (order determines final sequence)
|
||||
file '00-intro.mp4'
|
||||
file '01-particles.mp4'
|
||||
file '02-noise.mp4'
|
||||
file '03-outro.mp4'
|
||||
|
||||
# Lossless stitch (all clips must have same codec/resolution/fps)
|
||||
ffmpeg -f concat -safe 0 -i concat.txt -c copy final.mp4
|
||||
```
|
||||
|
||||
**Re-render a single scene:**
|
||||
```bash
|
||||
node capture-scene.js scenes/01-particles.html clips/01-particles 150
|
||||
ffmpeg -y -framerate 30 -i clips/01-particles/frame-%04d.png \
|
||||
-c:v libx264 -preset slow -crf 16 -pix_fmt yuv420p clips/01-particles.mp4
|
||||
# Then re-stitch
|
||||
ffmpeg -y -f concat -safe 0 -i clips/concat.txt -c copy final.mp4
|
||||
```
|
||||
|
||||
**Re-order without re-rendering:** Just change the order in concat.txt and re-stitch. No frames need re-rendering.
|
||||
|
||||
**Each scene HTML must:**
|
||||
- Call `noLoop()` in setup and set `window._p5Ready = true`
|
||||
- Use `frameCount`-based timing (not `millis()`) for deterministic output
|
||||
- Handle its own fade-in/fade-out envelope
|
||||
- Be fully self-contained (no shared state between scenes)
|
||||
|
||||
### ffmpeg: Frames to GIF (Better Quality)
|
||||
|
||||
```bash
|
||||
# Generate palette first for optimal colors
|
||||
ffmpeg -i frame-%04d.png -vf "fps=15,palettegen=max_colors=256" palette.png
|
||||
|
||||
# Render GIF using palette
|
||||
ffmpeg -i frame-%04d.png -i palette.png \
|
||||
-lavfi "fps=15 [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=3" \
|
||||
output.gif
|
||||
```
|
||||
|
||||
## Headless Export (Puppeteer)
|
||||
|
||||
For automated, server-side, or CI rendering. Uses a headless Chrome browser to run the sketch.
|
||||
|
||||
### export-frames.js (Node.js Script)
|
||||
|
||||
See `scripts/export-frames.js` for the full implementation. Basic pattern:
|
||||
|
||||
```javascript
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
async function captureFrames(htmlPath, outputDir, options) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.setViewport({
|
||||
width: options.width || 1920,
|
||||
height: options.height || 1080,
|
||||
deviceScaleFactor: 1
|
||||
});
|
||||
|
||||
await page.goto(`file://${path.resolve(htmlPath)}`, {
|
||||
waitUntil: 'networkidle0'
|
||||
});
|
||||
|
||||
// Wait for sketch to initialize
|
||||
await page.waitForSelector('canvas');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
for (let i = 0; i < options.frames; i++) {
|
||||
const canvas = await page.$('canvas');
|
||||
await canvas.screenshot({
|
||||
path: path.join(outputDir, `frame-${String(i).padStart(4, '0')}.png`)
|
||||
});
|
||||
|
||||
// Advance one frame
|
||||
await page.evaluate(() => { redraw(); });
|
||||
await page.waitForTimeout(1000 / options.fps);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
```
|
||||
|
||||
### render.sh (Full Pipeline)
|
||||
|
||||
See `scripts/render.sh` for the complete render script. Pipeline:
|
||||
|
||||
```
|
||||
1. Launch Puppeteer → open sketch HTML
|
||||
2. Capture N frames as PNG sequence
|
||||
3. Pipe to ffmpeg → encode H.264 MP4
|
||||
4. Optional: add audio track
|
||||
5. Clean up temp frames
|
||||
```
|
||||
|
||||
## SVG Export
|
||||
|
||||
### Using p5.js-svg Library
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/p5.js-svg@1.5.1"></script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
function setup() {
|
||||
createCanvas(1920, 1080, SVG); // SVG renderer
|
||||
noLoop();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Only vector operations (no pixels, no blend modes)
|
||||
stroke(0);
|
||||
noFill();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
let x = random(width);
|
||||
let y = random(height);
|
||||
ellipse(x, y, random(10, 50));
|
||||
}
|
||||
save('output.svg');
|
||||
}
|
||||
```
|
||||
|
||||
Limitations:
|
||||
- No `loadPixels()`, `updatePixels()`, `filter()`, `blendMode()`
|
||||
- No WebGL
|
||||
- No pixel-level effects
|
||||
- Great for: line art, geometric patterns, plots
|
||||
|
||||
### Hybrid: Raster Background + SVG Overlay
|
||||
|
||||
Render background effects to PNG, then SVG for crisp vector elements on top.
|
||||
|
||||
## Export Format Decision Guide
|
||||
|
||||
| Need | Format | Method |
|
||||
|------|--------|--------|
|
||||
| Single still image | PNG | `saveCanvas()` or `keyPressed()` |
|
||||
| Print-quality still | PNG (high-res) | `pixelDensity(1)` + large canvas |
|
||||
| Short animated loop | GIF | `saveGif()` |
|
||||
| Long animation | MP4 | Frame sequence + ffmpeg |
|
||||
| Social media video | MP4 | `scripts/render.sh` |
|
||||
| Vector/print | SVG | p5.js-svg renderer |
|
||||
| Batch variations | PNG sequence | Seed loop + `saveCanvas()` |
|
||||
| Interactive deployment | HTML | Single self-contained file |
|
||||
| Headless rendering | PNG/MP4 | Puppeteer + ffmpeg |
|
||||
|
||||
## Tiling for Ultra-High-Resolution
|
||||
|
||||
For resolutions too large for a single canvas (e.g., 10000x10000 for print):
|
||||
|
||||
```javascript
|
||||
function renderTiled(totalW, totalH, tileSize) {
|
||||
let cols = ceil(totalW / tileSize);
|
||||
let rows = ceil(totalH / tileSize);
|
||||
|
||||
for (let ty = 0; ty < rows; ty++) {
|
||||
for (let tx = 0; tx < cols; tx++) {
|
||||
let buffer = createGraphics(tileSize, tileSize);
|
||||
buffer.push();
|
||||
buffer.translate(-tx * tileSize, -ty * tileSize);
|
||||
renderScene(buffer, totalW, totalH);
|
||||
buffer.pop();
|
||||
buffer.save(`tile-${tx}-${ty}.png`);
|
||||
buffer.remove(); // free memory
|
||||
}
|
||||
}
|
||||
// Stitch with ImageMagick:
|
||||
// montage tile-*.png -tile 4x4 -geometry +0+0 final.png
|
||||
}
|
||||
```
|
||||
|
||||
## CCapture.js — Deterministic Video Capture
|
||||
|
||||
The built-in `saveFrames()` has limitations: small frame counts, memory issues, browser download blocking. CCapture.js solves all of these by hooking into the browser's timing functions to simulate constant time steps regardless of actual render speed.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/ccapture.js-npmfixed/build/CCapture.all.min.js"></script>
|
||||
```
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```javascript
|
||||
let capturer;
|
||||
let recording = false;
|
||||
|
||||
function setup() {
|
||||
createCanvas(1920, 1080);
|
||||
pixelDensity(1);
|
||||
|
||||
capturer = new CCapture({
|
||||
format: 'webm', // 'webm', 'gif', 'png', 'jpg'
|
||||
framerate: 30,
|
||||
quality: 99, // 0-100 for webm/jpg
|
||||
// timeLimit: 10, // auto-stop after N seconds
|
||||
// motionBlurFrames: 4 // supersampled motion blur
|
||||
});
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// ... render frame ...
|
||||
|
||||
if (recording) {
|
||||
capturer.capture(document.querySelector('canvas'));
|
||||
}
|
||||
}
|
||||
|
||||
function keyPressed() {
|
||||
if (key === 'c') {
|
||||
if (!recording) {
|
||||
capturer.start();
|
||||
recording = true;
|
||||
console.log('Recording started');
|
||||
} else {
|
||||
capturer.stop();
|
||||
capturer.save(); // triggers download
|
||||
recording = false;
|
||||
console.log('Recording saved');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Format Comparison
|
||||
|
||||
| Format | Quality | Size | Browser Support |
|
||||
|--------|---------|------|-----------------|
|
||||
| **WebM** | High | Medium | Chrome only |
|
||||
| **GIF** | 256 colors | Large | All (via gif.js worker) |
|
||||
| **PNG sequence** | Lossless | Very large (TAR) | All |
|
||||
| **JPEG sequence** | Lossy | Large (TAR) | All |
|
||||
|
||||
### Important: Timing Hook
|
||||
|
||||
CCapture.js overrides `Date.now()`, `setTimeout`, `requestAnimationFrame`, and `performance.now()`. This means:
|
||||
- `millis()` returns simulated time (perfect for recording)
|
||||
- `deltaTime` is constant (1000/framerate)
|
||||
- Complex sketches that take 500ms per frame still record at smooth 30fps
|
||||
- **Caveat**: Audio sync breaks (audio plays in real-time, not simulated time)
|
||||
|
||||
## Programmatic Export (canvas API)
|
||||
|
||||
For custom export workflows beyond `saveCanvas()`:
|
||||
|
||||
```javascript
|
||||
// Canvas to Blob (for upload, processing)
|
||||
document.querySelector('canvas').toBlob((blob) => {
|
||||
// Upload to server, process, etc.
|
||||
let url = URL.createObjectURL(blob);
|
||||
console.log('Blob URL:', url);
|
||||
}, 'image/png');
|
||||
|
||||
// Canvas to Data URL (for inline embedding)
|
||||
let dataUrl = document.querySelector('canvas').toDataURL('image/png');
|
||||
// Use in <img src="..."> or send as base64
|
||||
```
|
||||
|
||||
## SVG Export (p5.js-svg)
|
||||
|
||||
```html
|
||||
<script src="https://unpkg.com/p5.js-svg@1.6.0"></script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
function setup() {
|
||||
createCanvas(1920, 1080, SVG); // SVG renderer
|
||||
noLoop();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Only vector operations work (no pixel ops, no blendMode)
|
||||
stroke(0);
|
||||
noFill();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
ellipse(random(width), random(height), random(10, 50));
|
||||
}
|
||||
save('output.svg');
|
||||
}
|
||||
```
|
||||
|
||||
**Critical SVG caveats:**
|
||||
- **Must call `clear()` in `draw()`** for animated sketches — SVG DOM accumulates child elements, causing memory bloat
|
||||
- `blendMode()` is **not implemented** in SVG renderer
|
||||
- `filter()`, `loadPixels()`, `updatePixels()` don't work
|
||||
- Requires **p5.js 1.11.x** — not compatible with p5.js 2.x
|
||||
- Perfect for: line art, geometric patterns, pen plotter output
|
||||
|
||||
## Platform Export
|
||||
|
||||
### fxhash Conventions
|
||||
|
||||
```javascript
|
||||
// Replace p5's random with fxhash's deterministic PRNG
|
||||
const rng = $fx.rand;
|
||||
|
||||
// Declare features for rarity/filtering
|
||||
$fx.features({
|
||||
'Palette': paletteName,
|
||||
'Complexity': complexity > 0.7 ? 'High' : 'Low',
|
||||
'Has Particles': particleCount > 0
|
||||
});
|
||||
|
||||
// Declare on-chain parameters
|
||||
$fx.params([
|
||||
{ id: 'density', name: 'Density', type: 'number',
|
||||
options: { min: 1, max: 100, step: 1 } },
|
||||
{ id: 'palette', name: 'Palette', type: 'select',
|
||||
options: { options: ['Warm', 'Cool', 'Mono'] } },
|
||||
{ id: 'accent', name: 'Accent Color', type: 'color' }
|
||||
]);
|
||||
|
||||
// Read params
|
||||
let density = $fx.getParam('density');
|
||||
|
||||
// Build: npx fxhash build → upload.zip
|
||||
// Dev: npx fxhash dev → localhost:3300
|
||||
```
|
||||
|
||||
### Art Blocks / Generic Platform
|
||||
|
||||
```javascript
|
||||
// Platform provides a hash string
|
||||
const hash = tokenData.hash; // Art Blocks convention
|
||||
|
||||
// Build deterministic PRNG from hash
|
||||
function prngFromHash(hash) {
|
||||
let seed = parseInt(hash.slice(0, 16), 16);
|
||||
// xoshiro128** or similar
|
||||
return function() { /* ... */ };
|
||||
}
|
||||
|
||||
const rng = prngFromHash(hash);
|
||||
```
|
||||
@@ -0,0 +1,398 @@
|
||||
# Interaction
|
||||
|
||||
## Mouse Events
|
||||
|
||||
### Continuous State
|
||||
|
||||
```javascript
|
||||
mouseX, mouseY // current position (relative to canvas)
|
||||
pmouseX, pmouseY // previous frame position
|
||||
mouseIsPressed // boolean
|
||||
mouseButton // LEFT, RIGHT, CENTER (during press)
|
||||
movedX, movedY // delta since last frame
|
||||
winMouseX, winMouseY // relative to window (not canvas)
|
||||
```
|
||||
|
||||
### Event Callbacks
|
||||
|
||||
```javascript
|
||||
function mousePressed() {
|
||||
// fires once on press
|
||||
// mouseButton tells you which button
|
||||
}
|
||||
|
||||
function mouseReleased() {
|
||||
// fires once on release
|
||||
}
|
||||
|
||||
function mouseClicked() {
|
||||
// fires after press+release (same element)
|
||||
}
|
||||
|
||||
function doubleClicked() {
|
||||
// fires on double-click
|
||||
}
|
||||
|
||||
function mouseMoved() {
|
||||
// fires when mouse moves (no button pressed)
|
||||
}
|
||||
|
||||
function mouseDragged() {
|
||||
// fires when mouse moves WITH button pressed
|
||||
}
|
||||
|
||||
function mouseWheel(event) {
|
||||
// event.delta: positive = scroll down, negative = scroll up
|
||||
zoom += event.delta * -0.01;
|
||||
return false; // prevent page scroll
|
||||
}
|
||||
```
|
||||
|
||||
### Mouse Interaction Patterns
|
||||
|
||||
**Spawn on click:**
|
||||
```javascript
|
||||
function mousePressed() {
|
||||
particles.push(new Particle(mouseX, mouseY));
|
||||
}
|
||||
```
|
||||
|
||||
**Mouse follow with spring:**
|
||||
```javascript
|
||||
let springX, springY;
|
||||
function setup() {
|
||||
springX = new Spring(width/2, width/2);
|
||||
springY = new Spring(height/2, height/2);
|
||||
}
|
||||
function draw() {
|
||||
springX.setTarget(mouseX);
|
||||
springY.setTarget(mouseY);
|
||||
let x = springX.update();
|
||||
let y = springY.update();
|
||||
ellipse(x, y, 50);
|
||||
}
|
||||
```
|
||||
|
||||
**Drag interaction:**
|
||||
```javascript
|
||||
let dragging = false;
|
||||
let dragObj = null;
|
||||
let offsetX, offsetY;
|
||||
|
||||
function mousePressed() {
|
||||
for (let obj of objects) {
|
||||
if (dist(mouseX, mouseY, obj.x, obj.y) < obj.radius) {
|
||||
dragging = true;
|
||||
dragObj = obj;
|
||||
offsetX = mouseX - obj.x;
|
||||
offsetY = mouseY - obj.y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mouseDragged() {
|
||||
if (dragging && dragObj) {
|
||||
dragObj.x = mouseX - offsetX;
|
||||
dragObj.y = mouseY - offsetY;
|
||||
}
|
||||
}
|
||||
|
||||
function mouseReleased() {
|
||||
dragging = false;
|
||||
dragObj = null;
|
||||
}
|
||||
```
|
||||
|
||||
**Mouse repulsion (particles flee cursor):**
|
||||
```javascript
|
||||
function draw() {
|
||||
let mousePos = createVector(mouseX, mouseY);
|
||||
for (let p of particles) {
|
||||
let d = p.pos.dist(mousePos);
|
||||
if (d < 150) {
|
||||
let repel = p5.Vector.sub(p.pos, mousePos);
|
||||
repel.normalize();
|
||||
repel.mult(map(d, 0, 150, 5, 0));
|
||||
p.applyForce(repel);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Keyboard Events
|
||||
|
||||
### State
|
||||
|
||||
```javascript
|
||||
keyIsPressed // boolean
|
||||
key // last key as string ('a', 'A', ' ')
|
||||
keyCode // numeric code (LEFT_ARROW, UP_ARROW, etc.)
|
||||
```
|
||||
|
||||
### Event Callbacks
|
||||
|
||||
```javascript
|
||||
function keyPressed() {
|
||||
// fires once on press
|
||||
if (keyCode === LEFT_ARROW) { /* ... */ }
|
||||
if (key === 's') saveCanvas('output', 'png');
|
||||
if (key === ' ') CONFIG.paused = !CONFIG.paused;
|
||||
return false; // prevent default browser behavior
|
||||
}
|
||||
|
||||
function keyReleased() {
|
||||
// fires once on release
|
||||
}
|
||||
|
||||
function keyTyped() {
|
||||
// fires for printable characters only (not arrows, shift, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
### Continuous Key State (Multiple Keys)
|
||||
|
||||
```javascript
|
||||
let keys = {};
|
||||
|
||||
function keyPressed() { keys[keyCode] = true; }
|
||||
function keyReleased() { keys[keyCode] = false; }
|
||||
|
||||
function draw() {
|
||||
if (keys[LEFT_ARROW]) player.x -= 5;
|
||||
if (keys[RIGHT_ARROW]) player.x += 5;
|
||||
if (keys[UP_ARROW]) player.y -= 5;
|
||||
if (keys[DOWN_ARROW]) player.y += 5;
|
||||
}
|
||||
```
|
||||
|
||||
### Key Constants
|
||||
|
||||
```
|
||||
LEFT_ARROW, RIGHT_ARROW, UP_ARROW, DOWN_ARROW
|
||||
BACKSPACE, DELETE, ENTER, RETURN, TAB, ESCAPE
|
||||
SHIFT, CONTROL, OPTION, ALT
|
||||
```
|
||||
|
||||
## Touch Events
|
||||
|
||||
```javascript
|
||||
touches // array of { x, y, id } — all current touches
|
||||
|
||||
function touchStarted() {
|
||||
// fires on first touch
|
||||
return false; // prevent default (stops scroll on mobile)
|
||||
}
|
||||
|
||||
function touchMoved() {
|
||||
// fires on touch drag
|
||||
return false;
|
||||
}
|
||||
|
||||
function touchEnded() {
|
||||
// fires on touch release
|
||||
}
|
||||
```
|
||||
|
||||
### Pinch Zoom
|
||||
|
||||
```javascript
|
||||
let prevDist = 0;
|
||||
let zoomLevel = 1;
|
||||
|
||||
function touchMoved() {
|
||||
if (touches.length === 2) {
|
||||
let d = dist(touches[0].x, touches[0].y, touches[1].x, touches[1].y);
|
||||
if (prevDist > 0) {
|
||||
zoomLevel *= d / prevDist;
|
||||
}
|
||||
prevDist = d;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function touchEnded() {
|
||||
prevDist = 0;
|
||||
}
|
||||
```
|
||||
|
||||
## DOM Elements
|
||||
|
||||
### Creating Controls
|
||||
|
||||
```javascript
|
||||
function setup() {
|
||||
createCanvas(800, 800);
|
||||
|
||||
// Slider
|
||||
let slider = createSlider(0, 255, 100, 1); // min, max, default, step
|
||||
slider.position(10, height + 10);
|
||||
slider.input(() => { CONFIG.value = slider.value(); });
|
||||
|
||||
// Button
|
||||
let btn = createButton('Reset');
|
||||
btn.position(10, height + 40);
|
||||
btn.mousePressed(() => { resetSketch(); });
|
||||
|
||||
// Checkbox
|
||||
let check = createCheckbox('Show grid', false);
|
||||
check.position(10, height + 70);
|
||||
check.changed(() => { CONFIG.showGrid = check.checked(); });
|
||||
|
||||
// Select / dropdown
|
||||
let sel = createSelect();
|
||||
sel.position(10, height + 100);
|
||||
sel.option('Mode A');
|
||||
sel.option('Mode B');
|
||||
sel.changed(() => { CONFIG.mode = sel.value(); });
|
||||
|
||||
// Color picker
|
||||
let picker = createColorPicker('#ff0000');
|
||||
picker.position(10, height + 130);
|
||||
picker.input(() => { CONFIG.color = picker.value(); });
|
||||
|
||||
// Text input
|
||||
let inp = createInput('Hello');
|
||||
inp.position(10, height + 160);
|
||||
inp.input(() => { CONFIG.text = inp.value(); });
|
||||
}
|
||||
```
|
||||
|
||||
### Styling DOM Elements
|
||||
|
||||
```javascript
|
||||
let slider = createSlider(0, 100, 50);
|
||||
slider.position(10, 10);
|
||||
slider.style('width', '200px');
|
||||
slider.class('my-slider');
|
||||
slider.parent('controls-div'); // attach to specific DOM element
|
||||
```
|
||||
|
||||
## Audio Input (p5.sound)
|
||||
|
||||
Requires `p5.sound.min.js` addon.
|
||||
|
||||
```html
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.3/addons/p5.sound.min.js"></script>
|
||||
```
|
||||
|
||||
### Microphone Input
|
||||
|
||||
```javascript
|
||||
let mic, fft, amplitude;
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800);
|
||||
userStartAudio(); // required — user gesture to enable audio
|
||||
|
||||
mic = new p5.AudioIn();
|
||||
mic.start();
|
||||
|
||||
fft = new p5.FFT(0.8, 256); // smoothing, bins
|
||||
fft.setInput(mic);
|
||||
|
||||
amplitude = new p5.Amplitude();
|
||||
amplitude.setInput(mic);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
let level = amplitude.getLevel(); // 0.0 to 1.0 (overall volume)
|
||||
let spectrum = fft.analyze(); // array of 256 frequency values (0-255)
|
||||
let waveform = fft.waveform(); // array of 256 time-domain samples (-1 to 1)
|
||||
|
||||
// Get energy in frequency bands
|
||||
let bass = fft.getEnergy('bass'); // 20-140 Hz
|
||||
let lowMid = fft.getEnergy('lowMid'); // 140-400 Hz
|
||||
let mid = fft.getEnergy('mid'); // 400-2600 Hz
|
||||
let highMid = fft.getEnergy('highMid'); // 2600-5200 Hz
|
||||
let treble = fft.getEnergy('treble'); // 5200-14000 Hz
|
||||
// Each returns 0-255
|
||||
}
|
||||
```
|
||||
|
||||
### Audio File Playback
|
||||
|
||||
```javascript
|
||||
let song, fft;
|
||||
|
||||
function preload() {
|
||||
song = loadSound('track.mp3');
|
||||
}
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800);
|
||||
fft = new p5.FFT(0.8, 512);
|
||||
fft.setInput(song);
|
||||
}
|
||||
|
||||
function mousePressed() {
|
||||
if (song.isPlaying()) {
|
||||
song.pause();
|
||||
} else {
|
||||
song.play();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Beat Detection (Simple)
|
||||
|
||||
```javascript
|
||||
let prevBass = 0;
|
||||
let beatThreshold = 30;
|
||||
let beatCooldown = 0;
|
||||
|
||||
function detectBeat() {
|
||||
let bass = fft.getEnergy('bass');
|
||||
let isBeat = bass - prevBass > beatThreshold && beatCooldown <= 0;
|
||||
prevBass = bass;
|
||||
if (isBeat) beatCooldown = 10; // frames
|
||||
beatCooldown--;
|
||||
return isBeat;
|
||||
}
|
||||
```
|
||||
|
||||
## Scroll-Driven Animation
|
||||
|
||||
```javascript
|
||||
let scrollProgress = 0;
|
||||
|
||||
function setup() {
|
||||
let canvas = createCanvas(windowWidth, windowHeight);
|
||||
canvas.style('position', 'fixed');
|
||||
// Make page scrollable
|
||||
document.body.style.height = '500vh';
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
let maxScroll = document.body.scrollHeight - window.innerHeight;
|
||||
scrollProgress = window.scrollY / maxScroll;
|
||||
});
|
||||
|
||||
function draw() {
|
||||
background(0);
|
||||
// Use scrollProgress (0 to 1) to drive animation
|
||||
let x = lerp(0, width, scrollProgress);
|
||||
ellipse(x, height/2, 50);
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Events
|
||||
|
||||
```javascript
|
||||
function windowResized() {
|
||||
resizeCanvas(windowWidth, windowHeight);
|
||||
// Recreate buffers
|
||||
bgLayer = createGraphics(width, height);
|
||||
// Recalculate layout
|
||||
recalculateLayout();
|
||||
}
|
||||
|
||||
// Visibility change (tab switching)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
noLoop(); // pause when tab not visible
|
||||
} else {
|
||||
loop();
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,300 @@
|
||||
# Shapes and Geometry
|
||||
|
||||
## 2D Primitives
|
||||
|
||||
```javascript
|
||||
point(x, y);
|
||||
line(x1, y1, x2, y2);
|
||||
rect(x, y, w, h); // default: corner mode
|
||||
rect(x, y, w, h, r); // rounded corners
|
||||
rect(x, y, w, h, tl, tr, br, bl); // per-corner radius
|
||||
square(x, y, size);
|
||||
ellipse(x, y, w, h);
|
||||
circle(x, y, d); // diameter, not radius
|
||||
triangle(x1, y1, x2, y2, x3, y3);
|
||||
quad(x1, y1, x2, y2, x3, y3, x4, y4);
|
||||
arc(x, y, w, h, start, stop, mode); // mode: OPEN, CHORD, PIE
|
||||
```
|
||||
|
||||
### Drawing Modes
|
||||
|
||||
```javascript
|
||||
rectMode(CENTER); // x,y is center (default: CORNER)
|
||||
rectMode(CORNERS); // x1,y1 to x2,y2
|
||||
ellipseMode(CORNER); // x,y is top-left corner
|
||||
ellipseMode(CENTER); // default — x,y is center
|
||||
```
|
||||
|
||||
## Stroke and Fill
|
||||
|
||||
```javascript
|
||||
fill(r, g, b, a); // or fill(gray), fill('#hex'), fill(h, s, b) in HSB mode
|
||||
noFill();
|
||||
stroke(r, g, b, a);
|
||||
noStroke();
|
||||
strokeWeight(2);
|
||||
strokeCap(ROUND); // ROUND, SQUARE, PROJECT
|
||||
strokeJoin(ROUND); // ROUND, MITER, BEVEL
|
||||
```
|
||||
|
||||
## Custom Shapes with Vertices
|
||||
|
||||
### Basic vertex shape
|
||||
|
||||
```javascript
|
||||
beginShape();
|
||||
vertex(100, 100);
|
||||
vertex(200, 50);
|
||||
vertex(300, 100);
|
||||
vertex(250, 200);
|
||||
vertex(150, 200);
|
||||
endShape(CLOSE); // CLOSE connects last vertex to first
|
||||
```
|
||||
|
||||
### Shape modes
|
||||
|
||||
```javascript
|
||||
beginShape(); // default: polygon connecting all vertices
|
||||
beginShape(POINTS); // individual points
|
||||
beginShape(LINES); // pairs of vertices as lines
|
||||
beginShape(TRIANGLES); // triplets as triangles
|
||||
beginShape(TRIANGLE_FAN);
|
||||
beginShape(TRIANGLE_STRIP);
|
||||
beginShape(QUADS); // groups of 4
|
||||
beginShape(QUAD_STRIP);
|
||||
```
|
||||
|
||||
### Contours (holes in shapes)
|
||||
|
||||
```javascript
|
||||
beginShape();
|
||||
// outer shape
|
||||
vertex(100, 100);
|
||||
vertex(300, 100);
|
||||
vertex(300, 300);
|
||||
vertex(100, 300);
|
||||
// inner hole
|
||||
beginContour();
|
||||
vertex(150, 150);
|
||||
vertex(150, 250);
|
||||
vertex(250, 250);
|
||||
vertex(250, 150);
|
||||
endContour();
|
||||
endShape(CLOSE);
|
||||
```
|
||||
|
||||
## Bezier Curves
|
||||
|
||||
### Cubic Bezier
|
||||
|
||||
```javascript
|
||||
bezier(x1, y1, cx1, cy1, cx2, cy2, x2, y2);
|
||||
// x1,y1 = start point
|
||||
// cx1,cy1 = first control point
|
||||
// cx2,cy2 = second control point
|
||||
// x2,y2 = end point
|
||||
```
|
||||
|
||||
### Bezier in custom shapes
|
||||
|
||||
```javascript
|
||||
beginShape();
|
||||
vertex(100, 200);
|
||||
bezierVertex(150, 50, 250, 50, 300, 200);
|
||||
// control1, control2, endpoint
|
||||
endShape();
|
||||
```
|
||||
|
||||
### Quadratic Bezier
|
||||
|
||||
```javascript
|
||||
beginShape();
|
||||
vertex(100, 200);
|
||||
quadraticVertex(200, 50, 300, 200);
|
||||
// single control point + endpoint
|
||||
endShape();
|
||||
```
|
||||
|
||||
### Interpolation along Bezier
|
||||
|
||||
```javascript
|
||||
let x = bezierPoint(x1, cx1, cx2, x2, t); // t = 0..1
|
||||
let y = bezierPoint(y1, cy1, cy2, y2, t);
|
||||
let tx = bezierTangent(x1, cx1, cx2, x2, t); // tangent
|
||||
```
|
||||
|
||||
## Catmull-Rom Splines
|
||||
|
||||
```javascript
|
||||
curve(cpx1, cpy1, x1, y1, x2, y2, cpx2, cpy2);
|
||||
// cpx1,cpy1 = control point before start
|
||||
// x1,y1 = start point (visible)
|
||||
// x2,y2 = end point (visible)
|
||||
// cpx2,cpy2 = control point after end
|
||||
|
||||
curveVertex(x, y); // in beginShape() — smooth curve through all points
|
||||
curveTightness(0); // 0 = Catmull-Rom, 1 = straight lines, -1 = loose
|
||||
```
|
||||
|
||||
### Smooth curve through points
|
||||
|
||||
```javascript
|
||||
let points = [/* array of {x, y} */];
|
||||
beginShape();
|
||||
curveVertex(points[0].x, points[0].y); // repeat first for tangent
|
||||
for (let p of points) {
|
||||
curveVertex(p.x, p.y);
|
||||
}
|
||||
curveVertex(points[points.length-1].x, points[points.length-1].y); // repeat last
|
||||
endShape();
|
||||
```
|
||||
|
||||
## p5.Vector
|
||||
|
||||
Essential for physics, particle systems, and geometric computation.
|
||||
|
||||
```javascript
|
||||
let v = createVector(x, y);
|
||||
|
||||
// Arithmetic (modifies in place)
|
||||
v.add(other); // vector addition
|
||||
v.sub(other); // subtraction
|
||||
v.mult(scalar); // scale
|
||||
v.div(scalar); // inverse scale
|
||||
v.normalize(); // unit vector (length 1)
|
||||
v.limit(max); // cap magnitude
|
||||
v.setMag(len); // set exact magnitude
|
||||
|
||||
// Queries (non-destructive)
|
||||
v.mag(); // magnitude (length)
|
||||
v.magSq(); // squared magnitude (faster, no sqrt)
|
||||
v.heading(); // angle in radians
|
||||
v.dist(other); // distance to other vector
|
||||
v.dot(other); // dot product
|
||||
v.cross(other); // cross product (3D)
|
||||
v.angleBetween(other); // angle between vectors
|
||||
|
||||
// Static methods (return new vector)
|
||||
p5.Vector.add(a, b); // a + b → new vector
|
||||
p5.Vector.sub(a, b); // a - b → new vector
|
||||
p5.Vector.fromAngle(a); // unit vector at angle
|
||||
p5.Vector.random2D(); // random unit vector
|
||||
p5.Vector.lerp(a, b, t); // interpolate
|
||||
|
||||
// Copy
|
||||
let copy = v.copy();
|
||||
```
|
||||
|
||||
## Signed Distance Fields (2D)
|
||||
|
||||
SDFs return the distance from a point to the nearest edge of a shape. Negative inside, positive outside. Useful for smooth shapes, glow effects, boolean operations.
|
||||
|
||||
```javascript
|
||||
// Circle SDF
|
||||
function sdCircle(px, py, cx, cy, r) {
|
||||
return dist(px, py, cx, cy) - r;
|
||||
}
|
||||
|
||||
// Box SDF
|
||||
function sdBox(px, py, cx, cy, hw, hh) {
|
||||
let dx = abs(px - cx) - hw;
|
||||
let dy = abs(py - cy) - hh;
|
||||
return sqrt(max(dx, 0) ** 2 + max(dy, 0) ** 2) + min(max(dx, dy), 0);
|
||||
}
|
||||
|
||||
// Line segment SDF
|
||||
function sdSegment(px, py, ax, ay, bx, by) {
|
||||
let pa = createVector(px - ax, py - ay);
|
||||
let ba = createVector(bx - ax, by - ay);
|
||||
let t = constrain(pa.dot(ba) / ba.dot(ba), 0, 1);
|
||||
let closest = p5.Vector.add(createVector(ax, ay), p5.Vector.mult(ba, t));
|
||||
return dist(px, py, closest.x, closest.y);
|
||||
}
|
||||
|
||||
// Smooth boolean union
|
||||
function opSmoothUnion(d1, d2, k) {
|
||||
let h = constrain(0.5 + 0.5 * (d2 - d1) / k, 0, 1);
|
||||
return lerp(d2, d1, h) - k * h * (1 - h);
|
||||
}
|
||||
|
||||
// Rendering SDF as glow
|
||||
let d = sdCircle(x, y, width/2, height/2, 200);
|
||||
let glow = exp(-abs(d) * 0.02); // exponential falloff
|
||||
fill(glow * 255);
|
||||
```
|
||||
|
||||
## Useful Geometry Patterns
|
||||
|
||||
### Regular Polygon
|
||||
|
||||
```javascript
|
||||
function regularPolygon(cx, cy, r, sides) {
|
||||
beginShape();
|
||||
for (let i = 0; i < sides; i++) {
|
||||
let a = TWO_PI * i / sides - HALF_PI;
|
||||
vertex(cx + cos(a) * r, cy + sin(a) * r);
|
||||
}
|
||||
endShape(CLOSE);
|
||||
}
|
||||
```
|
||||
|
||||
### Star Shape
|
||||
|
||||
```javascript
|
||||
function star(cx, cy, r1, r2, npoints) {
|
||||
beginShape();
|
||||
let angle = TWO_PI / npoints;
|
||||
let halfAngle = angle / 2;
|
||||
for (let a = -HALF_PI; a < TWO_PI - HALF_PI; a += angle) {
|
||||
vertex(cx + cos(a) * r2, cy + sin(a) * r2);
|
||||
vertex(cx + cos(a + halfAngle) * r1, cy + sin(a + halfAngle) * r1);
|
||||
}
|
||||
endShape(CLOSE);
|
||||
}
|
||||
```
|
||||
|
||||
### Rounded Line (Capsule)
|
||||
|
||||
```javascript
|
||||
function capsule(x1, y1, x2, y2, weight) {
|
||||
strokeWeight(weight);
|
||||
strokeCap(ROUND);
|
||||
line(x1, y1, x2, y2);
|
||||
}
|
||||
```
|
||||
|
||||
### Soft Body / Blob
|
||||
|
||||
```javascript
|
||||
function blob(cx, cy, baseR, noiseScale, noiseOffset, detail = 64) {
|
||||
beginShape();
|
||||
for (let i = 0; i < detail; i++) {
|
||||
let a = TWO_PI * i / detail;
|
||||
let r = baseR + noise(cos(a) * noiseScale + noiseOffset,
|
||||
sin(a) * noiseScale + noiseOffset) * baseR * 0.4;
|
||||
vertex(cx + cos(a) * r, cy + sin(a) * r);
|
||||
}
|
||||
endShape(CLOSE);
|
||||
}
|
||||
```
|
||||
|
||||
## Clipping and Masking
|
||||
|
||||
```javascript
|
||||
// Clip shape — everything drawn after is masked by the clip shape
|
||||
beginClip();
|
||||
circle(width/2, height/2, 400);
|
||||
endClip();
|
||||
// Only content inside the circle is visible
|
||||
image(myImage, 0, 0);
|
||||
|
||||
// Or functional form
|
||||
clip(() => {
|
||||
circle(width/2, height/2, 400);
|
||||
});
|
||||
|
||||
// Erase mode — cut holes
|
||||
erase();
|
||||
circle(mouseX, mouseY, 100); // this area becomes transparent
|
||||
noErase();
|
||||
```
|
||||
@@ -0,0 +1,532 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Performance
|
||||
|
||||
### Step Zero — Disable FES
|
||||
|
||||
The Friendly Error System (FES) adds massive overhead — up to 10x slowdown. Disable it in every production sketch:
|
||||
|
||||
```javascript
|
||||
// BEFORE any p5 code
|
||||
p5.disableFriendlyErrors = true;
|
||||
|
||||
// Or use p5.min.js instead of p5.js — FES is stripped from minified build
|
||||
```
|
||||
|
||||
### Step One — pixelDensity(1)
|
||||
|
||||
Retina/HiDPI displays default to 2x or 3x density, multiplying pixel count by 4-9x:
|
||||
|
||||
```javascript
|
||||
function setup() {
|
||||
pixelDensity(1); // force 1:1 — always do this first
|
||||
createCanvas(1920, 1080);
|
||||
}
|
||||
```
|
||||
|
||||
### Use Math.* in Hot Loops
|
||||
|
||||
p5's `sin()`, `cos()`, `random()`, `min()`, `max()`, `abs()` are wrapper functions with overhead. In hot loops (thousands of iterations per frame), use native `Math.*`:
|
||||
|
||||
```javascript
|
||||
// SLOW — p5 wrappers
|
||||
for (let p of particles) {
|
||||
let a = sin(p.angle);
|
||||
let d = dist(p.x, p.y, mx, my);
|
||||
}
|
||||
|
||||
// FAST — native Math
|
||||
for (let p of particles) {
|
||||
let a = Math.sin(p.angle);
|
||||
let dx = p.x - mx, dy = p.y - my;
|
||||
let dSq = dx * dx + dy * dy; // skip sqrt entirely
|
||||
}
|
||||
```
|
||||
|
||||
Use `magSq()` instead of `mag()` for distance comparisons — avoids expensive `sqrt()`.
|
||||
|
||||
### Diagnosis
|
||||
|
||||
Open Chrome DevTools > Performance tab > Record while sketch runs.
|
||||
|
||||
Common bottlenecks:
|
||||
1. **FES enabled** — 10x overhead on every p5 function call
|
||||
2. **pixelDensity > 1** — 4x pixel count, 4x slower
|
||||
3. **Too many draw calls** — thousands of `ellipse()`, `rect()` per frame
|
||||
4. **Large canvas + pixel operations** — `loadPixels()`/`updatePixels()` on 4K canvas
|
||||
5. **Unoptimized particle systems** — checking all-vs-all distances (O(n^2))
|
||||
6. **Memory leaks** — creating objects every frame without cleanup
|
||||
7. **Shader compilation** — calling `createShader()` in `draw()` instead of `setup()`
|
||||
8. **console.log() in draw()** — DOM write per frame, destroys performance
|
||||
9. **DOM manipulation in draw()** — layout thrashing (400-500x slower than canvas ops)
|
||||
|
||||
### Solutions
|
||||
|
||||
**Reduce draw calls:**
|
||||
```javascript
|
||||
// BAD: 10000 individual circles
|
||||
for (let p of particles) {
|
||||
ellipse(p.x, p.y, p.size);
|
||||
}
|
||||
|
||||
// GOOD: single shape with vertices
|
||||
beginShape(POINTS);
|
||||
for (let p of particles) {
|
||||
vertex(p.x, p.y);
|
||||
}
|
||||
endShape();
|
||||
|
||||
// BEST: direct pixel manipulation
|
||||
loadPixels();
|
||||
for (let p of particles) {
|
||||
let idx = 4 * (floor(p.y) * width + floor(p.x));
|
||||
pixels[idx] = p.r;
|
||||
pixels[idx+1] = p.g;
|
||||
pixels[idx+2] = p.b;
|
||||
pixels[idx+3] = 255;
|
||||
}
|
||||
updatePixels();
|
||||
```
|
||||
|
||||
**Spatial hashing for neighbor queries:**
|
||||
```javascript
|
||||
class SpatialHash {
|
||||
constructor(cellSize) {
|
||||
this.cellSize = cellSize;
|
||||
this.cells = new Map();
|
||||
}
|
||||
|
||||
clear() { this.cells.clear(); }
|
||||
|
||||
_key(x, y) {
|
||||
return `${floor(x / this.cellSize)},${floor(y / this.cellSize)}`;
|
||||
}
|
||||
|
||||
insert(obj) {
|
||||
let key = this._key(obj.pos.x, obj.pos.y);
|
||||
if (!this.cells.has(key)) this.cells.set(key, []);
|
||||
this.cells.get(key).push(obj);
|
||||
}
|
||||
|
||||
query(x, y, radius) {
|
||||
let results = [];
|
||||
let minCX = floor((x - radius) / this.cellSize);
|
||||
let maxCX = floor((x + radius) / this.cellSize);
|
||||
let minCY = floor((y - radius) / this.cellSize);
|
||||
let maxCY = floor((y + radius) / this.cellSize);
|
||||
|
||||
for (let cx = minCX; cx <= maxCX; cx++) {
|
||||
for (let cy = minCY; cy <= maxCY; cy++) {
|
||||
let key = `${cx},${cy}`;
|
||||
let cell = this.cells.get(key);
|
||||
if (cell) {
|
||||
for (let obj of cell) {
|
||||
if (dist(x, y, obj.pos.x, obj.pos.y) <= radius) {
|
||||
results.push(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Object pooling:**
|
||||
```javascript
|
||||
class ParticlePool {
|
||||
constructor(maxSize) {
|
||||
this.pool = [];
|
||||
this.active = [];
|
||||
for (let i = 0; i < maxSize; i++) {
|
||||
this.pool.push(new Particle(0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
spawn(x, y) {
|
||||
let p = this.pool.pop();
|
||||
if (p) {
|
||||
p.reset(x, y);
|
||||
this.active.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
for (let i = this.active.length - 1; i >= 0; i--) {
|
||||
this.active[i].update();
|
||||
if (this.active[i].isDead()) {
|
||||
this.pool.push(this.active.splice(i, 1)[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Throttle heavy operations:**
|
||||
```javascript
|
||||
// Only update flow field every N frames
|
||||
if (frameCount % 5 === 0) {
|
||||
flowField.update(frameCount * 0.001);
|
||||
}
|
||||
```
|
||||
|
||||
### Frame Rate Targets
|
||||
|
||||
| Context | Target | Acceptable |
|
||||
|---------|--------|------------|
|
||||
| Interactive sketch | 60fps | 30fps |
|
||||
| Ambient animation | 30fps | 20fps |
|
||||
| Export/recording | 30fps render | Any (offline) |
|
||||
| Mobile | 30fps | 20fps |
|
||||
|
||||
### Per-Pixel Rendering Budgets
|
||||
|
||||
Pixel-level operations (`loadPixels()` loops) are the most expensive common pattern. Budget depends on canvas size and computation per pixel.
|
||||
|
||||
| Canvas | Pixels | Simple noise (1 call) | fBM (4 octave) | Domain warp (3-layer fBM) |
|
||||
|--------|--------|----------------------|----------------|--------------------------|
|
||||
| 540x540 | 291K | ~5ms | ~20ms | ~80ms |
|
||||
| 1080x1080 | 1.17M | ~20ms | ~80ms | ~300ms+ |
|
||||
| 1920x1080 | 2.07M | ~35ms | ~140ms | ~500ms+ |
|
||||
| 3840x2160 | 8.3M | ~140ms | ~560ms | WILL CRASH |
|
||||
|
||||
**Rules of thumb:**
|
||||
- 1 `noise()` call per pixel at 1080x1080 = ~20ms/frame (OK at 30fps)
|
||||
- 4-octave fBM per pixel at 1080x1080 = ~80ms/frame (borderline)
|
||||
- Multi-layer domain warp at 1080x1080 = 300ms+ (too slow for real-time, fine for `noLoop()` export)
|
||||
- **Headless Chrome is 2-5x slower** than desktop Chrome for pixel ops
|
||||
|
||||
**Solution: render at lower resolution, fill blocks:**
|
||||
```javascript
|
||||
let step = 3; // render 1/9 of pixels, fill 3x3 blocks
|
||||
loadPixels();
|
||||
for (let y = 0; y < H; y += step) {
|
||||
for (let x = 0; x < W; x += step) {
|
||||
let v = expensiveNoise(x, y);
|
||||
for (let dy = 0; dy < step && y+dy < H; dy++)
|
||||
for (let dx = 0; dx < step && x+dx < W; dx++) {
|
||||
let i = 4 * ((y+dy) * W + (x+dx));
|
||||
pixels[i] = v; pixels[i+1] = v; pixels[i+2] = v; pixels[i+3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
updatePixels();
|
||||
```
|
||||
|
||||
Step=2 gives 4x speedup. Step=3 gives 9x. Visible at 1080p but acceptable for video (motion hides it).
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### 1. Forgetting to reset blend mode
|
||||
|
||||
```javascript
|
||||
blendMode(ADD);
|
||||
image(glowLayer, 0, 0);
|
||||
// WRONG: everything after this is ADD blended
|
||||
blendMode(BLEND); // ALWAYS reset
|
||||
```
|
||||
|
||||
### 2. Creating objects in draw()
|
||||
|
||||
```javascript
|
||||
// BAD: creates new font object every frame
|
||||
function draw() {
|
||||
let f = loadFont('font.otf'); // NEVER load in draw()
|
||||
}
|
||||
|
||||
// GOOD: load in preload, use in draw
|
||||
let f;
|
||||
function preload() { f = loadFont('font.otf'); }
|
||||
```
|
||||
|
||||
### 3. Not using push()/pop() with transforms
|
||||
|
||||
```javascript
|
||||
// BAD: transforms accumulate
|
||||
translate(100, 0);
|
||||
rotate(0.1);
|
||||
ellipse(0, 0, 50);
|
||||
// Everything after this is also translated and rotated
|
||||
|
||||
// GOOD: isolated transforms
|
||||
push();
|
||||
translate(100, 0);
|
||||
rotate(0.1);
|
||||
ellipse(0, 0, 50);
|
||||
pop();
|
||||
```
|
||||
|
||||
### 4. Integer coordinates for crisp lines
|
||||
|
||||
```javascript
|
||||
// BLURRY: sub-pixel rendering
|
||||
line(10.5, 20.3, 100.7, 80.2);
|
||||
|
||||
// CRISP: integer + 0.5 for 1px lines
|
||||
line(10.5, 20.5, 100.5, 80.5); // on pixel boundary
|
||||
```
|
||||
|
||||
### 5. Pixel density confusion
|
||||
|
||||
```javascript
|
||||
// WRONG: assuming pixel array matches canvas dimensions
|
||||
loadPixels();
|
||||
let idx = 4 * (y * width + x); // wrong if pixelDensity > 1
|
||||
|
||||
// RIGHT: account for pixel density
|
||||
let d = pixelDensity();
|
||||
loadPixels();
|
||||
let idx = 4 * ((y * d) * (width * d) + (x * d));
|
||||
|
||||
// SIMPLEST: set pixelDensity(1) at the start
|
||||
```
|
||||
|
||||
### 6. Color mode confusion
|
||||
|
||||
```javascript
|
||||
// In HSB mode, fill(255) is NOT white
|
||||
colorMode(HSB, 360, 100, 100);
|
||||
fill(255); // This is hue=255, sat=100, bri=100 = vivid purple
|
||||
|
||||
// White in HSB:
|
||||
fill(0, 0, 100); // any hue, 0 saturation, 100 brightness
|
||||
|
||||
// Black in HSB:
|
||||
fill(0, 0, 0);
|
||||
```
|
||||
|
||||
### 7. WebGL origin is center
|
||||
|
||||
```javascript
|
||||
// In WEBGL mode, (0,0) is CENTER, not top-left
|
||||
function draw() {
|
||||
// This draws at the center, not the corner
|
||||
rect(0, 0, 100, 100);
|
||||
|
||||
// For top-left behavior:
|
||||
translate(-width/2, -height/2);
|
||||
rect(0, 0, 100, 100); // now at top-left
|
||||
}
|
||||
```
|
||||
|
||||
### 8. createGraphics cleanup
|
||||
|
||||
```javascript
|
||||
// BAD: memory leak — buffer never freed
|
||||
function draw() {
|
||||
let temp = createGraphics(width, height); // new buffer every frame!
|
||||
// ...
|
||||
}
|
||||
|
||||
// GOOD: create once, reuse
|
||||
let temp;
|
||||
function setup() {
|
||||
temp = createGraphics(width, height);
|
||||
}
|
||||
function draw() {
|
||||
temp.clear();
|
||||
// ... reuse temp
|
||||
}
|
||||
|
||||
// If you must create/destroy:
|
||||
temp.remove(); // explicitly free
|
||||
```
|
||||
|
||||
### 9. noise() returns 0-1, not -1 to 1
|
||||
|
||||
```javascript
|
||||
let n = noise(x); // 0.0 to 1.0 (biased toward 0.5)
|
||||
|
||||
// For -1 to 1 range:
|
||||
let n = noise(x) * 2 - 1;
|
||||
|
||||
// For a specific range:
|
||||
let n = map(noise(x), 0, 1, -100, 100);
|
||||
```
|
||||
|
||||
### 10. saveCanvas() in draw() saves every frame
|
||||
|
||||
```javascript
|
||||
// BAD: saves a PNG every single frame
|
||||
function draw() {
|
||||
// ... render ...
|
||||
saveCanvas('output', 'png'); // DON'T DO THIS
|
||||
}
|
||||
|
||||
// GOOD: save once via keyboard
|
||||
function keyPressed() {
|
||||
if (key === 's') saveCanvas('output', 'png');
|
||||
}
|
||||
|
||||
// GOOD: save once after rendering static piece
|
||||
function draw() {
|
||||
// ... render ...
|
||||
saveCanvas('output', 'png');
|
||||
noLoop(); // stop after saving
|
||||
}
|
||||
```
|
||||
|
||||
### 11. console.log() in draw()
|
||||
|
||||
```javascript
|
||||
// BAD: writes to DOM console every frame — massive overhead
|
||||
function draw() {
|
||||
console.log(particles.length); // 60 DOM writes/second
|
||||
}
|
||||
|
||||
// GOOD: log periodically or conditionally
|
||||
function draw() {
|
||||
if (frameCount % 60 === 0) console.log('FPS:', frameRate().toFixed(1));
|
||||
}
|
||||
```
|
||||
|
||||
### 12. DOM manipulation in draw()
|
||||
|
||||
```javascript
|
||||
// BAD: layout thrashing — 400-500x slower than canvas ops
|
||||
function draw() {
|
||||
document.getElementById('counter').innerText = frameCount;
|
||||
let el = document.querySelector('.info'); // DOM query per frame
|
||||
}
|
||||
|
||||
// GOOD: cache DOM refs, update infrequently
|
||||
let counterEl;
|
||||
function setup() { counterEl = document.getElementById('counter'); }
|
||||
function draw() {
|
||||
if (frameCount % 30 === 0) counterEl.innerText = frameCount;
|
||||
}
|
||||
```
|
||||
|
||||
### 13. Not disabling FES in production
|
||||
|
||||
```javascript
|
||||
// BAD: every p5 function call has error-checking overhead (up to 10x slower)
|
||||
function setup() { createCanvas(800, 800); }
|
||||
|
||||
// GOOD: disable before any p5 code
|
||||
p5.disableFriendlyErrors = true;
|
||||
function setup() { createCanvas(800, 800); }
|
||||
|
||||
// ALSO GOOD: use p5.min.js (FES stripped from minified build)
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Safari Issues
|
||||
- WebGL shader precision: always declare `precision mediump float;`
|
||||
- `AudioContext` requires user gesture (`userStartAudio()`)
|
||||
- Some `blendMode()` options behave differently
|
||||
|
||||
### Firefox Issues
|
||||
- `textToPoints()` may return slightly different point counts
|
||||
- WebGL extensions may differ from Chrome
|
||||
- Color profile handling can shift colors
|
||||
|
||||
### Mobile Issues
|
||||
- Touch events need `return false` to prevent scroll
|
||||
- `devicePixelRatio` can be 2x or 3x — use `pixelDensity(1)` for performance
|
||||
- Smaller canvas recommended (720p or less)
|
||||
- Audio requires explicit user gesture to start
|
||||
|
||||
## CORS Issues
|
||||
|
||||
```javascript
|
||||
// Loading images/fonts from external URLs requires CORS headers
|
||||
// Local files need a server:
|
||||
// python3 -m http.server 8080
|
||||
|
||||
// Or use a CORS proxy for external resources (not recommended for production)
|
||||
```
|
||||
|
||||
## Memory Leaks
|
||||
|
||||
### Symptoms
|
||||
- Framerate degrading over time
|
||||
- Browser tab memory growing unbounded
|
||||
- Page becomes unresponsive after minutes
|
||||
|
||||
### Common Causes
|
||||
|
||||
```javascript
|
||||
// 1. Growing arrays
|
||||
let history = [];
|
||||
function draw() {
|
||||
history.push(someData); // grows forever
|
||||
}
|
||||
// FIX: cap the array
|
||||
if (history.length > 1000) history.shift();
|
||||
|
||||
// 2. Creating p5 objects in draw()
|
||||
function draw() {
|
||||
let v = createVector(0, 0); // allocation every frame
|
||||
}
|
||||
// FIX: reuse pre-allocated objects
|
||||
|
||||
// 3. Unreleased graphics buffers
|
||||
let layers = [];
|
||||
function reset() {
|
||||
for (let l of layers) l.remove(); // free old buffers
|
||||
layers = [];
|
||||
}
|
||||
|
||||
// 4. Event listener accumulation
|
||||
function setup() {
|
||||
// BAD: adds new listener every time setup runs
|
||||
window.addEventListener('resize', handler);
|
||||
}
|
||||
// FIX: use p5's built-in windowResized()
|
||||
```
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Console Logging
|
||||
|
||||
```javascript
|
||||
// Log once (not every frame)
|
||||
if (frameCount === 1) {
|
||||
console.log('Canvas:', width, 'x', height);
|
||||
console.log('Pixel density:', pixelDensity());
|
||||
console.log('Renderer:', drawingContext.constructor.name);
|
||||
}
|
||||
|
||||
// Log periodically
|
||||
if (frameCount % 60 === 0) {
|
||||
console.log('FPS:', frameRate().toFixed(1));
|
||||
console.log('Particles:', particles.length);
|
||||
}
|
||||
```
|
||||
|
||||
### Visual Debugging
|
||||
|
||||
```javascript
|
||||
// Show frame rate
|
||||
function draw() {
|
||||
// ... your sketch ...
|
||||
if (CONFIG.debug) {
|
||||
fill(255, 0, 0);
|
||||
noStroke();
|
||||
textSize(14);
|
||||
textAlign(LEFT, TOP);
|
||||
text('FPS: ' + frameRate().toFixed(1), 10, 10);
|
||||
text('Particles: ' + particles.length, 10, 28);
|
||||
text('Frame: ' + frameCount, 10, 46);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle debug with 'd' key
|
||||
function keyPressed() {
|
||||
if (key === 'd') CONFIG.debug = !CONFIG.debug;
|
||||
}
|
||||
```
|
||||
|
||||
### Isolating Issues
|
||||
|
||||
```javascript
|
||||
// Comment out layers to find the slow one
|
||||
function draw() {
|
||||
renderBackground(); // comment out to test
|
||||
// renderParticles(); // this might be slow
|
||||
// renderPostEffects(); // or this
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,302 @@
|
||||
# Typography
|
||||
|
||||
## Loading Fonts
|
||||
|
||||
### System Fonts
|
||||
|
||||
```javascript
|
||||
textFont('Helvetica');
|
||||
textFont('Georgia');
|
||||
textFont('monospace');
|
||||
```
|
||||
|
||||
### Custom Fonts (OTF/TTF/WOFF2)
|
||||
|
||||
```javascript
|
||||
let myFont;
|
||||
|
||||
function preload() {
|
||||
myFont = loadFont('path/to/font.otf');
|
||||
// Requires local server or CORS-enabled URL
|
||||
}
|
||||
|
||||
function setup() {
|
||||
textFont(myFont);
|
||||
}
|
||||
```
|
||||
|
||||
### Google Fonts via CSS
|
||||
|
||||
```html
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
function setup() {
|
||||
textFont('Inter');
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
Google Fonts work without `loadFont()` but only for `text()` — not for `textToPoints()`. For particle text, you need `loadFont()` with an OTF/TTF file.
|
||||
|
||||
## Text Rendering
|
||||
|
||||
### Basic Text
|
||||
|
||||
```javascript
|
||||
textSize(32);
|
||||
textAlign(CENTER, CENTER);
|
||||
text('Hello World', width/2, height/2);
|
||||
```
|
||||
|
||||
### Text Properties
|
||||
|
||||
```javascript
|
||||
textSize(48); // pixel size
|
||||
textAlign(LEFT, TOP); // horizontal: LEFT, CENTER, RIGHT
|
||||
// vertical: TOP, CENTER, BOTTOM, BASELINE
|
||||
textLeading(40); // line spacing (for multi-line text)
|
||||
textStyle(BOLD); // NORMAL, BOLD, ITALIC, BOLDITALIC
|
||||
textWrap(WORD); // WORD or CHAR (for text() with max width)
|
||||
```
|
||||
|
||||
### Text Metrics
|
||||
|
||||
```javascript
|
||||
let w = textWidth('Hello'); // pixel width of string
|
||||
let a = textAscent(); // height above baseline
|
||||
let d = textDescent(); // height below baseline
|
||||
let totalH = a + d; // full line height
|
||||
```
|
||||
|
||||
### Text Bounding Box
|
||||
|
||||
```javascript
|
||||
let bounds = myFont.textBounds('Hello', x, y, size);
|
||||
// bounds = { x, y, w, h }
|
||||
// Useful for positioning, collision, background rectangles
|
||||
```
|
||||
|
||||
### Multi-Line Text
|
||||
|
||||
```javascript
|
||||
// With max width — auto wraps
|
||||
textWrap(WORD);
|
||||
text('Long text that wraps within the given width', x, y, maxWidth);
|
||||
|
||||
// With max width AND height — clips
|
||||
text('Very long text', x, y, maxWidth, maxHeight);
|
||||
```
|
||||
|
||||
## textToPoints() — Text as Particles
|
||||
|
||||
Convert text outline to array of points. Requires a loaded font (OTF/TTF via `loadFont()`).
|
||||
|
||||
```javascript
|
||||
let font;
|
||||
let points;
|
||||
|
||||
function preload() {
|
||||
font = loadFont('font.otf'); // MUST be loadFont, not CSS
|
||||
}
|
||||
|
||||
function setup() {
|
||||
createCanvas(1200, 600);
|
||||
points = font.textToPoints('HELLO', 100, 400, 200, {
|
||||
sampleFactor: 0.1, // lower = more points (0.1-0.5 typical)
|
||||
simplifyThreshold: 0
|
||||
});
|
||||
}
|
||||
|
||||
function draw() {
|
||||
background(0);
|
||||
for (let pt of points) {
|
||||
let n = noise(pt.x * 0.01, pt.y * 0.01, frameCount * 0.01);
|
||||
fill(255, n * 255);
|
||||
noStroke();
|
||||
ellipse(pt.x + random(-2, 2), pt.y + random(-2, 2), 3);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Particle Text Class
|
||||
|
||||
```javascript
|
||||
class TextParticle {
|
||||
constructor(target) {
|
||||
this.target = createVector(target.x, target.y);
|
||||
this.pos = createVector(random(width), random(height));
|
||||
this.vel = createVector(0, 0);
|
||||
this.acc = createVector(0, 0);
|
||||
this.maxSpeed = 10;
|
||||
this.maxForce = 0.5;
|
||||
}
|
||||
|
||||
arrive() {
|
||||
let desired = p5.Vector.sub(this.target, this.pos);
|
||||
let d = desired.mag();
|
||||
let speed = d < 100 ? map(d, 0, 100, 0, this.maxSpeed) : this.maxSpeed;
|
||||
desired.setMag(speed);
|
||||
let steer = p5.Vector.sub(desired, this.vel);
|
||||
steer.limit(this.maxForce);
|
||||
this.acc.add(steer);
|
||||
}
|
||||
|
||||
flee(target, radius) {
|
||||
let d = this.pos.dist(target);
|
||||
if (d < radius) {
|
||||
let desired = p5.Vector.sub(this.pos, target);
|
||||
desired.setMag(this.maxSpeed);
|
||||
let steer = p5.Vector.sub(desired, this.vel);
|
||||
steer.limit(this.maxForce * 2);
|
||||
this.acc.add(steer);
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
this.vel.add(this.acc);
|
||||
this.vel.limit(this.maxSpeed);
|
||||
this.pos.add(this.vel);
|
||||
this.acc.mult(0);
|
||||
}
|
||||
|
||||
display() {
|
||||
fill(255);
|
||||
noStroke();
|
||||
ellipse(this.pos.x, this.pos.y, 3);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: particles form text, scatter from mouse
|
||||
let textParticles = [];
|
||||
for (let pt of points) {
|
||||
textParticles.push(new TextParticle(pt));
|
||||
}
|
||||
|
||||
function draw() {
|
||||
background(0);
|
||||
for (let p of textParticles) {
|
||||
p.arrive();
|
||||
p.flee(createVector(mouseX, mouseY), 80);
|
||||
p.update();
|
||||
p.display();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Kinetic Typography
|
||||
|
||||
### Wave Text
|
||||
|
||||
```javascript
|
||||
function waveText(str, x, y, size, amplitude, frequency) {
|
||||
textSize(size);
|
||||
textAlign(LEFT, BASELINE);
|
||||
let xOff = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let yOff = sin(frameCount * 0.05 + i * frequency) * amplitude;
|
||||
text(str[i], x + xOff, y + yOff);
|
||||
xOff += textWidth(str[i]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Typewriter Effect
|
||||
|
||||
```javascript
|
||||
class Typewriter {
|
||||
constructor(str, x, y, speed = 50) {
|
||||
this.str = str;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.speed = speed; // ms per character
|
||||
this.startTime = millis();
|
||||
this.cursor = true;
|
||||
}
|
||||
|
||||
display() {
|
||||
let elapsed = millis() - this.startTime;
|
||||
let chars = min(floor(elapsed / this.speed), this.str.length);
|
||||
let visible = this.str.substring(0, chars);
|
||||
|
||||
textAlign(LEFT, TOP);
|
||||
text(visible, this.x, this.y);
|
||||
|
||||
// Blinking cursor
|
||||
if (chars < this.str.length && floor(millis() / 500) % 2 === 0) {
|
||||
let cursorX = this.x + textWidth(visible);
|
||||
line(cursorX, this.y, cursorX, this.y + textAscent() + textDescent());
|
||||
}
|
||||
}
|
||||
|
||||
isDone() { return millis() - this.startTime >= this.str.length * this.speed; }
|
||||
}
|
||||
```
|
||||
|
||||
### Character-by-Character Animation
|
||||
|
||||
```javascript
|
||||
function animatedText(str, x, y, size, delay = 50) {
|
||||
textSize(size);
|
||||
textAlign(LEFT, BASELINE);
|
||||
let xOff = 0;
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let charStart = i * delay;
|
||||
let t = constrain((millis() - charStart) / 500, 0, 1);
|
||||
let et = easeOutElastic(t);
|
||||
|
||||
push();
|
||||
translate(x + xOff, y);
|
||||
scale(et);
|
||||
let alpha = t * 255;
|
||||
fill(255, alpha);
|
||||
text(str[i], 0, 0);
|
||||
pop();
|
||||
|
||||
xOff += textWidth(str[i]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Text as Mask
|
||||
|
||||
```javascript
|
||||
let textBuffer;
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800);
|
||||
textBuffer = createGraphics(width, height);
|
||||
textBuffer.background(0);
|
||||
textBuffer.fill(255);
|
||||
textBuffer.textSize(200);
|
||||
textBuffer.textAlign(CENTER, CENTER);
|
||||
textBuffer.text('MASK', width/2, height/2);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Draw content
|
||||
background(0);
|
||||
// ... render something colorful
|
||||
|
||||
// Apply text mask (show content only where text is white)
|
||||
loadPixels();
|
||||
textBuffer.loadPixels();
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
let maskVal = textBuffer.pixels[i]; // white = show, black = hide
|
||||
pixels[i + 3] = maskVal; // set alpha from mask
|
||||
}
|
||||
updatePixels();
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Text Sizing
|
||||
|
||||
```javascript
|
||||
function responsiveTextSize(baseSize, baseWidth = 1920) {
|
||||
return baseSize * (width / baseWidth);
|
||||
}
|
||||
|
||||
// Usage
|
||||
textSize(responsiveTextSize(48));
|
||||
text('Scales with canvas', width/2, height/2);
|
||||
```
|
||||
@@ -0,0 +1,895 @@
|
||||
# Visual Effects
|
||||
|
||||
## Noise
|
||||
|
||||
### Perlin Noise Basics
|
||||
|
||||
```javascript
|
||||
noiseSeed(42);
|
||||
noiseDetail(4, 0.5); // octaves, falloff
|
||||
|
||||
// 1D noise — smooth undulation
|
||||
let y = noise(x * 0.01); // returns 0.0 to 1.0
|
||||
|
||||
// 2D noise — terrain/texture
|
||||
let v = noise(x * 0.005, y * 0.005);
|
||||
|
||||
// 3D noise — animated 2D field (z = time)
|
||||
let v = noise(x * 0.005, y * 0.005, frameCount * 0.005);
|
||||
```
|
||||
|
||||
The scale factor (0.005 etc.) is critical:
|
||||
- `0.001` — very smooth, large features
|
||||
- `0.005` — smooth, medium features
|
||||
- `0.01` — standard generative art scale
|
||||
- `0.05` — detailed, small features
|
||||
- `0.1` — near-random, grainy
|
||||
|
||||
### Fractal Brownian Motion (fBM)
|
||||
|
||||
Layered noise octaves for natural-looking texture. Each octave adds detail at smaller scale.
|
||||
|
||||
```javascript
|
||||
function fbm(x, y, octaves = 6, lacunarity = 2.0, gain = 0.5) {
|
||||
let value = 0;
|
||||
let amplitude = 1.0;
|
||||
let frequency = 1.0;
|
||||
let maxValue = 0;
|
||||
for (let i = 0; i < octaves; i++) {
|
||||
value += noise(x * frequency, y * frequency) * amplitude;
|
||||
maxValue += amplitude;
|
||||
amplitude *= gain;
|
||||
frequency *= lacunarity;
|
||||
}
|
||||
return value / maxValue;
|
||||
}
|
||||
```
|
||||
|
||||
### Domain Warping
|
||||
|
||||
Feed noise output back as input coordinates for flowing organic distortion.
|
||||
|
||||
```javascript
|
||||
function domainWarp(x, y, scale, strength, time) {
|
||||
// First warp pass
|
||||
let qx = fbm(x + 0.0, y + 0.0);
|
||||
let qy = fbm(x + 5.2, y + 1.3);
|
||||
|
||||
// Second warp pass (feed back)
|
||||
let rx = fbm(x + strength * qx + 1.7, y + strength * qy + 9.2, 4, 2, 0.5);
|
||||
let ry = fbm(x + strength * qx + 8.3, y + strength * qy + 2.8, 4, 2, 0.5);
|
||||
|
||||
return fbm(x + strength * rx + time, y + strength * ry + time);
|
||||
}
|
||||
```
|
||||
|
||||
### Curl Noise
|
||||
|
||||
Divergence-free noise field. Particles following curl noise never converge or diverge — they flow in smooth, swirling patterns.
|
||||
|
||||
```javascript
|
||||
function curlNoise(x, y, scale, time) {
|
||||
let eps = 0.001;
|
||||
// Partial derivatives via finite differences
|
||||
let dndx = (noise(x * scale + eps, y * scale, time) -
|
||||
noise(x * scale - eps, y * scale, time)) / (2 * eps);
|
||||
let dndy = (noise(x * scale, y * scale + eps, time) -
|
||||
noise(x * scale, y * scale - eps, time)) / (2 * eps);
|
||||
// Curl = perpendicular to gradient
|
||||
return createVector(dndy, -dndx);
|
||||
}
|
||||
```
|
||||
|
||||
## Flow Fields
|
||||
|
||||
A grid of vectors that steer particles. The foundational generative art technique.
|
||||
|
||||
```javascript
|
||||
class FlowField {
|
||||
constructor(resolution, noiseScale) {
|
||||
this.resolution = resolution;
|
||||
this.cols = ceil(width / resolution);
|
||||
this.rows = ceil(height / resolution);
|
||||
this.field = new Array(this.cols * this.rows);
|
||||
this.noiseScale = noiseScale;
|
||||
}
|
||||
|
||||
update(time) {
|
||||
for (let i = 0; i < this.cols; i++) {
|
||||
for (let j = 0; j < this.rows; j++) {
|
||||
let angle = noise(i * this.noiseScale, j * this.noiseScale, time) * TWO_PI * 2;
|
||||
this.field[i + j * this.cols] = p5.Vector.fromAngle(angle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lookup(x, y) {
|
||||
let col = constrain(floor(x / this.resolution), 0, this.cols - 1);
|
||||
let row = constrain(floor(y / this.resolution), 0, this.rows - 1);
|
||||
return this.field[col + row * this.cols].copy();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Flow Field Particle
|
||||
|
||||
```javascript
|
||||
class FlowParticle {
|
||||
constructor(x, y) {
|
||||
this.pos = createVector(x, y);
|
||||
this.vel = createVector(0, 0);
|
||||
this.acc = createVector(0, 0);
|
||||
this.prev = this.pos.copy();
|
||||
this.maxSpeed = 2;
|
||||
this.life = 1.0;
|
||||
}
|
||||
|
||||
follow(field) {
|
||||
let force = field.lookup(this.pos.x, this.pos.y);
|
||||
force.mult(0.5); // force magnitude
|
||||
this.acc.add(force);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.prev = this.pos.copy();
|
||||
this.vel.add(this.acc);
|
||||
this.vel.limit(this.maxSpeed);
|
||||
this.pos.add(this.vel);
|
||||
this.acc.mult(0);
|
||||
this.life -= 0.001;
|
||||
}
|
||||
|
||||
edges() {
|
||||
if (this.pos.x > width) this.pos.x = 0;
|
||||
if (this.pos.x < 0) this.pos.x = width;
|
||||
if (this.pos.y > height) this.pos.y = 0;
|
||||
if (this.pos.y < 0) this.pos.y = height;
|
||||
this.prev = this.pos.copy(); // prevent wrap line
|
||||
}
|
||||
|
||||
display(buffer) {
|
||||
buffer.stroke(255, this.life * 30);
|
||||
buffer.strokeWeight(0.5);
|
||||
buffer.line(this.prev.x, this.prev.y, this.pos.x, this.pos.y);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Particle Systems
|
||||
|
||||
### Basic Physics Particle
|
||||
|
||||
```javascript
|
||||
class Particle {
|
||||
constructor(x, y) {
|
||||
this.pos = createVector(x, y);
|
||||
this.vel = p5.Vector.random2D().mult(random(1, 3));
|
||||
this.acc = createVector(0, 0);
|
||||
this.life = 255;
|
||||
this.decay = random(1, 5);
|
||||
this.size = random(3, 8);
|
||||
}
|
||||
|
||||
applyForce(f) { this.acc.add(f); }
|
||||
|
||||
update() {
|
||||
this.vel.add(this.acc);
|
||||
this.pos.add(this.vel);
|
||||
this.acc.mult(0);
|
||||
this.life -= this.decay;
|
||||
}
|
||||
|
||||
display() {
|
||||
noStroke();
|
||||
fill(255, this.life);
|
||||
ellipse(this.pos.x, this.pos.y, this.size);
|
||||
}
|
||||
|
||||
isDead() { return this.life <= 0; }
|
||||
}
|
||||
```
|
||||
|
||||
### Attractor-Driven Particles
|
||||
|
||||
```javascript
|
||||
class Attractor {
|
||||
constructor(x, y, strength) {
|
||||
this.pos = createVector(x, y);
|
||||
this.strength = strength;
|
||||
}
|
||||
|
||||
attract(particle) {
|
||||
let force = p5.Vector.sub(this.pos, particle.pos);
|
||||
let d = constrain(force.mag(), 5, 200);
|
||||
force.normalize();
|
||||
force.mult(this.strength / (d * d));
|
||||
particle.applyForce(force);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Boid Flocking
|
||||
|
||||
```javascript
|
||||
class Boid {
|
||||
constructor(x, y) {
|
||||
this.pos = createVector(x, y);
|
||||
this.vel = p5.Vector.random2D().mult(random(2, 4));
|
||||
this.acc = createVector(0, 0);
|
||||
this.maxForce = 0.2;
|
||||
this.maxSpeed = 4;
|
||||
this.perceptionRadius = 50;
|
||||
}
|
||||
|
||||
flock(boids) {
|
||||
let alignment = createVector(0, 0);
|
||||
let cohesion = createVector(0, 0);
|
||||
let separation = createVector(0, 0);
|
||||
let total = 0;
|
||||
|
||||
for (let other of boids) {
|
||||
let d = this.pos.dist(other.pos);
|
||||
if (other !== this && d < this.perceptionRadius) {
|
||||
alignment.add(other.vel);
|
||||
cohesion.add(other.pos);
|
||||
let diff = p5.Vector.sub(this.pos, other.pos);
|
||||
diff.div(d * d);
|
||||
separation.add(diff);
|
||||
total++;
|
||||
}
|
||||
}
|
||||
if (total > 0) {
|
||||
alignment.div(total).setMag(this.maxSpeed).sub(this.vel).limit(this.maxForce);
|
||||
cohesion.div(total).sub(this.pos).setMag(this.maxSpeed).sub(this.vel).limit(this.maxForce);
|
||||
separation.div(total).setMag(this.maxSpeed).sub(this.vel).limit(this.maxForce);
|
||||
}
|
||||
|
||||
this.acc.add(alignment.mult(1.0));
|
||||
this.acc.add(cohesion.mult(1.0));
|
||||
this.acc.add(separation.mult(1.5));
|
||||
}
|
||||
|
||||
update() {
|
||||
this.vel.add(this.acc);
|
||||
this.vel.limit(this.maxSpeed);
|
||||
this.pos.add(this.vel);
|
||||
this.acc.mult(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pixel Manipulation
|
||||
|
||||
### Reading and Writing Pixels
|
||||
|
||||
```javascript
|
||||
loadPixels();
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let idx = 4 * (y * width + x);
|
||||
let r = pixels[idx];
|
||||
let g = pixels[idx + 1];
|
||||
let b = pixels[idx + 2];
|
||||
let a = pixels[idx + 3];
|
||||
|
||||
// Modify
|
||||
pixels[idx] = 255 - r; // invert red
|
||||
pixels[idx + 1] = 255 - g; // invert green
|
||||
pixels[idx + 2] = 255 - b; // invert blue
|
||||
}
|
||||
}
|
||||
updatePixels();
|
||||
```
|
||||
|
||||
### Pixel-Level Noise Texture
|
||||
|
||||
```javascript
|
||||
loadPixels();
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
let x = (i / 4) % width;
|
||||
let y = floor((i / 4) / width);
|
||||
let n = noise(x * 0.01, y * 0.01, frameCount * 0.02);
|
||||
let c = n * 255;
|
||||
pixels[i] = c;
|
||||
pixels[i + 1] = c;
|
||||
pixels[i + 2] = c;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
updatePixels();
|
||||
```
|
||||
|
||||
### Built-in Filters
|
||||
|
||||
```javascript
|
||||
filter(BLUR, 3); // Gaussian blur (radius)
|
||||
filter(THRESHOLD, 0.5); // Black/white threshold
|
||||
filter(INVERT); // Color inversion
|
||||
filter(POSTERIZE, 4); // Reduce color levels
|
||||
filter(GRAY); // Desaturate
|
||||
filter(ERODE); // Thin bright areas
|
||||
filter(DILATE); // Expand bright areas
|
||||
filter(OPAQUE); // Remove transparency
|
||||
```
|
||||
|
||||
## Texture Generation
|
||||
|
||||
### Stippling / Pointillism
|
||||
|
||||
```javascript
|
||||
function stipple(buffer, density, minSize, maxSize) {
|
||||
buffer.loadPixels();
|
||||
for (let i = 0; i < density; i++) {
|
||||
let x = floor(random(width));
|
||||
let y = floor(random(height));
|
||||
let idx = 4 * (y * width + x);
|
||||
let brightness = (buffer.pixels[idx] + buffer.pixels[idx+1] + buffer.pixels[idx+2]) / 3;
|
||||
let size = map(brightness, 0, 255, maxSize, minSize);
|
||||
if (random() < map(brightness, 0, 255, 0.8, 0.1)) {
|
||||
noStroke();
|
||||
fill(buffer.pixels[idx], buffer.pixels[idx+1], buffer.pixels[idx+2]);
|
||||
ellipse(x, y, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Halftone
|
||||
|
||||
```javascript
|
||||
function halftone(sourceBuffer, dotSpacing, maxDotSize) {
|
||||
sourceBuffer.loadPixels();
|
||||
background(255);
|
||||
fill(0);
|
||||
noStroke();
|
||||
for (let y = 0; y < height; y += dotSpacing) {
|
||||
for (let x = 0; x < width; x += dotSpacing) {
|
||||
let idx = 4 * (y * width + x);
|
||||
let brightness = (sourceBuffer.pixels[idx] + sourceBuffer.pixels[idx+1] + sourceBuffer.pixels[idx+2]) / 3;
|
||||
let dotSize = map(brightness, 0, 255, maxDotSize, 0);
|
||||
ellipse(x + dotSpacing/2, y + dotSpacing/2, dotSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cross-Hatching
|
||||
|
||||
```javascript
|
||||
function crossHatch(x, y, w, h, value, spacing) {
|
||||
// value: 0 (dark) to 1 (light)
|
||||
let numLayers = floor(map(value, 0, 1, 4, 0));
|
||||
let angles = [PI/4, -PI/4, 0, PI/2];
|
||||
|
||||
for (let layer = 0; layer < numLayers; layer++) {
|
||||
push();
|
||||
translate(x + w/2, y + h/2);
|
||||
rotate(angles[layer]);
|
||||
let s = spacing + layer * 2;
|
||||
for (let i = -max(w, h); i < max(w, h); i += s) {
|
||||
line(i, -max(w, h), i, max(w, h));
|
||||
}
|
||||
pop();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Feedback Loops
|
||||
|
||||
### Frame Feedback (Echo/Trail)
|
||||
|
||||
```javascript
|
||||
let feedback;
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800);
|
||||
feedback = createGraphics(width, height);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Copy current feedback, slightly zoomed and rotated
|
||||
let temp = feedback.get();
|
||||
|
||||
feedback.push();
|
||||
feedback.translate(width/2, height/2);
|
||||
feedback.scale(1.005); // slow zoom
|
||||
feedback.rotate(0.002); // slow rotation
|
||||
feedback.translate(-width/2, -height/2);
|
||||
feedback.tint(255, 245); // slight fade
|
||||
feedback.image(temp, 0, 0);
|
||||
feedback.pop();
|
||||
|
||||
// Draw new content to feedback
|
||||
feedback.noStroke();
|
||||
feedback.fill(255);
|
||||
feedback.ellipse(mouseX, mouseY, 20);
|
||||
|
||||
// Show
|
||||
image(feedback, 0, 0);
|
||||
}
|
||||
```
|
||||
|
||||
### Bloom / Glow (Post-Processing)
|
||||
|
||||
Downsample the scene to a small buffer, blur it, overlay additively. Creates soft glow around bright areas. This is the standard generative art bloom technique.
|
||||
|
||||
```javascript
|
||||
let scene, bloomBuf;
|
||||
|
||||
function setup() {
|
||||
createCanvas(1080, 1080);
|
||||
scene = createGraphics(width, height);
|
||||
bloomBuf = createGraphics(width, height);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// 1. Render scene to offscreen buffer
|
||||
scene.background(0);
|
||||
scene.fill(255, 200, 100);
|
||||
scene.noStroke();
|
||||
// ... draw bright elements to scene ...
|
||||
|
||||
// 2. Build bloom: downsample → blur → upscale
|
||||
bloomBuf.clear();
|
||||
bloomBuf.image(scene, 0, 0, width / 4, height / 4); // 4x downsample
|
||||
bloomBuf.filter(BLUR, 6); // blur the small version
|
||||
|
||||
// 3. Composite: scene + additive bloom
|
||||
background(0);
|
||||
image(scene, 0, 0); // base layer
|
||||
blendMode(ADD); // additive = glow
|
||||
tint(255, 80); // control bloom intensity (0-255)
|
||||
image(bloomBuf, 0, 0, width, height); // upscale back to full size
|
||||
noTint();
|
||||
blendMode(BLEND); // ALWAYS reset blend mode
|
||||
}
|
||||
```
|
||||
|
||||
**Tuning:**
|
||||
- Downsample ratio (1/4 is standard, 1/8 for softer, 1/2 for tighter)
|
||||
- Blur radius (4-8 typical, higher = wider glow)
|
||||
- Tint alpha (40-120, controls glow intensity)
|
||||
- Update bloom every N frames to save perf: `if (frameCount % 2 === 0) { ... }`
|
||||
|
||||
**Common mistake:** Forgetting `blendMode(BLEND)` after the ADD pass — everything drawn after will be additive.
|
||||
|
||||
### Trail Buffer Brightness
|
||||
|
||||
Trail accumulation via `createGraphics()` + semi-transparent fade rect is the standard technique for particle trails, but **trails are always dimmer than you expect**. The fade rect's alpha compounds multiplicatively every frame.
|
||||
|
||||
```javascript
|
||||
// The fade rect alpha controls trail length AND brightness:
|
||||
trailBuf.fill(0, 0, 0, alpha);
|
||||
trailBuf.rect(0, 0, width, height);
|
||||
|
||||
// alpha=5 → very long trails, very dim (content fades to 50% in ~35 frames)
|
||||
// alpha=10 → long trails, dim
|
||||
// alpha=20 → medium trails, visible
|
||||
// alpha=40 → short trails, bright
|
||||
// alpha=80 → very short trails, crisp
|
||||
```
|
||||
|
||||
**The trap:** You set alpha=5 for long trails, but particle strokes at alpha=30 are invisible because they fade before accumulating enough density. Either:
|
||||
- **Boost stroke alpha** to 80-150 (not the intuitive 20-40)
|
||||
- **Reduce fade alpha** but accept shorter trails
|
||||
- **Use additive blending** for the strokes: bright particles accumulate, dim ones stay dark
|
||||
|
||||
```javascript
|
||||
// WRONG: low fade + low stroke = invisible
|
||||
trailBuf.fill(0, 0, 0, 5); // long trails
|
||||
trailBuf.rect(0, 0, W, H);
|
||||
trailBuf.stroke(255, 30); // too dim to ever accumulate
|
||||
trailBuf.line(px, py, x, y);
|
||||
|
||||
// RIGHT: low fade + high stroke = visible long trails
|
||||
trailBuf.fill(0, 0, 0, 5);
|
||||
trailBuf.rect(0, 0, W, H);
|
||||
trailBuf.stroke(255, 100); // bright enough to persist through fade
|
||||
trailBuf.line(px, py, x, y);
|
||||
```
|
||||
|
||||
### Reaction-Diffusion (Gray-Scott)
|
||||
|
||||
```javascript
|
||||
class ReactionDiffusion {
|
||||
constructor(w, h) {
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.a = new Float32Array(w * h).fill(1);
|
||||
this.b = new Float32Array(w * h).fill(0);
|
||||
this.nextA = new Float32Array(w * h);
|
||||
this.nextB = new Float32Array(w * h);
|
||||
this.dA = 1.0;
|
||||
this.dB = 0.5;
|
||||
this.feed = 0.055;
|
||||
this.kill = 0.062;
|
||||
}
|
||||
|
||||
seed(cx, cy, r) {
|
||||
for (let y = cy - r; y < cy + r; y++) {
|
||||
for (let x = cx - r; x < cx + r; x++) {
|
||||
if (dist(x, y, cx, cy) < r) {
|
||||
let idx = y * this.w + x;
|
||||
this.b[idx] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
step() {
|
||||
for (let y = 1; y < this.h - 1; y++) {
|
||||
for (let x = 1; x < this.w - 1; x++) {
|
||||
let idx = y * this.w + x;
|
||||
let a = this.a[idx], b = this.b[idx];
|
||||
let lapA = this.laplacian(this.a, x, y);
|
||||
let lapB = this.laplacian(this.b, x, y);
|
||||
let abb = a * b * b;
|
||||
this.nextA[idx] = constrain(a + this.dA * lapA - abb + this.feed * (1 - a), 0, 1);
|
||||
this.nextB[idx] = constrain(b + this.dB * lapB + abb - (this.kill + this.feed) * b, 0, 1);
|
||||
}
|
||||
}
|
||||
[this.a, this.nextA] = [this.nextA, this.a];
|
||||
[this.b, this.nextB] = [this.nextB, this.b];
|
||||
}
|
||||
|
||||
laplacian(arr, x, y) {
|
||||
let w = this.w;
|
||||
return arr[(y-1)*w+x] + arr[(y+1)*w+x] + arr[y*w+(x-1)] + arr[y*w+(x+1)]
|
||||
- 4 * arr[y*w+x];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pixel Sorting
|
||||
|
||||
```javascript
|
||||
function pixelSort(buffer, threshold, direction = 'horizontal') {
|
||||
buffer.loadPixels();
|
||||
let px = buffer.pixels;
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
for (let y = 0; y < height; y++) {
|
||||
let spans = findSpans(px, y, width, threshold, true);
|
||||
for (let span of spans) {
|
||||
sortSpan(px, span.start, span.end, y, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
buffer.updatePixels();
|
||||
}
|
||||
|
||||
function findSpans(px, row, w, threshold, horizontal) {
|
||||
let spans = [];
|
||||
let start = -1;
|
||||
for (let i = 0; i < w; i++) {
|
||||
let idx = horizontal ? 4 * (row * w + i) : 4 * (i * w + row);
|
||||
let brightness = (px[idx] + px[idx+1] + px[idx+2]) / 3;
|
||||
if (brightness > threshold && start === -1) {
|
||||
start = i;
|
||||
} else if (brightness <= threshold && start !== -1) {
|
||||
spans.push({ start, end: i });
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
if (start !== -1) spans.push({ start, end: w });
|
||||
return spans;
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Generative Techniques
|
||||
|
||||
### L-Systems (Lindenmayer Systems)
|
||||
|
||||
Grammar-based recursive growth for trees, plants, fractals.
|
||||
|
||||
```javascript
|
||||
class LSystem {
|
||||
constructor(axiom, rules) {
|
||||
this.axiom = axiom;
|
||||
this.rules = rules; // { 'F': 'F[+F]F[-F]F' }
|
||||
this.sentence = axiom;
|
||||
}
|
||||
|
||||
generate(iterations) {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
let next = '';
|
||||
for (let ch of this.sentence) {
|
||||
next += this.rules[ch] || ch;
|
||||
}
|
||||
this.sentence = next;
|
||||
}
|
||||
}
|
||||
|
||||
draw(len, angle) {
|
||||
for (let ch of this.sentence) {
|
||||
switch (ch) {
|
||||
case 'F': line(0, 0, 0, -len); translate(0, -len); break;
|
||||
case '+': rotate(angle); break;
|
||||
case '-': rotate(-angle); break;
|
||||
case '[': push(); break;
|
||||
case ']': pop(); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: fractal plant
|
||||
let lsys = new LSystem('X', {
|
||||
'X': 'F+[[X]-X]-F[-FX]+X',
|
||||
'F': 'FF'
|
||||
});
|
||||
lsys.generate(5);
|
||||
translate(width/2, height);
|
||||
lsys.draw(4, radians(25));
|
||||
```
|
||||
|
||||
### Circle Packing
|
||||
|
||||
Fill a space with non-overlapping circles of varying size.
|
||||
|
||||
```javascript
|
||||
class PackedCircle {
|
||||
constructor(x, y, r) {
|
||||
this.x = x; this.y = y; this.r = r;
|
||||
this.growing = true;
|
||||
}
|
||||
|
||||
grow() { if (this.growing) this.r += 0.5; }
|
||||
|
||||
overlaps(other) {
|
||||
let d = dist(this.x, this.y, other.x, other.y);
|
||||
return d < this.r + other.r + 2; // +2 gap
|
||||
}
|
||||
|
||||
atEdge() {
|
||||
return this.x - this.r < 0 || this.x + this.r > width ||
|
||||
this.y - this.r < 0 || this.y + this.r > height;
|
||||
}
|
||||
}
|
||||
|
||||
let circles = [];
|
||||
|
||||
function packStep() {
|
||||
// Try to place new circle
|
||||
for (let attempts = 0; attempts < 100; attempts++) {
|
||||
let x = random(width), y = random(height);
|
||||
let valid = true;
|
||||
for (let c of circles) {
|
||||
if (dist(x, y, c.x, c.y) < c.r + 2) { valid = false; break; }
|
||||
}
|
||||
if (valid) { circles.push(new PackedCircle(x, y, 1)); break; }
|
||||
}
|
||||
|
||||
// Grow existing circles
|
||||
for (let c of circles) {
|
||||
if (!c.growing) continue;
|
||||
c.grow();
|
||||
if (c.atEdge()) { c.growing = false; continue; }
|
||||
for (let other of circles) {
|
||||
if (c !== other && c.overlaps(other)) { c.growing = false; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Voronoi Diagram (Fortune's Algorithm Approximation)
|
||||
|
||||
```javascript
|
||||
// Simple brute-force Voronoi (for small point counts)
|
||||
function drawVoronoi(points, colors) {
|
||||
loadPixels();
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let minDist = Infinity;
|
||||
let closest = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
let d = (x - points[i].x) ** 2 + (y - points[i].y) ** 2; // magSq
|
||||
if (d < minDist) { minDist = d; closest = i; }
|
||||
}
|
||||
let idx = 4 * (y * width + x);
|
||||
let c = colors[closest % colors.length];
|
||||
pixels[idx] = red(c);
|
||||
pixels[idx+1] = green(c);
|
||||
pixels[idx+2] = blue(c);
|
||||
pixels[idx+3] = 255;
|
||||
}
|
||||
}
|
||||
updatePixels();
|
||||
}
|
||||
```
|
||||
|
||||
### Fractal Trees
|
||||
|
||||
```javascript
|
||||
function fractalTree(x, y, len, angle, depth, branchAngle) {
|
||||
if (depth <= 0 || len < 2) return;
|
||||
|
||||
let x2 = x + Math.cos(angle) * len;
|
||||
let y2 = y + Math.sin(angle) * len;
|
||||
|
||||
strokeWeight(map(depth, 0, 10, 0.5, 4));
|
||||
line(x, y, x2, y2);
|
||||
|
||||
let shrink = 0.67 + noise(x * 0.01, y * 0.01) * 0.15;
|
||||
fractalTree(x2, y2, len * shrink, angle - branchAngle, depth - 1, branchAngle);
|
||||
fractalTree(x2, y2, len * shrink, angle + branchAngle, depth - 1, branchAngle);
|
||||
}
|
||||
|
||||
// Usage
|
||||
fractalTree(width/2, height, 120, -HALF_PI, 10, PI/6);
|
||||
```
|
||||
|
||||
### Strange Attractors
|
||||
|
||||
```javascript
|
||||
// Clifford Attractor
|
||||
function cliffordAttractor(a, b, c, d, iterations) {
|
||||
let x = 0, y = 0;
|
||||
beginShape(POINTS);
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
let nx = Math.sin(a * y) + c * Math.cos(a * x);
|
||||
let ny = Math.sin(b * x) + d * Math.cos(b * y);
|
||||
x = nx; y = ny;
|
||||
let px = map(x, -3, 3, 0, width);
|
||||
let py = map(y, -3, 3, 0, height);
|
||||
vertex(px, py);
|
||||
}
|
||||
endShape();
|
||||
}
|
||||
|
||||
// De Jong Attractor
|
||||
function deJongAttractor(a, b, c, d, iterations) {
|
||||
let x = 0, y = 0;
|
||||
beginShape(POINTS);
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
let nx = Math.sin(a * y) - Math.cos(b * x);
|
||||
let ny = Math.sin(c * x) - Math.cos(d * y);
|
||||
x = nx; y = ny;
|
||||
let px = map(x, -2.5, 2.5, 0, width);
|
||||
let py = map(y, -2.5, 2.5, 0, height);
|
||||
vertex(px, py);
|
||||
}
|
||||
endShape();
|
||||
}
|
||||
```
|
||||
|
||||
### Poisson Disk Sampling
|
||||
|
||||
Even distribution that looks natural — better than pure random for placing elements.
|
||||
|
||||
```javascript
|
||||
function poissonDiskSampling(r, k = 30) {
|
||||
let cellSize = r / Math.sqrt(2);
|
||||
let cols = Math.ceil(width / cellSize);
|
||||
let rows = Math.ceil(height / cellSize);
|
||||
let grid = new Array(cols * rows).fill(-1);
|
||||
let points = [];
|
||||
let active = [];
|
||||
|
||||
function gridIndex(x, y) {
|
||||
return Math.floor(x / cellSize) + Math.floor(y / cellSize) * cols;
|
||||
}
|
||||
|
||||
// Seed
|
||||
let p0 = createVector(random(width), random(height));
|
||||
points.push(p0);
|
||||
active.push(p0);
|
||||
grid[gridIndex(p0.x, p0.y)] = 0;
|
||||
|
||||
while (active.length > 0) {
|
||||
let idx = Math.floor(Math.random() * active.length);
|
||||
let pos = active[idx];
|
||||
let found = false;
|
||||
|
||||
for (let n = 0; n < k; n++) {
|
||||
let angle = Math.random() * TWO_PI;
|
||||
let mag = r + Math.random() * r;
|
||||
let sample = createVector(pos.x + Math.cos(angle) * mag, pos.y + Math.sin(angle) * mag);
|
||||
|
||||
if (sample.x < 0 || sample.x >= width || sample.y < 0 || sample.y >= height) continue;
|
||||
|
||||
let col = Math.floor(sample.x / cellSize);
|
||||
let row = Math.floor(sample.y / cellSize);
|
||||
let ok = true;
|
||||
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
let nc = col + dx, nr = row + dy;
|
||||
if (nc >= 0 && nc < cols && nr >= 0 && nr < rows) {
|
||||
let gi = nc + nr * cols;
|
||||
if (grid[gi] !== -1 && points[grid[gi]].dist(sample) < r) { ok = false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
points.push(sample);
|
||||
active.push(sample);
|
||||
grid[gridIndex(sample.x, sample.y)] = points.length - 1;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) active.splice(idx, 1);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
```
|
||||
|
||||
## Addon Libraries
|
||||
|
||||
### p5.brush — Natural Media
|
||||
|
||||
Hand-drawn, organic aesthetics. Watercolor, charcoal, pen, marker. Requires **p5.js 2.x + WEBGL**.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/p5.brush@latest/dist/p5.brush.js"></script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
function setup() {
|
||||
createCanvas(1200, 1200, WEBGL);
|
||||
brush.scaleBrushes(3); // essential for proper sizing
|
||||
translate(-width/2, -height/2); // WEBGL origin is center
|
||||
brush.pick('2B'); // pencil brush
|
||||
brush.stroke(50, 50, 50);
|
||||
brush.strokeWeight(2);
|
||||
brush.line(100, 100, 500, 500);
|
||||
brush.pick('watercolor');
|
||||
brush.fill('#4a90d9', 150);
|
||||
brush.circle(400, 400, 200);
|
||||
}
|
||||
```
|
||||
|
||||
Built-in brushes: `2B`, `HB`, `2H`, `cpencil`, `pen`, `rotring`, `spray`, `marker`, `charcoal`, `hatch_brush`.
|
||||
Built-in vector fields: `hand`, `curved`, `zigzag`, `waves`, `seabed`, `spiral`, `columns`.
|
||||
|
||||
### p5.grain — Film Grain & Texture
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/p5.grain@0.7.0/p5.grain.min.js"></script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
function draw() {
|
||||
// ... render scene ...
|
||||
applyMonochromaticGrain(42); // uniform grain
|
||||
// or: applyChromaticGrain(42); // per-channel randomization
|
||||
}
|
||||
```
|
||||
|
||||
### CCapture.js — Deterministic Video Capture
|
||||
|
||||
Records canvas at fixed framerate regardless of actual render speed. Essential for complex generative art.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/ccapture.js-npmfixed/build/CCapture.all.min.js"></script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
let capturer;
|
||||
|
||||
function setup() {
|
||||
createCanvas(1920, 1080);
|
||||
capturer = new CCapture({
|
||||
format: 'webm',
|
||||
framerate: 60,
|
||||
quality: 99,
|
||||
// timeLimit: 10, // auto-stop after N seconds
|
||||
// motionBlurFrames: 4 // supersampled motion blur
|
||||
});
|
||||
}
|
||||
|
||||
function startRecording() {
|
||||
capturer.start();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// ... render frame ...
|
||||
if (capturer) capturer.capture(document.querySelector('canvas'));
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
capturer.stop();
|
||||
capturer.save(); // triggers download
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,423 @@
|
||||
# WebGL and 3D
|
||||
|
||||
## WebGL Mode Setup
|
||||
|
||||
```javascript
|
||||
function setup() {
|
||||
createCanvas(1920, 1080, WEBGL);
|
||||
// Origin is CENTER, not top-left
|
||||
// Y-axis points UP (opposite of 2D mode)
|
||||
// Z-axis points toward viewer
|
||||
}
|
||||
```
|
||||
|
||||
### Coordinate Conversion (WEBGL to P2D-like)
|
||||
|
||||
```javascript
|
||||
function draw() {
|
||||
translate(-width/2, -height/2); // shift origin to top-left
|
||||
// Now coordinates work like P2D
|
||||
}
|
||||
```
|
||||
|
||||
## 3D Primitives
|
||||
|
||||
```javascript
|
||||
box(w, h, d); // rectangular prism
|
||||
sphere(radius, detailX, detailY);
|
||||
cylinder(radius, height, detailX, detailY);
|
||||
cone(radius, height, detailX, detailY);
|
||||
torus(radius, tubeRadius, detailX, detailY);
|
||||
plane(width, height); // flat rectangle
|
||||
ellipsoid(rx, ry, rz); // stretched sphere
|
||||
```
|
||||
|
||||
### 3D Transforms
|
||||
|
||||
```javascript
|
||||
push();
|
||||
translate(x, y, z);
|
||||
rotateX(angleX);
|
||||
rotateY(angleY);
|
||||
rotateZ(angleZ);
|
||||
scale(s);
|
||||
box(100);
|
||||
pop();
|
||||
```
|
||||
|
||||
## Camera
|
||||
|
||||
### Default Camera
|
||||
|
||||
```javascript
|
||||
camera(
|
||||
eyeX, eyeY, eyeZ, // camera position
|
||||
centerX, centerY, centerZ, // look-at target
|
||||
upX, upY, upZ // up direction
|
||||
);
|
||||
|
||||
// Default: camera(0, 0, (height/2)/tan(PI/6), 0, 0, 0, 0, 1, 0)
|
||||
```
|
||||
|
||||
### Orbit Control
|
||||
|
||||
```javascript
|
||||
function draw() {
|
||||
orbitControl(); // mouse drag to rotate, scroll to zoom
|
||||
box(200);
|
||||
}
|
||||
```
|
||||
|
||||
### createCamera
|
||||
|
||||
```javascript
|
||||
let cam;
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800, WEBGL);
|
||||
cam = createCamera();
|
||||
cam.setPosition(300, -200, 500);
|
||||
cam.lookAt(0, 0, 0);
|
||||
}
|
||||
|
||||
// Camera methods
|
||||
cam.setPosition(x, y, z);
|
||||
cam.lookAt(x, y, z);
|
||||
cam.move(dx, dy, dz); // relative to camera orientation
|
||||
cam.pan(angle); // horizontal rotation
|
||||
cam.tilt(angle); // vertical rotation
|
||||
cam.roll(angle); // z-axis rotation
|
||||
cam.slerp(otherCam, t); // smooth interpolation between cameras
|
||||
```
|
||||
|
||||
### Perspective and Orthographic
|
||||
|
||||
```javascript
|
||||
// Perspective (default)
|
||||
perspective(fov, aspect, near, far);
|
||||
// fov: field of view in radians (PI/3 default)
|
||||
// aspect: width/height
|
||||
// near/far: clipping planes
|
||||
|
||||
// Orthographic (no depth foreshortening)
|
||||
ortho(-width/2, width/2, -height/2, height/2, 0, 2000);
|
||||
```
|
||||
|
||||
## Lighting
|
||||
|
||||
```javascript
|
||||
// Ambient (uniform, no direction)
|
||||
ambientLight(50, 50, 50); // dim fill light
|
||||
|
||||
// Directional (parallel rays, like sun)
|
||||
directionalLight(255, 255, 255, 0, -1, 0); // color + direction
|
||||
|
||||
// Point (radiates from position)
|
||||
pointLight(255, 200, 150, 200, -300, 400); // color + position
|
||||
|
||||
// Spot (cone from position toward target)
|
||||
spotLight(255, 255, 255, // color
|
||||
0, -300, 300, // position
|
||||
0, 1, -1, // direction
|
||||
PI / 4, 5); // angle, concentration
|
||||
|
||||
// Image-based lighting
|
||||
imageLight(myHDRI);
|
||||
|
||||
// No lights (flat shading)
|
||||
noLights();
|
||||
|
||||
// Quick default lighting
|
||||
lights();
|
||||
```
|
||||
|
||||
### Three-Point Lighting Setup
|
||||
|
||||
```javascript
|
||||
function setupLighting() {
|
||||
ambientLight(30, 30, 40); // dim blue fill
|
||||
|
||||
// Key light (main, warm)
|
||||
directionalLight(255, 240, 220, -1, -1, -1);
|
||||
|
||||
// Fill light (softer, cooler, opposite side)
|
||||
directionalLight(80, 100, 140, 1, -0.5, -1);
|
||||
|
||||
// Rim light (behind subject, for edge definition)
|
||||
pointLight(200, 200, 255, 0, -200, -400);
|
||||
}
|
||||
```
|
||||
|
||||
## Materials
|
||||
|
||||
```javascript
|
||||
// Normal material (debug — colors from surface normals)
|
||||
normalMaterial();
|
||||
|
||||
// Ambient (responds only to ambientLight)
|
||||
ambientMaterial(200, 100, 100);
|
||||
|
||||
// Emissive (self-lit, no shadows)
|
||||
emissiveMaterial(255, 0, 100);
|
||||
|
||||
// Specular (shiny reflections)
|
||||
specularMaterial(255);
|
||||
shininess(50); // 1-200 (higher = tighter highlight)
|
||||
metalness(100); // 0-200 (metallic reflection)
|
||||
|
||||
// Fill works too (no lighting response)
|
||||
fill(255, 0, 0);
|
||||
```
|
||||
|
||||
### Texture
|
||||
|
||||
```javascript
|
||||
let img;
|
||||
function preload() { img = loadImage('texture.jpg'); }
|
||||
|
||||
function draw() {
|
||||
texture(img);
|
||||
textureMode(NORMAL); // UV coords 0-1
|
||||
// textureMode(IMAGE); // UV coords in pixels
|
||||
textureWrap(REPEAT); // or CLAMP, MIRROR
|
||||
box(200);
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Geometry
|
||||
|
||||
### buildGeometry
|
||||
|
||||
```javascript
|
||||
let myShape;
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800, WEBGL);
|
||||
myShape = buildGeometry(() => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
push();
|
||||
translate(random(-200, 200), random(-200, 200), random(-200, 200));
|
||||
sphere(10);
|
||||
pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function draw() {
|
||||
model(myShape); // renders once-built geometry efficiently
|
||||
}
|
||||
```
|
||||
|
||||
### beginGeometry / endGeometry
|
||||
|
||||
```javascript
|
||||
beginGeometry();
|
||||
// draw shapes here
|
||||
box(50);
|
||||
translate(100, 0, 0);
|
||||
sphere(30);
|
||||
let geo = endGeometry();
|
||||
|
||||
model(geo); // reuse
|
||||
```
|
||||
|
||||
### Manual Geometry (p5.Geometry)
|
||||
|
||||
```javascript
|
||||
let geo = new p5.Geometry(detailX, detailY, function() {
|
||||
for (let i = 0; i <= detailX; i++) {
|
||||
for (let j = 0; j <= detailY; j++) {
|
||||
let u = i / detailX;
|
||||
let v = j / detailY;
|
||||
let x = cos(u * TWO_PI) * (100 + 30 * cos(v * TWO_PI));
|
||||
let y = sin(u * TWO_PI) * (100 + 30 * cos(v * TWO_PI));
|
||||
let z = 30 * sin(v * TWO_PI);
|
||||
this.vertices.push(createVector(x, y, z));
|
||||
this.uvs.push(u, v);
|
||||
}
|
||||
}
|
||||
this.computeFaces();
|
||||
this.computeNormals();
|
||||
});
|
||||
```
|
||||
|
||||
## GLSL Shaders
|
||||
|
||||
### createShader (Vertex + Fragment)
|
||||
|
||||
```javascript
|
||||
let myShader;
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800, WEBGL);
|
||||
|
||||
let vert = `
|
||||
precision mediump float;
|
||||
attribute vec3 aPosition;
|
||||
attribute vec2 aTexCoord;
|
||||
varying vec2 vTexCoord;
|
||||
uniform mat4 uModelViewMatrix;
|
||||
uniform mat4 uProjectionMatrix;
|
||||
void main() {
|
||||
vTexCoord = aTexCoord;
|
||||
vec4 pos = uProjectionMatrix * uModelViewMatrix * vec4(aPosition, 1.0);
|
||||
gl_Position = pos;
|
||||
}
|
||||
`;
|
||||
|
||||
let frag = `
|
||||
precision mediump float;
|
||||
varying vec2 vTexCoord;
|
||||
uniform float uTime;
|
||||
uniform vec2 uResolution;
|
||||
|
||||
void main() {
|
||||
vec2 uv = vTexCoord;
|
||||
vec3 col = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0, 2, 4));
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
myShader = createShader(vert, frag);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
shader(myShader);
|
||||
myShader.setUniform('uTime', millis() / 1000.0);
|
||||
myShader.setUniform('uResolution', [width, height]);
|
||||
rect(0, 0, width, height);
|
||||
resetShader();
|
||||
}
|
||||
```
|
||||
|
||||
### createFilterShader (Post-Processing)
|
||||
|
||||
Simpler — only needs a fragment shader. Automatically gets the canvas as a texture.
|
||||
|
||||
```javascript
|
||||
let blurShader;
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800, WEBGL);
|
||||
|
||||
blurShader = createFilterShader(`
|
||||
precision mediump float;
|
||||
varying vec2 vTexCoord;
|
||||
uniform sampler2D tex0;
|
||||
uniform vec2 texelSize;
|
||||
|
||||
void main() {
|
||||
vec4 sum = vec4(0.0);
|
||||
for (int x = -2; x <= 2; x++) {
|
||||
for (int y = -2; y <= 2; y++) {
|
||||
sum += texture2D(tex0, vTexCoord + vec2(float(x), float(y)) * texelSize);
|
||||
}
|
||||
}
|
||||
gl_FragColor = sum / 25.0;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Draw scene normally
|
||||
background(0);
|
||||
fill(255, 0, 0);
|
||||
sphere(100);
|
||||
|
||||
// Apply post-processing filter
|
||||
filter(blurShader);
|
||||
}
|
||||
```
|
||||
|
||||
### Common Shader Uniforms
|
||||
|
||||
```javascript
|
||||
myShader.setUniform('uTime', millis() / 1000.0);
|
||||
myShader.setUniform('uResolution', [width, height]);
|
||||
myShader.setUniform('uMouse', [mouseX / width, mouseY / height]);
|
||||
myShader.setUniform('uTexture', myGraphics); // pass p5.Graphics as texture
|
||||
myShader.setUniform('uValue', 0.5); // float
|
||||
myShader.setUniform('uColor', [1.0, 0.0, 0.5, 1.0]); // vec4
|
||||
```
|
||||
|
||||
### Shader Recipes
|
||||
|
||||
**Chromatic Aberration:**
|
||||
```glsl
|
||||
vec4 r = texture2D(tex0, vTexCoord + vec2(0.005, 0.0));
|
||||
vec4 g = texture2D(tex0, vTexCoord);
|
||||
vec4 b = texture2D(tex0, vTexCoord - vec2(0.005, 0.0));
|
||||
gl_FragColor = vec4(r.r, g.g, b.b, 1.0);
|
||||
```
|
||||
|
||||
**Vignette:**
|
||||
```glsl
|
||||
float d = distance(vTexCoord, vec2(0.5));
|
||||
float v = smoothstep(0.7, 0.4, d);
|
||||
gl_FragColor = texture2D(tex0, vTexCoord) * v;
|
||||
```
|
||||
|
||||
**Scanlines:**
|
||||
```glsl
|
||||
float scanline = sin(vTexCoord.y * uResolution.y * 3.14159) * 0.04;
|
||||
vec4 col = texture2D(tex0, vTexCoord);
|
||||
gl_FragColor = col - scanline;
|
||||
```
|
||||
|
||||
## Framebuffers
|
||||
|
||||
```javascript
|
||||
let fbo;
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800, WEBGL);
|
||||
fbo = createFramebuffer();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Render to framebuffer
|
||||
fbo.begin();
|
||||
clear();
|
||||
rotateY(frameCount * 0.01);
|
||||
box(200);
|
||||
fbo.end();
|
||||
|
||||
// Use framebuffer as texture
|
||||
texture(fbo.color);
|
||||
plane(width, height);
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Pass Rendering
|
||||
|
||||
```javascript
|
||||
let sceneBuffer, blurBuffer;
|
||||
|
||||
function setup() {
|
||||
createCanvas(800, 800, WEBGL);
|
||||
sceneBuffer = createFramebuffer();
|
||||
blurBuffer = createFramebuffer();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
// Pass 1: render scene
|
||||
sceneBuffer.begin();
|
||||
clear();
|
||||
lights();
|
||||
rotateY(frameCount * 0.01);
|
||||
box(200);
|
||||
sceneBuffer.end();
|
||||
|
||||
// Pass 2: blur
|
||||
blurBuffer.begin();
|
||||
shader(blurShader);
|
||||
blurShader.setUniform('uTexture', sceneBuffer.color);
|
||||
rect(0, 0, width, height);
|
||||
resetShader();
|
||||
blurBuffer.end();
|
||||
|
||||
// Final: composite
|
||||
texture(blurBuffer.color);
|
||||
plane(width, height);
|
||||
}
|
||||
```
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* p5.js Skill — Headless Frame Export
|
||||
*
|
||||
* Captures frames from a p5.js sketch using Puppeteer (headless Chrome).
|
||||
* Uses noLoop() + redraw() for DETERMINISTIC frame-by-frame control.
|
||||
*
|
||||
* IMPORTANT: Your sketch must call noLoop() in setup() and set
|
||||
* window._p5Ready = true when initialized. This script calls redraw()
|
||||
* for each frame capture, ensuring exact 1:1 correspondence between
|
||||
* frameCount and captured frames.
|
||||
*
|
||||
* If the sketch does NOT set window._p5Ready, the script falls back to
|
||||
* a timed capture mode (less precise, may drop/duplicate frames).
|
||||
*
|
||||
* Usage:
|
||||
* node export-frames.js sketch.html [options]
|
||||
*
|
||||
* Options:
|
||||
* --output <dir> Output directory (default: ./frames)
|
||||
* --width <px> Canvas width (default: 1920)
|
||||
* --height <px> Canvas height (default: 1080)
|
||||
* --frames <n> Number of frames to capture (default: 1)
|
||||
* --fps <n> Target FPS for timed fallback mode (default: 30)
|
||||
* --wait <ms> Wait before first capture (default: 2000)
|
||||
* --selector <sel> Canvas CSS selector (default: canvas)
|
||||
*
|
||||
* Examples:
|
||||
* node export-frames.js sketch.html --frames 1 # single PNG
|
||||
* node export-frames.js sketch.html --frames 300 --fps 30 # 10s at 30fps
|
||||
* node export-frames.js sketch.html --width 3840 --height 2160 # 4K still
|
||||
*
|
||||
* Sketch template for deterministic capture:
|
||||
* function setup() {
|
||||
* createCanvas(1920, 1080);
|
||||
* pixelDensity(1);
|
||||
* noLoop(); // REQUIRED for deterministic capture
|
||||
* window._p5Ready = true; // REQUIRED to signal readiness
|
||||
* }
|
||||
* function draw() { ... }
|
||||
*/
|
||||
|
||||
const puppeteer = require('puppeteer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Parse CLI arguments
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const opts = {
|
||||
input: null,
|
||||
output: './frames',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frames: 1,
|
||||
fps: 30,
|
||||
wait: 2000,
|
||||
selector: 'canvas',
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith('--')) {
|
||||
const key = args[i].slice(2);
|
||||
const val = args[i + 1];
|
||||
if (key in opts && val !== undefined) {
|
||||
opts[key] = isNaN(Number(val)) ? val : Number(val);
|
||||
i++;
|
||||
}
|
||||
} else if (!opts.input) {
|
||||
opts.input = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.input) {
|
||||
console.error('Usage: node export-frames.js <sketch.html> [options]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
const inputPath = path.resolve(opts.input);
|
||||
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.error(`File not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
fs.mkdirSync(opts.output, { recursive: true });
|
||||
|
||||
console.log(`Capturing ${opts.frames} frame(s) from ${opts.input}`);
|
||||
console.log(`Resolution: ${opts.width}x${opts.height}`);
|
||||
console.log(`Output: ${opts.output}/`);
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--allow-file-access-from-files',
|
||||
],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.setViewport({
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
|
||||
// Navigate to sketch
|
||||
const fileUrl = `file://${inputPath}`;
|
||||
await page.goto(fileUrl, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
|
||||
// Wait for canvas to appear
|
||||
await page.waitForSelector(opts.selector, { timeout: 10000 });
|
||||
|
||||
// Detect capture mode: deterministic (noLoop+redraw) vs timed (fallback)
|
||||
let deterministic = false;
|
||||
try {
|
||||
await page.waitForFunction('window._p5Ready === true', { timeout: 5000 });
|
||||
deterministic = true;
|
||||
console.log(`Mode: deterministic (noLoop + redraw)`);
|
||||
} catch {
|
||||
console.log(`Mode: timed fallback (sketch does not set window._p5Ready)`);
|
||||
console.log(` For frame-perfect capture, add noLoop() and window._p5Ready=true to setup()`);
|
||||
await new Promise(r => setTimeout(r, opts.wait));
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < opts.frames; i++) {
|
||||
if (deterministic) {
|
||||
// Advance exactly one frame
|
||||
await page.evaluate(() => { redraw(); });
|
||||
// Brief settle time for render to complete
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
}
|
||||
|
||||
const frameName = `frame-${String(i).padStart(4, '0')}.png`;
|
||||
const framePath = path.join(opts.output, frameName);
|
||||
|
||||
// Capture the canvas element
|
||||
const canvas = await page.$(opts.selector);
|
||||
if (!canvas) {
|
||||
console.error('Canvas element not found');
|
||||
break;
|
||||
}
|
||||
|
||||
await canvas.screenshot({ path: framePath, type: 'png' });
|
||||
|
||||
// Progress
|
||||
if (i % 30 === 0 || i === opts.frames - 1) {
|
||||
const pct = ((i + 1) / opts.frames * 100).toFixed(1);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
process.stdout.write(`\r Frame ${i + 1}/${opts.frames} (${pct}%) — ${elapsed}s`);
|
||||
}
|
||||
|
||||
// In timed mode, wait between frames
|
||||
if (!deterministic && i < opts.frames - 1) {
|
||||
await new Promise(r => setTimeout(r, 1000 / opts.fps));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n Done.');
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
# p5.js Skill — Headless Render Pipeline
|
||||
# Renders a p5.js sketch to MP4 video via Puppeteer + ffmpeg
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/render.sh sketch.html output.mp4 [options]
|
||||
#
|
||||
# Options:
|
||||
# --width Canvas width (default: 1920)
|
||||
# --height Canvas height (default: 1080)
|
||||
# --fps Frames per second (default: 30)
|
||||
# --duration Duration in seconds (default: 10)
|
||||
# --quality CRF value 0-51 (default: 18, lower = better)
|
||||
# --frames-only Only export frames, skip MP4 encoding
|
||||
#
|
||||
# Examples:
|
||||
# bash scripts/render.sh sketch.html output.mp4
|
||||
# bash scripts/render.sh sketch.html output.mp4 --duration 30 --fps 60
|
||||
# bash scripts/render.sh sketch.html output.mp4 --width 3840 --height 2160
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Defaults
|
||||
WIDTH=1920
|
||||
HEIGHT=1080
|
||||
FPS=30
|
||||
DURATION=10
|
||||
CRF=18
|
||||
FRAMES_ONLY=false
|
||||
|
||||
# Parse arguments
|
||||
INPUT="${1:?Usage: render.sh <input.html> <output.mp4> [options]}"
|
||||
OUTPUT="${2:?Usage: render.sh <input.html> <output.mp4> [options]}"
|
||||
shift 2
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--width) WIDTH="$2"; shift 2 ;;
|
||||
--height) HEIGHT="$2"; shift 2 ;;
|
||||
--fps) FPS="$2"; shift 2 ;;
|
||||
--duration) DURATION="$2"; shift 2 ;;
|
||||
--quality) CRF="$2"; shift 2 ;;
|
||||
--frames-only) FRAMES_ONLY=true; shift ;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TOTAL_FRAMES=$((FPS * DURATION))
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FRAME_DIR=$(mktemp -d)
|
||||
|
||||
echo "=== p5.js Render Pipeline ==="
|
||||
echo "Input: $INPUT"
|
||||
echo "Output: $OUTPUT"
|
||||
echo "Resolution: ${WIDTH}x${HEIGHT}"
|
||||
echo "FPS: $FPS"
|
||||
echo "Duration: ${DURATION}s (${TOTAL_FRAMES} frames)"
|
||||
echo "Quality: CRF $CRF"
|
||||
echo "Frame dir: $FRAME_DIR"
|
||||
echo ""
|
||||
|
||||
# Check dependencies
|
||||
command -v node >/dev/null 2>&1 || { echo "Error: Node.js required"; exit 1; }
|
||||
if [ "$FRAMES_ONLY" = false ]; then
|
||||
command -v ffmpeg >/dev/null 2>&1 || { echo "Error: ffmpeg required for MP4"; exit 1; }
|
||||
fi
|
||||
|
||||
# Step 1: Capture frames via Puppeteer
|
||||
echo "Step 1/2: Capturing ${TOTAL_FRAMES} frames..."
|
||||
node "$SCRIPT_DIR/export-frames.js" \
|
||||
"$INPUT" \
|
||||
--output "$FRAME_DIR" \
|
||||
--width "$WIDTH" \
|
||||
--height "$HEIGHT" \
|
||||
--frames "$TOTAL_FRAMES" \
|
||||
--fps "$FPS"
|
||||
|
||||
echo "Frames captured to $FRAME_DIR"
|
||||
|
||||
if [ "$FRAMES_ONLY" = true ]; then
|
||||
echo "Frames saved to: $FRAME_DIR"
|
||||
echo "To encode manually:"
|
||||
echo " ffmpeg -framerate $FPS -i $FRAME_DIR/frame-%04d.png -c:v libx264 -crf $CRF -pix_fmt yuv420p $OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Step 2: Encode to MP4
|
||||
echo "Step 2/2: Encoding MP4..."
|
||||
ffmpeg -y \
|
||||
-framerate "$FPS" \
|
||||
-i "$FRAME_DIR/frame-%04d.png" \
|
||||
-c:v libx264 \
|
||||
-preset slow \
|
||||
-crf "$CRF" \
|
||||
-pix_fmt yuv420p \
|
||||
-movflags +faststart \
|
||||
"$OUTPUT" \
|
||||
2>"$FRAME_DIR/ffmpeg.log"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$FRAME_DIR"
|
||||
|
||||
# Report
|
||||
FILE_SIZE=$(ls -lh "$OUTPUT" | awk '{print $5}')
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "Output: $OUTPUT ($FILE_SIZE)"
|
||||
echo "Duration: ${DURATION}s at ${FPS}fps, ${WIDTH}x${HEIGHT}"
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# p5.js Skill — Local Development Server
|
||||
# Serves the current directory over HTTP for loading local assets (fonts, images)
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/serve.sh [port] [directory]
|
||||
#
|
||||
# Examples:
|
||||
# bash scripts/serve.sh # serve CWD on port 8080
|
||||
# bash scripts/serve.sh 3000 # serve CWD on port 3000
|
||||
# bash scripts/serve.sh 8080 ./my-project # serve specific directory
|
||||
|
||||
PORT="${1:-8080}"
|
||||
DIR="${2:-.}"
|
||||
|
||||
echo "=== p5.js Dev Server ==="
|
||||
echo "Serving: $(cd "$DIR" && pwd)"
|
||||
echo "URL: http://localhost:$PORT"
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
cd "$DIR" && python3 -m http.server "$PORT" 2>/dev/null || {
|
||||
echo "Python3 not found. Trying Node.js..."
|
||||
npx serve -l "$PORT" "$DIR" 2>/dev/null || {
|
||||
echo "Error: Need python3 or npx (Node.js) for local server"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# p5.js Skill — Dependency Verification
|
||||
# Run: bash skills/creative/p5js/scripts/setup.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
fail() { echo -e "${RED}[FAIL]${NC} $1"; }
|
||||
|
||||
echo "=== p5.js Skill — Setup Check ==="
|
||||
echo ""
|
||||
|
||||
# Required: Node.js (for Puppeteer headless export)
|
||||
if command -v node &>/dev/null; then
|
||||
NODE_VER=$(node -v)
|
||||
ok "Node.js $NODE_VER"
|
||||
else
|
||||
warn "Node.js not found — optional, needed for headless export"
|
||||
echo " Install: https://nodejs.org/ or 'brew install node'"
|
||||
fi
|
||||
|
||||
# Required: npm (for Puppeteer install)
|
||||
if command -v npm &>/dev/null; then
|
||||
NPM_VER=$(npm -v)
|
||||
ok "npm $NPM_VER"
|
||||
else
|
||||
warn "npm not found — optional, needed for headless export"
|
||||
fi
|
||||
|
||||
# Optional: Puppeteer
|
||||
if node -e "require('puppeteer')" 2>/dev/null; then
|
||||
ok "Puppeteer installed"
|
||||
else
|
||||
warn "Puppeteer not installed — needed for headless export"
|
||||
echo " Install: npm install puppeteer"
|
||||
fi
|
||||
|
||||
# Optional: ffmpeg (for MP4 encoding from frame sequences)
|
||||
if command -v ffmpeg &>/dev/null; then
|
||||
FFMPEG_VER=$(ffmpeg -version 2>&1 | head -1 | awk '{print $3}')
|
||||
ok "ffmpeg $FFMPEG_VER"
|
||||
else
|
||||
warn "ffmpeg not found — needed for MP4 export"
|
||||
echo " Install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)"
|
||||
fi
|
||||
|
||||
# Optional: Python3 (for local server)
|
||||
if command -v python3 &>/dev/null; then
|
||||
PY_VER=$(python3 --version 2>&1 | awk '{print $2}')
|
||||
ok "Python $PY_VER (for local server: python3 -m http.server)"
|
||||
else
|
||||
warn "Python3 not found — needed for local file serving"
|
||||
fi
|
||||
|
||||
# Browser check (macOS)
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
if open -Ra "Google Chrome" 2>/dev/null; then
|
||||
ok "Google Chrome found"
|
||||
elif open -Ra "Safari" 2>/dev/null; then
|
||||
ok "Safari found"
|
||||
else
|
||||
warn "No browser detected"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Core Requirements ==="
|
||||
echo " A modern browser (Chrome/Firefox/Safari/Edge)"
|
||||
echo " p5.js loaded via CDN — no local install needed"
|
||||
echo ""
|
||||
echo "=== Optional (for export) ==="
|
||||
echo " Node.js + Puppeteer — headless frame capture"
|
||||
echo " ffmpeg — frame sequence to MP4"
|
||||
echo " Python3 — local development server"
|
||||
echo ""
|
||||
echo "=== Quick Start ==="
|
||||
echo " 1. Create an HTML file with inline p5.js sketch"
|
||||
echo " 2. Open in browser: open sketch.html"
|
||||
echo " 3. Press 's' to save PNG, 'g' to save GIF"
|
||||
echo ""
|
||||
echo "Setup check complete."
|
||||
@@ -0,0 +1,395 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
p5.js Interactive Viewer Template
|
||||
=================================
|
||||
USE THIS AS THE STARTING POINT for interactive generative art sketches.
|
||||
|
||||
FIXED (keep as-is):
|
||||
✓ Layout structure (sidebar + canvas)
|
||||
✓ Seed navigation (prev/next/random/jump)
|
||||
✓ Action buttons (regenerate, reset, download PNG)
|
||||
✓ Responsive canvas sizing
|
||||
✓ Parameter update + regeneration wiring
|
||||
|
||||
VARIABLE (replace for each project):
|
||||
✗ The p5.js algorithm (setup/draw/classes)
|
||||
✗ The PARAMS object (define what your art needs)
|
||||
✗ The parameter controls in the sidebar (sliders, pickers)
|
||||
✗ The color palette
|
||||
✗ The title and description
|
||||
|
||||
For headless export: add noLoop() and window._p5Ready=true in setup().
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Generative Art Viewer</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.11.3/p5.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: #0a0a0f;
|
||||
color: #c8c8d0;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Sidebar --- */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
background: #12121a;
|
||||
border-right: 1px solid #1e1e2a;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.sidebar h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #e8e8f0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sidebar .subtitle {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* --- Seed Controls --- */
|
||||
.seed-display {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #e8e8f0;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: #1a1a25;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.seed-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.seed-nav button {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.seed-jump {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.seed-jump input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
background: #1a1a25;
|
||||
border: 1px solid #2a2a35;
|
||||
border-radius: 4px;
|
||||
color: #c8c8d0;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.seed-jump button { padding: 6px 12px; font-size: 12px; }
|
||||
|
||||
/* --- Parameter Controls --- */
|
||||
.control-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.control-group label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.control-group .value {
|
||||
color: #aaa;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.control-group input[type="range"] {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
background: #2a2a35;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
.control-group input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #6a9bcc;
|
||||
cursor: pointer;
|
||||
}
|
||||
.control-group input[type="color"] {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
border: 1px solid #2a2a35;
|
||||
border-radius: 4px;
|
||||
background: #1a1a25;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- Buttons --- */
|
||||
button {
|
||||
padding: 8px 12px;
|
||||
background: #1e1e2a;
|
||||
border: 1px solid #2a2a35;
|
||||
border-radius: 4px;
|
||||
color: #c8c8d0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover { background: #2a2a3a; }
|
||||
button.primary { background: #2a4a6a; border-color: #3a5a7a; }
|
||||
button.primary:hover { background: #3a5a7a; }
|
||||
|
||||
.actions { display: flex; flex-direction: column; gap: 6px; }
|
||||
.actions button { width: 100%; }
|
||||
|
||||
/* --- Canvas Area --- */
|
||||
.canvas-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: #08080c;
|
||||
}
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- === SIDEBAR === -->
|
||||
<div class="sidebar">
|
||||
<!-- FIXED: Title (customize text, keep structure) -->
|
||||
<div>
|
||||
<h1 id="art-title">Generative Sketch</h1>
|
||||
<div class="subtitle" id="art-subtitle">p5.js generative art</div>
|
||||
</div>
|
||||
|
||||
<!-- FIXED: Seed Navigation -->
|
||||
<div>
|
||||
<div class="section-title">Seed</div>
|
||||
<div class="seed-display" id="seed-display">42</div>
|
||||
<div class="seed-nav">
|
||||
<button onclick="changeSeed(-1)">◀ Prev</button>
|
||||
<button onclick="changeSeed(1)">Next ▶</button>
|
||||
<button onclick="randomizeSeed()">Random</button>
|
||||
</div>
|
||||
<div class="seed-jump">
|
||||
<input type="number" id="seed-input" placeholder="Seed #" min="0">
|
||||
<button onclick="jumpToSeed()">Go</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VARIABLE: Parameters (customize for each project) -->
|
||||
<div id="params-section">
|
||||
<div class="section-title">Parameters</div>
|
||||
|
||||
<!-- === REPLACE THESE WITH YOUR PARAMETERS === -->
|
||||
<div class="control-group">
|
||||
<label>Count <span class="value" id="count-val">500</span></label>
|
||||
<input type="range" id="count" min="50" max="2000" step="50" value="500"
|
||||
oninput="updateParam('count', +this.value)">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Scale <span class="value" id="scale-val">0.005</span></label>
|
||||
<input type="range" id="scale" min="0.001" max="0.02" step="0.001" value="0.005"
|
||||
oninput="updateParam('scale', +this.value)">
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>Speed <span class="value" id="speed-val">2.0</span></label>
|
||||
<input type="range" id="speed" min="0.5" max="5" step="0.1" value="2.0"
|
||||
oninput="updateParam('speed', +this.value)">
|
||||
</div>
|
||||
<!-- === END PARAMETER CONTROLS === -->
|
||||
</div>
|
||||
|
||||
<!-- VARIABLE: Colors (optional — include if art needs adjustable palette) -->
|
||||
<!--
|
||||
<div>
|
||||
<div class="section-title">Colors</div>
|
||||
<div class="control-group">
|
||||
<label>Background</label>
|
||||
<input type="color" id="bg-color" value="#0a0a14"
|
||||
oninput="updateParam('bgColor', this.value)">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Primary</label>
|
||||
<input type="color" id="primary-color" value="#6a9bcc"
|
||||
oninput="updateParam('primaryColor', this.value)">
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- FIXED: Actions -->
|
||||
<div class="actions">
|
||||
<div class="section-title">Actions</div>
|
||||
<button class="primary" onclick="regenerate()">Regenerate</button>
|
||||
<button onclick="resetDefaults()">Reset Defaults</button>
|
||||
<button onclick="downloadPNG()">Download PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === CANVAS === -->
|
||||
<div class="canvas-area" id="canvas-container"></div>
|
||||
|
||||
<script>
|
||||
// ====================================================================
|
||||
// CONFIGURATION — REPLACE FOR EACH PROJECT
|
||||
// ====================================================================
|
||||
const DEFAULTS = {
|
||||
seed: 42,
|
||||
count: 500,
|
||||
scale: 0.005,
|
||||
speed: 2.0,
|
||||
// Add your parameters here
|
||||
};
|
||||
|
||||
let PARAMS = { ...DEFAULTS };
|
||||
|
||||
// ====================================================================
|
||||
// SEED NAVIGATION — FIXED (do not modify)
|
||||
// ====================================================================
|
||||
function changeSeed(delta) {
|
||||
PARAMS.seed = Math.max(0, PARAMS.seed + delta);
|
||||
document.getElementById('seed-display').textContent = PARAMS.seed;
|
||||
regenerate();
|
||||
}
|
||||
|
||||
function randomizeSeed() {
|
||||
PARAMS.seed = Math.floor(Math.random() * 99999);
|
||||
document.getElementById('seed-display').textContent = PARAMS.seed;
|
||||
regenerate();
|
||||
}
|
||||
|
||||
function jumpToSeed() {
|
||||
let v = parseInt(document.getElementById('seed-input').value);
|
||||
if (!isNaN(v) && v >= 0) {
|
||||
PARAMS.seed = v;
|
||||
document.getElementById('seed-display').textContent = PARAMS.seed;
|
||||
document.getElementById('seed-input').value = '';
|
||||
regenerate();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// PARAMETER UPDATES — CUSTOMIZE updateParam body as needed
|
||||
// ====================================================================
|
||||
function updateParam(name, value) {
|
||||
PARAMS[name] = value;
|
||||
let el = document.getElementById(name + '-val');
|
||||
if (el) el.textContent = typeof value === 'number' && value < 1 ? value.toFixed(3) : value;
|
||||
regenerate();
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
PARAMS = { ...DEFAULTS };
|
||||
// Reset all sliders to default values
|
||||
for (let [key, val] of Object.entries(DEFAULTS)) {
|
||||
let el = document.getElementById(key);
|
||||
if (el) el.value = val;
|
||||
let valEl = document.getElementById(key + '-val');
|
||||
if (valEl) valEl.textContent = typeof val === 'number' && val < 1 ? val.toFixed(3) : val;
|
||||
}
|
||||
document.getElementById('seed-display').textContent = PARAMS.seed;
|
||||
regenerate();
|
||||
}
|
||||
|
||||
function regenerate() {
|
||||
randomSeed(PARAMS.seed);
|
||||
noiseSeed(PARAMS.seed);
|
||||
// Clear and redraw
|
||||
clear();
|
||||
initializeArt();
|
||||
redraw();
|
||||
}
|
||||
|
||||
function downloadPNG() {
|
||||
saveCanvas('generative-art-seed-' + PARAMS.seed, 'png');
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// P5.JS SKETCH — REPLACE ENTIRELY FOR EACH PROJECT
|
||||
// ====================================================================
|
||||
|
||||
// Your state variables
|
||||
let particles = [];
|
||||
|
||||
function initializeArt() {
|
||||
// Initialize your generative system using PARAMS
|
||||
// This is called on every regenerate()
|
||||
particles = [];
|
||||
for (let i = 0; i < PARAMS.count; i++) {
|
||||
particles.push({
|
||||
x: random(width),
|
||||
y: random(height),
|
||||
vx: 0, vy: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
// Size canvas to fit container
|
||||
let container = document.getElementById('canvas-container');
|
||||
let size = Math.min(container.clientWidth - 40, container.clientHeight - 40, 1080);
|
||||
let cnv = createCanvas(size, size);
|
||||
cnv.parent('canvas-container');
|
||||
pixelDensity(1);
|
||||
colorMode(HSB, 360, 100, 100, 100);
|
||||
|
||||
randomSeed(PARAMS.seed);
|
||||
noiseSeed(PARAMS.seed);
|
||||
initializeArt();
|
||||
|
||||
// For interactive/animated sketches: remove noLoop()
|
||||
// For static generation: keep noLoop()
|
||||
noLoop();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
background(0, 0, 5);
|
||||
|
||||
// === YOUR ALGORITHM HERE ===
|
||||
// Use PARAMS.count, PARAMS.scale, PARAMS.speed, etc.
|
||||
noStroke();
|
||||
for (let p of particles) {
|
||||
let n = noise(p.x * PARAMS.scale, p.y * PARAMS.scale);
|
||||
let hue = (n * 200 + PARAMS.seed * 0.1) % 360;
|
||||
fill(hue, 70, 80, 60);
|
||||
circle(p.x, p.y, n * 10 + 2);
|
||||
}
|
||||
// === END ALGORITHM ===
|
||||
}
|
||||
|
||||
function windowResized() {
|
||||
let container = document.getElementById('canvas-container');
|
||||
let size = Math.min(container.clientWidth - 40, container.clientHeight - 40, 1080);
|
||||
resizeCanvas(size, size);
|
||||
regenerate();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,8 +14,12 @@ from agent.auxiliary_client import (
|
||||
resolve_vision_provider_client,
|
||||
resolve_provider_client,
|
||||
auxiliary_max_tokens_param,
|
||||
call_llm,
|
||||
_read_codex_access_token,
|
||||
_get_auxiliary_provider,
|
||||
_get_provider_chain,
|
||||
_is_payment_error,
|
||||
_try_payment_fallback,
|
||||
_resolve_forced_provider,
|
||||
_resolve_auto,
|
||||
)
|
||||
@@ -1106,3 +1110,183 @@ class TestAuxiliaryMaxTokensParam:
|
||||
patch("agent.auxiliary_client._read_codex_access_token", return_value=None):
|
||||
result = auxiliary_max_tokens_param(1024)
|
||||
assert result == {"max_tokens": 1024}
|
||||
|
||||
|
||||
# ── Payment / credit exhaustion fallback ─────────────────────────────────
|
||||
|
||||
|
||||
class TestIsPaymentError:
|
||||
"""_is_payment_error detects 402 and credit-related errors."""
|
||||
|
||||
def test_402_status_code(self):
|
||||
exc = Exception("Payment Required")
|
||||
exc.status_code = 402
|
||||
assert _is_payment_error(exc) is True
|
||||
|
||||
def test_402_with_credits_message(self):
|
||||
exc = Exception("You requested up to 65535 tokens, but can only afford 8029")
|
||||
exc.status_code = 402
|
||||
assert _is_payment_error(exc) is True
|
||||
|
||||
def test_429_with_credits_message(self):
|
||||
exc = Exception("insufficient credits remaining")
|
||||
exc.status_code = 429
|
||||
assert _is_payment_error(exc) is True
|
||||
|
||||
def test_429_without_credits_message_is_not_payment(self):
|
||||
"""Normal rate limits should NOT be treated as payment errors."""
|
||||
exc = Exception("Rate limit exceeded, try again in 2 seconds")
|
||||
exc.status_code = 429
|
||||
assert _is_payment_error(exc) is False
|
||||
|
||||
def test_generic_500_is_not_payment(self):
|
||||
exc = Exception("Internal server error")
|
||||
exc.status_code = 500
|
||||
assert _is_payment_error(exc) is False
|
||||
|
||||
def test_no_status_code_with_billing_message(self):
|
||||
exc = Exception("billing: payment required for this request")
|
||||
assert _is_payment_error(exc) is True
|
||||
|
||||
def test_no_status_code_no_message(self):
|
||||
exc = Exception("connection reset")
|
||||
assert _is_payment_error(exc) is False
|
||||
|
||||
|
||||
class TestGetProviderChain:
|
||||
"""_get_provider_chain() resolves functions at call time (testable)."""
|
||||
|
||||
def test_returns_five_entries(self):
|
||||
chain = _get_provider_chain()
|
||||
assert len(chain) == 5
|
||||
labels = [label for label, _ in chain]
|
||||
assert labels == ["openrouter", "nous", "local/custom", "openai-codex", "api-key"]
|
||||
|
||||
def test_picks_up_patched_functions(self):
|
||||
"""Patches on _try_* functions must be visible in the chain."""
|
||||
sentinel = lambda: ("patched", "model")
|
||||
with patch("agent.auxiliary_client._try_openrouter", sentinel):
|
||||
chain = _get_provider_chain()
|
||||
assert chain[0] == ("openrouter", sentinel)
|
||||
|
||||
|
||||
class TestTryPaymentFallback:
|
||||
"""_try_payment_fallback skips the failed provider and tries alternatives."""
|
||||
|
||||
def test_skips_failed_provider(self):
|
||||
mock_client = MagicMock()
|
||||
with patch("agent.auxiliary_client._try_openrouter", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._try_nous", return_value=(mock_client, "nous-model")), \
|
||||
patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"):
|
||||
client, model, label = _try_payment_fallback("openrouter", task="compression")
|
||||
assert client is mock_client
|
||||
assert model == "nous-model"
|
||||
assert label == "nous"
|
||||
|
||||
def test_returns_none_when_no_fallback(self):
|
||||
with patch("agent.auxiliary_client._try_openrouter", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._try_nous", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._try_codex", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._resolve_api_key_provider", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"):
|
||||
client, model, label = _try_payment_fallback("openrouter")
|
||||
assert client is None
|
||||
assert label == ""
|
||||
|
||||
def test_codex_alias_maps_to_chain_label(self):
|
||||
"""'codex' should map to 'openai-codex' in the skip set."""
|
||||
mock_client = MagicMock()
|
||||
with patch("agent.auxiliary_client._try_openrouter", return_value=(mock_client, "or-model")), \
|
||||
patch("agent.auxiliary_client._try_codex", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._read_main_provider", return_value="openai-codex"):
|
||||
client, model, label = _try_payment_fallback("openai-codex", task="vision")
|
||||
assert client is mock_client
|
||||
assert label == "openrouter"
|
||||
|
||||
def test_skips_to_codex_when_or_and_nous_fail(self):
|
||||
mock_codex = MagicMock()
|
||||
with patch("agent.auxiliary_client._try_openrouter", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._try_nous", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \
|
||||
patch("agent.auxiliary_client._try_codex", return_value=(mock_codex, "gpt-5.2-codex")), \
|
||||
patch("agent.auxiliary_client._read_main_provider", return_value="openrouter"):
|
||||
client, model, label = _try_payment_fallback("openrouter")
|
||||
assert client is mock_codex
|
||||
assert model == "gpt-5.2-codex"
|
||||
assert label == "openai-codex"
|
||||
|
||||
|
||||
class TestCallLlmPaymentFallback:
|
||||
"""call_llm() retries with a different provider on 402 / payment errors."""
|
||||
|
||||
def _make_402_error(self, msg="Payment Required: insufficient credits"):
|
||||
exc = Exception(msg)
|
||||
exc.status_code = 402
|
||||
return exc
|
||||
|
||||
def test_402_triggers_fallback(self, monkeypatch):
|
||||
"""When the primary provider returns 402, call_llm tries the next one."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
primary_client.chat.completions.create.side_effect = self._make_402_error()
|
||||
|
||||
fallback_client = MagicMock()
|
||||
fallback_response = MagicMock()
|
||||
fallback_client.chat.completions.create.return_value = fallback_response
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "google/gemini-3-flash-preview")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(fallback_client, "gpt-5.2-codex", "openai-codex")) as mock_fb:
|
||||
result = call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
assert result is fallback_response
|
||||
mock_fb.assert_called_once_with("openrouter", "compression")
|
||||
# Fallback call should use the fallback model
|
||||
fb_kwargs = fallback_client.chat.completions.create.call_args.kwargs
|
||||
assert fb_kwargs["model"] == "gpt-5.2-codex"
|
||||
|
||||
def test_non_payment_error_not_caught(self, monkeypatch):
|
||||
"""Non-payment errors (500, connection, etc.) should NOT trigger fallback."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
server_err = Exception("Internal Server Error")
|
||||
server_err.status_code = 500
|
||||
primary_client.chat.completions.create.side_effect = server_err
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "google/gemini-3-flash-preview")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)):
|
||||
with pytest.raises(Exception, match="Internal Server Error"):
|
||||
call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
def test_402_with_no_fallback_reraises(self, monkeypatch):
|
||||
"""When 402 hits and no fallback is available, the original error propagates."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "or-key")
|
||||
|
||||
primary_client = MagicMock()
|
||||
primary_client.chat.completions.create.side_effect = self._make_402_error()
|
||||
|
||||
with patch("agent.auxiliary_client._get_cached_client",
|
||||
return_value=(primary_client, "google/gemini-3-flash-preview")), \
|
||||
patch("agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("openrouter", "google/gemini-3-flash-preview", None, None)), \
|
||||
patch("agent.auxiliary_client._try_payment_fallback",
|
||||
return_value=(None, None, "")):
|
||||
with pytest.raises(Exception, match="insufficient credits"):
|
||||
call_llm(
|
||||
task="compression",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
|
||||
@@ -1018,6 +1018,9 @@ class TestToolUseEnforcementGuidance:
|
||||
def test_enforcement_models_includes_codex(self):
|
||||
assert "codex" in TOOL_USE_ENFORCEMENT_MODELS
|
||||
|
||||
def test_enforcement_models_includes_grok(self):
|
||||
assert "grok" in TOOL_USE_ENFORCEMENT_MODELS
|
||||
|
||||
def test_enforcement_models_is_tuple(self):
|
||||
assert isinstance(TOOL_USE_ENFORCEMENT_MODELS, tuple)
|
||||
|
||||
|
||||
@@ -102,6 +102,49 @@ class TestScanSkillCommands:
|
||||
assert "/disabled-skill" not in result
|
||||
|
||||
|
||||
def test_special_chars_stripped_from_cmd_key(self, tmp_path):
|
||||
"""Skill names with +, /, or other special chars produce clean cmd keys."""
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
||||
# Simulate a skill named "Jellyfin + Jellystat 24h Summary"
|
||||
skill_dir = tmp_path / "jellyfin-plus"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: Jellyfin + Jellystat 24h Summary\n"
|
||||
"description: Test skill\n---\n\nBody.\n"
|
||||
)
|
||||
result = scan_skill_commands()
|
||||
# The + should be stripped, not left as a literal character
|
||||
assert "/jellyfin-jellystat-24h-summary" in result
|
||||
# The old buggy key should NOT exist
|
||||
assert "/jellyfin-+-jellystat-24h-summary" not in result
|
||||
|
||||
def test_allspecial_name_skipped(self, tmp_path):
|
||||
"""Skill with name consisting only of special chars is silently skipped."""
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
||||
skill_dir = tmp_path / "bad-name"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: +++\ndescription: Bad skill\n---\n\nBody.\n"
|
||||
)
|
||||
result = scan_skill_commands()
|
||||
# Should not create a "/" key or any entry
|
||||
assert "/" not in result
|
||||
assert result == {}
|
||||
|
||||
def test_slash_in_name_stripped_from_cmd_key(self, tmp_path):
|
||||
"""Skill names with / chars produce clean cmd keys."""
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
||||
skill_dir = tmp_path / "sonarr-api"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: Sonarr v3/v4 API\n"
|
||||
"description: Test skill\n---\n\nBody.\n"
|
||||
)
|
||||
result = scan_skill_commands()
|
||||
assert "/sonarr-v3v4-api" in result
|
||||
assert any("/" in k[1:] for k in result) is False # no unescaped /
|
||||
|
||||
|
||||
class TestResolveSkillCommandKey:
|
||||
"""Telegram bot-command names disallow hyphens, so the menu registers
|
||||
skills with hyphens swapped for underscores. When Telegram autocomplete
|
||||
|
||||
@@ -114,7 +114,7 @@ class TestRunJobScript:
|
||||
def test_script_not_found(self, cron_env):
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
success, output = _run_job_script("/nonexistent/script.py")
|
||||
success, output = _run_job_script("nonexistent_script.py")
|
||||
assert success is False
|
||||
assert "not found" in output.lower()
|
||||
|
||||
@@ -198,7 +198,7 @@ class TestBuildJobPromptWithScript:
|
||||
|
||||
job = {
|
||||
"prompt": "Report status.",
|
||||
"script": "/nonexistent/script.py",
|
||||
"script": "nonexistent_monitor.py",
|
||||
}
|
||||
prompt = _build_job_prompt(job)
|
||||
assert "## Script Error" in prompt
|
||||
@@ -239,10 +239,10 @@ class TestCronjobToolScript:
|
||||
action="create",
|
||||
schedule="every 1h",
|
||||
prompt="Monitor things",
|
||||
script="/home/user/monitor.py",
|
||||
script="monitor.py",
|
||||
))
|
||||
assert result["success"] is True
|
||||
assert result["job"]["script"] == "/home/user/monitor.py"
|
||||
assert result["job"]["script"] == "monitor.py"
|
||||
|
||||
def test_update_script(self, cron_env, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
@@ -258,10 +258,10 @@ class TestCronjobToolScript:
|
||||
update_result = json.loads(cronjob(
|
||||
action="update",
|
||||
job_id=job_id,
|
||||
script="/new/script.py",
|
||||
script="new_script.py",
|
||||
))
|
||||
assert update_result["success"] is True
|
||||
assert update_result["job"]["script"] == "/new/script.py"
|
||||
assert update_result["job"]["script"] == "new_script.py"
|
||||
|
||||
def test_clear_script(self, cron_env, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
@@ -271,7 +271,7 @@ class TestCronjobToolScript:
|
||||
action="create",
|
||||
schedule="every 1h",
|
||||
prompt="Monitor things",
|
||||
script="/some/script.py",
|
||||
script="some_script.py",
|
||||
))
|
||||
job_id = create_result["job_id"]
|
||||
|
||||
@@ -291,10 +291,267 @@ class TestCronjobToolScript:
|
||||
action="create",
|
||||
schedule="every 1h",
|
||||
prompt="Monitor things",
|
||||
script="/path/to/script.py",
|
||||
script="data_collector.py",
|
||||
)
|
||||
|
||||
list_result = json.loads(cronjob(action="list"))
|
||||
assert list_result["success"] is True
|
||||
assert len(list_result["jobs"]) == 1
|
||||
assert list_result["jobs"][0]["script"] == "/path/to/script.py"
|
||||
assert list_result["jobs"][0]["script"] == "data_collector.py"
|
||||
|
||||
|
||||
class TestScriptPathContainment:
|
||||
"""Regression tests for path containment bypass in _run_job_script().
|
||||
|
||||
Prior to the fix, absolute paths and ~-prefixed paths bypassed the
|
||||
scripts_dir containment check entirely, allowing arbitrary script
|
||||
execution through the cron system.
|
||||
"""
|
||||
|
||||
def test_absolute_path_outside_scripts_dir_blocked(self, cron_env):
|
||||
"""Absolute paths outside ~/.hermes/scripts/ must be rejected."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
# Create a script outside the scripts dir
|
||||
outside_script = cron_env / "outside.py"
|
||||
outside_script.write_text('print("should not run")\n')
|
||||
|
||||
success, output = _run_job_script(str(outside_script))
|
||||
assert success is False
|
||||
assert "blocked" in output.lower() or "outside" in output.lower()
|
||||
|
||||
def test_absolute_path_tmp_blocked(self, cron_env):
|
||||
"""Absolute paths to /tmp must be rejected."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
success, output = _run_job_script("/tmp/evil.py")
|
||||
assert success is False
|
||||
assert "blocked" in output.lower() or "outside" in output.lower()
|
||||
|
||||
def test_tilde_path_blocked(self, cron_env):
|
||||
"""~ prefixed paths must be rejected (expanduser bypasses check)."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
success, output = _run_job_script("~/evil.py")
|
||||
assert success is False
|
||||
assert "blocked" in output.lower() or "outside" in output.lower()
|
||||
|
||||
def test_tilde_traversal_blocked(self, cron_env):
|
||||
"""~/../../../tmp/evil.py must be rejected."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
success, output = _run_job_script("~/../../../tmp/evil.py")
|
||||
assert success is False
|
||||
assert "blocked" in output.lower() or "outside" in output.lower()
|
||||
|
||||
def test_relative_traversal_still_blocked(self, cron_env):
|
||||
"""../../etc/passwd style traversal must still be blocked."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
success, output = _run_job_script("../../etc/passwd")
|
||||
assert success is False
|
||||
assert "blocked" in output.lower() or "outside" in output.lower()
|
||||
|
||||
def test_relative_path_inside_scripts_dir_allowed(self, cron_env):
|
||||
"""Relative paths within the scripts dir should still work."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
script = cron_env / "scripts" / "good.py"
|
||||
script.write_text('print("ok")\n')
|
||||
|
||||
success, output = _run_job_script("good.py")
|
||||
assert success is True
|
||||
assert output == "ok"
|
||||
|
||||
def test_subdirectory_inside_scripts_dir_allowed(self, cron_env):
|
||||
"""Relative paths to subdirectories within scripts/ should work."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
subdir = cron_env / "scripts" / "monitors"
|
||||
subdir.mkdir()
|
||||
script = subdir / "check.py"
|
||||
script.write_text('print("sub ok")\n')
|
||||
|
||||
success, output = _run_job_script("monitors/check.py")
|
||||
assert success is True
|
||||
assert output == "sub ok"
|
||||
|
||||
def test_absolute_path_inside_scripts_dir_allowed(self, cron_env):
|
||||
"""Absolute paths that resolve WITHIN scripts/ should work."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
script = cron_env / "scripts" / "abs_ok.py"
|
||||
script.write_text('print("abs ok")\n')
|
||||
|
||||
success, output = _run_job_script(str(script))
|
||||
assert success is True
|
||||
assert output == "abs ok"
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="Symlinks require elevated privileges on Windows",
|
||||
)
|
||||
def test_symlink_escape_blocked(self, cron_env, tmp_path):
|
||||
"""Symlinks pointing outside scripts/ must be rejected."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
# Create a script outside the scripts dir
|
||||
outside = tmp_path / "outside_evil.py"
|
||||
outside.write_text('print("escaped")\n')
|
||||
|
||||
# Create a symlink inside scripts/ pointing outside
|
||||
link = cron_env / "scripts" / "sneaky.py"
|
||||
link.symlink_to(outside)
|
||||
|
||||
success, output = _run_job_script("sneaky.py")
|
||||
assert success is False
|
||||
assert "blocked" in output.lower() or "outside" in output.lower()
|
||||
|
||||
|
||||
class TestCronjobToolScriptValidation:
|
||||
"""Test API-boundary validation of cron script paths in cronjob_tools."""
|
||||
|
||||
def test_create_with_absolute_script_rejected(self, cron_env, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
result = json.loads(cronjob(
|
||||
action="create",
|
||||
schedule="every 1h",
|
||||
prompt="Monitor things",
|
||||
script="/home/user/evil.py",
|
||||
))
|
||||
assert result["success"] is False
|
||||
assert "relative" in result["error"].lower() or "absolute" in result["error"].lower()
|
||||
|
||||
def test_create_with_tilde_script_rejected(self, cron_env, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
result = json.loads(cronjob(
|
||||
action="create",
|
||||
schedule="every 1h",
|
||||
prompt="Monitor things",
|
||||
script="~/monitor.py",
|
||||
))
|
||||
assert result["success"] is False
|
||||
assert "relative" in result["error"].lower() or "absolute" in result["error"].lower()
|
||||
|
||||
def test_create_with_traversal_script_rejected(self, cron_env, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
result = json.loads(cronjob(
|
||||
action="create",
|
||||
schedule="every 1h",
|
||||
prompt="Monitor things",
|
||||
script="../../etc/passwd",
|
||||
))
|
||||
assert result["success"] is False
|
||||
assert "escapes" in result["error"].lower() or "traversal" in result["error"].lower()
|
||||
|
||||
def test_create_with_relative_script_allowed(self, cron_env, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
result = json.loads(cronjob(
|
||||
action="create",
|
||||
schedule="every 1h",
|
||||
prompt="Monitor things",
|
||||
script="monitor.py",
|
||||
))
|
||||
assert result["success"] is True
|
||||
assert result["job"]["script"] == "monitor.py"
|
||||
|
||||
def test_update_with_absolute_script_rejected(self, cron_env, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
create_result = json.loads(cronjob(
|
||||
action="create",
|
||||
schedule="every 1h",
|
||||
prompt="Monitor things",
|
||||
))
|
||||
job_id = create_result["job_id"]
|
||||
|
||||
update_result = json.loads(cronjob(
|
||||
action="update",
|
||||
job_id=job_id,
|
||||
script="/tmp/evil.py",
|
||||
))
|
||||
assert update_result["success"] is False
|
||||
assert "relative" in update_result["error"].lower() or "absolute" in update_result["error"].lower()
|
||||
|
||||
def test_update_clear_script_allowed(self, cron_env, monkeypatch):
|
||||
"""Clearing a script (empty string) should always be permitted."""
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
create_result = json.loads(cronjob(
|
||||
action="create",
|
||||
schedule="every 1h",
|
||||
prompt="Monitor things",
|
||||
script="monitor.py",
|
||||
))
|
||||
job_id = create_result["job_id"]
|
||||
|
||||
update_result = json.loads(cronjob(
|
||||
action="update",
|
||||
job_id=job_id,
|
||||
script="",
|
||||
))
|
||||
assert update_result["success"] is True
|
||||
assert "script" not in update_result["job"]
|
||||
|
||||
def test_windows_absolute_path_rejected(self, cron_env, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_INTERACTIVE", "1")
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
result = json.loads(cronjob(
|
||||
action="create",
|
||||
schedule="every 1h",
|
||||
prompt="Monitor things",
|
||||
script="C:\\Users\\evil\\script.py",
|
||||
))
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
class TestRunJobEnvVarCleanup:
|
||||
"""Test that run_job() env vars are cleaned up even on early failure."""
|
||||
|
||||
def test_env_vars_cleaned_on_early_error(self, cron_env, monkeypatch):
|
||||
"""Origin env vars must be cleaned up even if run_job fails early."""
|
||||
# Ensure env vars are clean before test
|
||||
for key in (
|
||||
"HERMES_SESSION_PLATFORM",
|
||||
"HERMES_SESSION_CHAT_ID",
|
||||
"HERMES_SESSION_CHAT_NAME",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
# Build a job with origin info that will fail during execution
|
||||
# (no valid model, no API key — will raise inside try block)
|
||||
job = {
|
||||
"id": "test-envleak",
|
||||
"name": "env-leak-test",
|
||||
"prompt": "test",
|
||||
"schedule_display": "every 1h",
|
||||
"origin": {
|
||||
"platform": "telegram",
|
||||
"chat_id": "12345",
|
||||
"chat_name": "Test Chat",
|
||||
},
|
||||
}
|
||||
|
||||
from cron.scheduler import run_job
|
||||
|
||||
# Expect it to fail (no model/API key), but env vars must be cleaned
|
||||
try:
|
||||
run_job(job)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Verify env vars were cleaned up by the finally block
|
||||
assert os.environ.get("HERMES_SESSION_PLATFORM") is None
|
||||
assert os.environ.get("HERMES_SESSION_CHAT_ID") is None
|
||||
assert os.environ.get("HERMES_SESSION_CHAT_NAME") is None
|
||||
|
||||
@@ -250,6 +250,33 @@ class TestDeliverResultWrapping:
|
||||
assert "Cronjob Response" not in sent_content
|
||||
assert "The agent cannot see" not in sent_content
|
||||
|
||||
def test_delivery_extracts_media_tags_before_send(self):
|
||||
"""Cron delivery should pass MEDIA attachments separately to the send helper."""
|
||||
from gateway.config import Platform
|
||||
|
||||
pconfig = MagicMock()
|
||||
pconfig.enabled = True
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.platforms = {Platform.TELEGRAM: pconfig}
|
||||
|
||||
with patch("gateway.config.load_gateway_config", return_value=mock_cfg), \
|
||||
patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \
|
||||
patch("cron.scheduler.load_config", return_value={"cron": {"wrap_response": False}}):
|
||||
job = {
|
||||
"id": "voice-job",
|
||||
"deliver": "origin",
|
||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||
}
|
||||
_deliver_result(job, "Title\nMEDIA:/tmp/test-voice.ogg")
|
||||
|
||||
send_mock.assert_called_once()
|
||||
args, kwargs = send_mock.call_args
|
||||
# Text content should have MEDIA: tag stripped
|
||||
assert "MEDIA:" not in args[3]
|
||||
assert "Title" in args[3]
|
||||
# Media files should be forwarded separately
|
||||
assert kwargs["media_files"] == [("/tmp/test-voice.ogg", False)]
|
||||
|
||||
def test_no_mirror_to_session_call(self):
|
||||
"""Cron deliveries should NOT mirror into the gateway session."""
|
||||
from gateway.config import Platform
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import base64
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -368,3 +369,341 @@ class TestSignalSendMessage:
|
||||
# Just verify the import works and Signal is a valid platform
|
||||
from gateway.config import Platform
|
||||
assert Platform.SIGNAL.value == "signal"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_image_file method (#5105)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalSendImageFile:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_sends_via_rpc(self, monkeypatch, tmp_path):
|
||||
"""send_image_file should send image as attachment via signal-cli RPC."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc({"timestamp": 1234567890})
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
img_path = tmp_path / "chart.png"
|
||||
img_path.write_bytes(b"\x89PNG" + b"\x00" * 100)
|
||||
|
||||
result = await adapter.send_image_file(chat_id="+155****4567", image_path=str(img_path))
|
||||
|
||||
assert result.success is True
|
||||
assert len(captured) == 1
|
||||
assert captured[0]["method"] == "send"
|
||||
assert captured[0]["params"]["account"] == adapter.account
|
||||
assert captured[0]["params"]["recipient"] == ["+155****4567"]
|
||||
assert captured[0]["params"]["attachments"] == [str(img_path)]
|
||||
assert captured[0]["params"]["message"] == "" # caption=None → ""
|
||||
# Typing indicator must be stopped before sending
|
||||
adapter._stop_typing_indicator.assert_awaited_once_with("+155****4567")
|
||||
# Timestamp must be tracked for echo-back prevention
|
||||
assert 1234567890 in adapter._recent_sent_timestamps
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_to_group(self, monkeypatch, tmp_path):
|
||||
"""send_image_file should route group chats via groupId."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc({"timestamp": 1234567890})
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
img_path = tmp_path / "photo.jpg"
|
||||
img_path.write_bytes(b"\xff\xd8" + b"\x00" * 100)
|
||||
|
||||
result = await adapter.send_image_file(
|
||||
chat_id="group:abc123==", image_path=str(img_path), caption="Here's the chart"
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert captured[0]["params"]["groupId"] == "abc123=="
|
||||
assert captured[0]["params"]["message"] == "Here's the chart"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_missing(self, monkeypatch):
|
||||
"""send_image_file should fail gracefully for nonexistent files."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
result = await adapter.send_image_file(chat_id="+155****4567", image_path="/nonexistent.png")
|
||||
|
||||
assert result.success is False
|
||||
assert "not found" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_too_large(self, monkeypatch, tmp_path):
|
||||
"""send_image_file should reject files over 100MB."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
img_path = tmp_path / "huge.png"
|
||||
img_path.write_bytes(b"x")
|
||||
|
||||
def mock_stat(self, **kwargs):
|
||||
class FakeStat:
|
||||
st_size = 200 * 1024 * 1024 # 200 MB
|
||||
return FakeStat()
|
||||
|
||||
with patch.object(Path, "stat", mock_stat):
|
||||
result = await adapter.send_image_file(chat_id="+155****4567", image_path=str(img_path))
|
||||
|
||||
assert result.success is False
|
||||
assert "too large" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_image_file_rpc_failure(self, monkeypatch, tmp_path):
|
||||
"""send_image_file should return error when RPC returns None."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, _ = _stub_rpc(None)
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
img_path = tmp_path / "test.png"
|
||||
img_path.write_bytes(b"\x89PNG" + b"\x00" * 100)
|
||||
|
||||
result = await adapter.send_image_file(chat_id="+155****4567", image_path=str(img_path))
|
||||
|
||||
assert result.success is False
|
||||
assert "failed" in result.error.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_voice method (#5105)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalSendVoice:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_sends_via_rpc(self, monkeypatch, tmp_path):
|
||||
"""send_voice should send audio as attachment via signal-cli RPC."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc({"timestamp": 1234567890})
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
audio_path = tmp_path / "reply.ogg"
|
||||
audio_path.write_bytes(b"OggS" + b"\x00" * 100)
|
||||
|
||||
result = await adapter.send_voice(chat_id="+155****4567", audio_path=str(audio_path))
|
||||
|
||||
assert result.success is True
|
||||
assert captured[0]["method"] == "send"
|
||||
assert captured[0]["params"]["attachments"] == [str(audio_path)]
|
||||
assert captured[0]["params"]["message"] == "" # caption=None → ""
|
||||
adapter._stop_typing_indicator.assert_awaited_once_with("+155****4567")
|
||||
assert 1234567890 in adapter._recent_sent_timestamps
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_missing_file(self, monkeypatch):
|
||||
"""send_voice should fail for nonexistent audio."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
result = await adapter.send_voice(chat_id="+155****4567", audio_path="/missing.ogg")
|
||||
|
||||
assert result.success is False
|
||||
assert "not found" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_to_group(self, monkeypatch, tmp_path):
|
||||
"""send_voice should route group chats correctly."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc({"timestamp": 9999})
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
audio_path = tmp_path / "note.mp3"
|
||||
audio_path.write_bytes(b"\xff\xe0" + b"\x00" * 100)
|
||||
|
||||
result = await adapter.send_voice(chat_id="group:grp1==", audio_path=str(audio_path))
|
||||
|
||||
assert result.success is True
|
||||
assert captured[0]["params"]["groupId"] == "grp1=="
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_too_large(self, monkeypatch, tmp_path):
|
||||
"""send_voice should reject files over 100MB."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
audio_path = tmp_path / "huge.ogg"
|
||||
audio_path.write_bytes(b"x")
|
||||
|
||||
def mock_stat(self, **kwargs):
|
||||
class FakeStat:
|
||||
st_size = 200 * 1024 * 1024
|
||||
return FakeStat()
|
||||
|
||||
with patch.object(Path, "stat", mock_stat):
|
||||
result = await adapter.send_voice(chat_id="+155****4567", audio_path=str(audio_path))
|
||||
|
||||
assert result.success is False
|
||||
assert "too large" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_voice_rpc_failure(self, monkeypatch, tmp_path):
|
||||
"""send_voice should return error when RPC returns None."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, _ = _stub_rpc(None)
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
audio_path = tmp_path / "reply.ogg"
|
||||
audio_path.write_bytes(b"OggS" + b"\x00" * 100)
|
||||
|
||||
result = await adapter.send_voice(chat_id="+155****4567", audio_path=str(audio_path))
|
||||
|
||||
assert result.success is False
|
||||
assert "failed" in result.error.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_video method (#5105)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalSendVideo:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_video_sends_via_rpc(self, monkeypatch, tmp_path):
|
||||
"""send_video should send video as attachment via signal-cli RPC."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, captured = _stub_rpc({"timestamp": 1234567890})
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
vid_path = tmp_path / "demo.mp4"
|
||||
vid_path.write_bytes(b"\x00\x00\x00\x18ftyp" + b"\x00" * 100)
|
||||
|
||||
result = await adapter.send_video(chat_id="+155****4567", video_path=str(vid_path))
|
||||
|
||||
assert result.success is True
|
||||
assert captured[0]["method"] == "send"
|
||||
assert captured[0]["params"]["attachments"] == [str(vid_path)]
|
||||
assert captured[0]["params"]["message"] == "" # caption=None → ""
|
||||
adapter._stop_typing_indicator.assert_awaited_once_with("+155****4567")
|
||||
assert 1234567890 in adapter._recent_sent_timestamps
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_video_missing_file(self, monkeypatch):
|
||||
"""send_video should fail for nonexistent video."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
result = await adapter.send_video(chat_id="+155****4567", video_path="/missing.mp4")
|
||||
|
||||
assert result.success is False
|
||||
assert "not found" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_video_too_large(self, monkeypatch, tmp_path):
|
||||
"""send_video should reject files over 100MB."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
vid_path = tmp_path / "huge.mp4"
|
||||
vid_path.write_bytes(b"x")
|
||||
|
||||
def mock_stat(self, **kwargs):
|
||||
class FakeStat:
|
||||
st_size = 200 * 1024 * 1024
|
||||
return FakeStat()
|
||||
|
||||
with patch.object(Path, "stat", mock_stat):
|
||||
result = await adapter.send_video(chat_id="+155****4567", video_path=str(vid_path))
|
||||
|
||||
assert result.success is False
|
||||
assert "too large" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_video_rpc_failure(self, monkeypatch, tmp_path):
|
||||
"""send_video should return error when RPC returns None."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
mock_rpc, _ = _stub_rpc(None)
|
||||
adapter._rpc = mock_rpc
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
vid_path = tmp_path / "demo.mp4"
|
||||
vid_path.write_bytes(b"\x00\x00\x00\x18ftyp" + b"\x00" * 100)
|
||||
|
||||
result = await adapter.send_video(chat_id="+155****4567", video_path=str(vid_path))
|
||||
|
||||
assert result.success is False
|
||||
assert "failed" in result.error.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEDIA: tag extraction integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalMediaExtraction:
|
||||
"""Verify the full pipeline: MEDIA: tag → extract → send_image_file/send_voice."""
|
||||
|
||||
def test_extract_media_finds_image_tag(self):
|
||||
"""BasePlatformAdapter.extract_media should find MEDIA: image paths."""
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
media, cleaned = BasePlatformAdapter.extract_media(
|
||||
"Here's the chart.\nMEDIA:/tmp/price_graph.png"
|
||||
)
|
||||
assert len(media) == 1
|
||||
assert media[0][0] == "/tmp/price_graph.png"
|
||||
assert "MEDIA:" not in cleaned
|
||||
|
||||
def test_extract_media_finds_audio_tag(self):
|
||||
"""BasePlatformAdapter.extract_media should find MEDIA: audio paths."""
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
media, cleaned = BasePlatformAdapter.extract_media(
|
||||
"[[audio_as_voice]]\nMEDIA:/tmp/reply.ogg"
|
||||
)
|
||||
assert len(media) == 1
|
||||
assert media[0][0] == "/tmp/reply.ogg"
|
||||
assert media[0][1] is True # is_voice flag
|
||||
|
||||
def test_signal_has_all_media_methods(self, monkeypatch):
|
||||
"""SignalAdapter must override all media send methods used by gateway."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
# These methods must NOT be the base class defaults (which just send text)
|
||||
assert type(adapter).send_image_file is not BasePlatformAdapter.send_image_file
|
||||
assert type(adapter).send_voice is not BasePlatformAdapter.send_voice
|
||||
assert type(adapter).send_video is not BasePlatformAdapter.send_video
|
||||
assert type(adapter).send_document is not BasePlatformAdapter.send_document
|
||||
assert type(adapter).send_image is not BasePlatformAdapter.send_image
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# send_document now routes through _send_attachment (#5105 bonus)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalSendDocumentViaHelper:
|
||||
"""Verify send_document gained size check and path-in-error via _send_attachment."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_document_too_large(self, monkeypatch, tmp_path):
|
||||
"""send_document should now reject files over 100MB (was previously missing)."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
doc_path = tmp_path / "huge.pdf"
|
||||
doc_path.write_bytes(b"x")
|
||||
|
||||
def mock_stat(self, **kwargs):
|
||||
class FakeStat:
|
||||
st_size = 200 * 1024 * 1024
|
||||
return FakeStat()
|
||||
|
||||
with patch.object(Path, "stat", mock_stat):
|
||||
result = await adapter.send_document(chat_id="+155****4567", file_path=str(doc_path))
|
||||
|
||||
assert result.success is False
|
||||
assert "too large" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_document_error_includes_path(self, monkeypatch):
|
||||
"""send_document error message should include the file path."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
adapter._stop_typing_indicator = AsyncMock()
|
||||
|
||||
result = await adapter.send_document(chat_id="+155****4567", file_path="/nonexistent.pdf")
|
||||
|
||||
assert result.success is False
|
||||
assert "/nonexistent.pdf" in result.error
|
||||
|
||||
@@ -12,8 +12,12 @@ from hermes_cli.commands import (
|
||||
SUBCOMMANDS,
|
||||
SlashCommandAutoSuggest,
|
||||
SlashCommandCompleter,
|
||||
_CMD_NAME_LIMIT,
|
||||
_TG_NAME_LIMIT,
|
||||
_clamp_command_names,
|
||||
_clamp_telegram_names,
|
||||
_sanitize_telegram_name,
|
||||
discord_skill_commands,
|
||||
gateway_help_lines,
|
||||
resolve_command,
|
||||
slack_subcommand_map,
|
||||
@@ -198,6 +202,13 @@ class TestTelegramBotCommands:
|
||||
for name, _ in telegram_bot_commands():
|
||||
assert "-" not in name, f"Telegram command '{name}' contains a hyphen"
|
||||
|
||||
def test_all_names_valid_telegram_chars(self):
|
||||
"""Telegram requires: lowercase a-z, 0-9, underscores only."""
|
||||
import re
|
||||
tg_valid = re.compile(r"^[a-z0-9_]+$")
|
||||
for name, _ in telegram_bot_commands():
|
||||
assert tg_valid.match(name), f"Invalid Telegram command name: {name!r}"
|
||||
|
||||
def test_excludes_cli_only_without_config_gate(self):
|
||||
names = {name for name, _ in telegram_bot_commands()}
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
@@ -509,6 +520,53 @@ class TestGhostText:
|
||||
assert _suggestion("hello") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telegram command name sanitization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSanitizeTelegramName:
|
||||
"""Tests for _sanitize_telegram_name() — Telegram requires [a-z0-9_] only."""
|
||||
|
||||
def test_hyphens_replaced_with_underscores(self):
|
||||
assert _sanitize_telegram_name("my-skill-name") == "my_skill_name"
|
||||
|
||||
def test_plus_sign_stripped(self):
|
||||
"""Regression: skill name 'Jellyfin + Jellystat 24h Summary'."""
|
||||
assert _sanitize_telegram_name("jellyfin-+-jellystat-24h-summary") == "jellyfin_jellystat_24h_summary"
|
||||
|
||||
def test_slash_stripped(self):
|
||||
"""Regression: skill name 'Sonarr v3/v4 API Integration'."""
|
||||
assert _sanitize_telegram_name("sonarr-v3/v4-api-integration") == "sonarr_v3v4_api_integration"
|
||||
|
||||
def test_uppercase_lowercased(self):
|
||||
assert _sanitize_telegram_name("MyCommand") == "mycommand"
|
||||
|
||||
def test_dots_and_special_chars_stripped(self):
|
||||
assert _sanitize_telegram_name("skill.v2@beta!") == "skillv2beta"
|
||||
|
||||
def test_consecutive_underscores_collapsed(self):
|
||||
assert _sanitize_telegram_name("a---b") == "a_b"
|
||||
assert _sanitize_telegram_name("a-+-b") == "a_b"
|
||||
|
||||
def test_leading_trailing_underscores_stripped(self):
|
||||
assert _sanitize_telegram_name("-leading") == "leading"
|
||||
assert _sanitize_telegram_name("trailing-") == "trailing"
|
||||
assert _sanitize_telegram_name("-both-") == "both"
|
||||
|
||||
def test_digits_preserved(self):
|
||||
assert _sanitize_telegram_name("skill-24h") == "skill_24h"
|
||||
|
||||
def test_empty_after_sanitization(self):
|
||||
assert _sanitize_telegram_name("+++") == ""
|
||||
|
||||
def test_spaces_only_becomes_empty(self):
|
||||
assert _sanitize_telegram_name(" ") == ""
|
||||
|
||||
def test_already_valid(self):
|
||||
assert _sanitize_telegram_name("valid_name_123") == "valid_name_123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telegram command name clamping (32-char limit)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -628,3 +686,306 @@ class TestTelegramMenuCommands:
|
||||
menu_names = {n for n, _ in menu}
|
||||
assert "my_enabled_skill" in menu_names
|
||||
assert "my_disabled_skill" not in menu_names
|
||||
|
||||
def test_special_chars_in_skill_names_sanitized(self, tmp_path, monkeypatch):
|
||||
"""Skills with +, /, or other special chars produce valid Telegram names."""
|
||||
from unittest.mock import patch
|
||||
import re
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
fake_cmds = {
|
||||
"/jellyfin-+-jellystat-24h-summary": {
|
||||
"name": "Jellyfin + Jellystat 24h Summary",
|
||||
"description": "Test",
|
||||
"skill_md_path": f"{fake_skills_dir}/jellyfin/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/jellyfin",
|
||||
},
|
||||
"/sonarr-v3/v4-api": {
|
||||
"name": "Sonarr v3/v4 API",
|
||||
"description": "Test",
|
||||
"skill_md_path": f"{fake_skills_dir}/sonarr/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/sonarr",
|
||||
},
|
||||
}
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
(tmp_path / "skills").mkdir(exist_ok=True)
|
||||
menu, _ = telegram_menu_commands(max_commands=100)
|
||||
|
||||
# Every name must match Telegram's [a-z0-9_] requirement
|
||||
tg_valid = re.compile(r"^[a-z0-9_]+$")
|
||||
for name, _ in menu:
|
||||
assert tg_valid.match(name), f"Invalid Telegram command name: {name!r}"
|
||||
|
||||
def test_empty_sanitized_names_excluded(self, tmp_path, monkeypatch):
|
||||
"""Skills whose names sanitize to empty string are silently dropped."""
|
||||
from unittest.mock import patch
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
fake_cmds = {
|
||||
"/+++": {
|
||||
"name": "+++",
|
||||
"description": "All special chars",
|
||||
"skill_md_path": f"{fake_skills_dir}/bad/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/bad",
|
||||
},
|
||||
"/valid-skill": {
|
||||
"name": "valid-skill",
|
||||
"description": "Normal skill",
|
||||
"skill_md_path": f"{fake_skills_dir}/valid/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/valid",
|
||||
},
|
||||
}
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
(tmp_path / "skills").mkdir(exist_ok=True)
|
||||
menu, _ = telegram_menu_commands(max_commands=100)
|
||||
|
||||
menu_names = {n for n, _ in menu}
|
||||
# The valid skill should be present, the empty one should not
|
||||
assert "valid_skill" in menu_names
|
||||
# No empty string in menu names
|
||||
assert "" not in menu_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backward-compat aliases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBackwardCompatAliases:
|
||||
"""The renamed constants/functions still exist under the old names."""
|
||||
|
||||
def test_tg_name_limit_alias(self):
|
||||
assert _TG_NAME_LIMIT == _CMD_NAME_LIMIT == 32
|
||||
|
||||
def test_clamp_telegram_names_is_clamp_command_names(self):
|
||||
assert _clamp_telegram_names is _clamp_command_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discord skill command registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDiscordSkillCommands:
|
||||
"""Tests for discord_skill_commands() — centralized skill registration."""
|
||||
|
||||
def test_returns_skill_entries(self, tmp_path, monkeypatch):
|
||||
"""Skills under SKILLS_DIR (not .hub) should be returned."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
fake_cmds = {
|
||||
"/gif-search": {
|
||||
"name": "gif-search",
|
||||
"description": "Search for GIFs",
|
||||
"skill_md_path": f"{fake_skills_dir}/gif-search/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/gif-search",
|
||||
},
|
||||
"/code-review": {
|
||||
"name": "code-review",
|
||||
"description": "Review code changes",
|
||||
"skill_md_path": f"{fake_skills_dir}/code-review/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/code-review",
|
||||
},
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "skills").mkdir(exist_ok=True)
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
entries, hidden = discord_skill_commands(
|
||||
max_slots=50, reserved_names=set(),
|
||||
)
|
||||
|
||||
names = {n for n, _d, _k in entries}
|
||||
assert "gif-search" in names
|
||||
assert "code-review" in names
|
||||
assert hidden == 0
|
||||
# Verify cmd_key is preserved for handler callbacks
|
||||
keys = {k for _n, _d, k in entries}
|
||||
assert "/gif-search" in keys
|
||||
assert "/code-review" in keys
|
||||
|
||||
def test_names_allow_hyphens(self, tmp_path, monkeypatch):
|
||||
"""Discord names should keep hyphens (unlike Telegram's _ sanitization)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
fake_cmds = {
|
||||
"/my-cool-skill": {
|
||||
"name": "my-cool-skill",
|
||||
"description": "A cool skill",
|
||||
"skill_md_path": f"{fake_skills_dir}/my-cool-skill/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/my-cool-skill",
|
||||
},
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "skills").mkdir(exist_ok=True)
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
entries, _ = discord_skill_commands(
|
||||
max_slots=50, reserved_names=set(),
|
||||
)
|
||||
|
||||
assert entries[0][0] == "my-cool-skill" # hyphens preserved
|
||||
|
||||
def test_cap_enforcement(self, tmp_path, monkeypatch):
|
||||
"""Entries beyond max_slots should be hidden."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
fake_cmds = {
|
||||
f"/skill-{i:03d}": {
|
||||
"name": f"skill-{i:03d}",
|
||||
"description": f"Skill {i}",
|
||||
"skill_md_path": f"{fake_skills_dir}/skill-{i:03d}/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/skill-{i:03d}",
|
||||
}
|
||||
for i in range(20)
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "skills").mkdir(exist_ok=True)
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
entries, hidden = discord_skill_commands(
|
||||
max_slots=5, reserved_names=set(),
|
||||
)
|
||||
|
||||
assert len(entries) == 5
|
||||
assert hidden == 15
|
||||
|
||||
def test_excludes_discord_disabled_skills(self, tmp_path, monkeypatch):
|
||||
"""Skills disabled for discord should not appear."""
|
||||
from unittest.mock import patch
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(
|
||||
"skills:\n"
|
||||
" platform_disabled:\n"
|
||||
" discord:\n"
|
||||
" - secret-skill\n"
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
fake_cmds = {
|
||||
"/secret-skill": {
|
||||
"name": "secret-skill",
|
||||
"description": "Should not appear",
|
||||
"skill_md_path": f"{fake_skills_dir}/secret-skill/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/secret-skill",
|
||||
},
|
||||
"/public-skill": {
|
||||
"name": "public-skill",
|
||||
"description": "Should appear",
|
||||
"skill_md_path": f"{fake_skills_dir}/public-skill/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/public-skill",
|
||||
},
|
||||
}
|
||||
(tmp_path / "skills").mkdir(exist_ok=True)
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
entries, _ = discord_skill_commands(
|
||||
max_slots=50, reserved_names=set(),
|
||||
)
|
||||
|
||||
names = {n for n, _d, _k in entries}
|
||||
assert "secret-skill" not in names
|
||||
assert "public-skill" in names
|
||||
|
||||
def test_reserved_names_not_overwritten(self, tmp_path, monkeypatch):
|
||||
"""Skills whose names collide with built-in commands should be skipped."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
fake_cmds = {
|
||||
"/status": {
|
||||
"name": "status",
|
||||
"description": "Skill that collides with built-in",
|
||||
"skill_md_path": f"{fake_skills_dir}/status/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/status",
|
||||
},
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "skills").mkdir(exist_ok=True)
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
entries, _ = discord_skill_commands(
|
||||
max_slots=50, reserved_names={"status"},
|
||||
)
|
||||
|
||||
names = {n for n, _d, _k in entries}
|
||||
assert "status" not in names
|
||||
|
||||
def test_description_truncated_at_100_chars(self, tmp_path, monkeypatch):
|
||||
"""Descriptions exceeding 100 chars should be truncated."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
long_desc = "x" * 150
|
||||
fake_cmds = {
|
||||
"/verbose-skill": {
|
||||
"name": "verbose-skill",
|
||||
"description": long_desc,
|
||||
"skill_md_path": f"{fake_skills_dir}/verbose-skill/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/verbose-skill",
|
||||
},
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "skills").mkdir(exist_ok=True)
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
entries, _ = discord_skill_commands(
|
||||
max_slots=50, reserved_names=set(),
|
||||
)
|
||||
|
||||
assert len(entries[0][1]) == 100
|
||||
assert entries[0][1].endswith("...")
|
||||
|
||||
def test_all_names_within_32_chars(self, tmp_path, monkeypatch):
|
||||
"""All returned names must respect the 32-char Discord limit."""
|
||||
from unittest.mock import patch
|
||||
|
||||
fake_skills_dir = str(tmp_path / "skills")
|
||||
long_name = "a" * 50
|
||||
fake_cmds = {
|
||||
f"/{long_name}": {
|
||||
"name": long_name,
|
||||
"description": "Long name skill",
|
||||
"skill_md_path": f"{fake_skills_dir}/{long_name}/SKILL.md",
|
||||
"skill_dir": f"{fake_skills_dir}/{long_name}",
|
||||
},
|
||||
}
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "skills").mkdir(exist_ok=True)
|
||||
with (
|
||||
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
|
||||
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
|
||||
):
|
||||
entries, _ = discord_skill_commands(
|
||||
max_slots=50, reserved_names=set(),
|
||||
)
|
||||
|
||||
for name, _d, _k in entries:
|
||||
assert len(name) <= _CMD_NAME_LIMIT, (
|
||||
f"Name '{name}' is {len(name)} chars (limit {_CMD_NAME_LIMIT})"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Tests for Google AI Studio (Gemini) provider integration."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider, resolve_api_key_provider_credentials
|
||||
from hermes_cli.models import _PROVIDER_MODELS, _PROVIDER_LABELS, _PROVIDER_ALIASES, normalize_provider
|
||||
from hermes_cli.model_normalize import normalize_model_for_provider, detect_vendor
|
||||
from agent.model_metadata import get_model_context_length
|
||||
from agent.models_dev import PROVIDER_TO_MODELS_DEV, list_agentic_models, _NOISE_PATTERNS
|
||||
|
||||
|
||||
# ── Provider Registry ──
|
||||
|
||||
class TestGeminiProviderRegistry:
|
||||
def test_gemini_in_registry(self):
|
||||
assert "gemini" in PROVIDER_REGISTRY
|
||||
|
||||
def test_gemini_config(self):
|
||||
pconfig = PROVIDER_REGISTRY["gemini"]
|
||||
assert pconfig.id == "gemini"
|
||||
assert pconfig.name == "Google AI Studio"
|
||||
assert pconfig.auth_type == "api_key"
|
||||
assert pconfig.inference_base_url == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
def test_gemini_env_vars(self):
|
||||
pconfig = PROVIDER_REGISTRY["gemini"]
|
||||
assert pconfig.api_key_env_vars == ("GOOGLE_API_KEY", "GEMINI_API_KEY")
|
||||
assert pconfig.base_url_env_var == "GEMINI_BASE_URL"
|
||||
|
||||
def test_gemini_base_url(self):
|
||||
assert "generativelanguage.googleapis.com" in PROVIDER_REGISTRY["gemini"].inference_base_url
|
||||
|
||||
|
||||
# ── Provider Aliases ──
|
||||
|
||||
PROVIDER_ENV_VARS = (
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
|
||||
"GOOGLE_API_KEY", "GEMINI_API_KEY", "GEMINI_BASE_URL",
|
||||
"GLM_API_KEY", "ZAI_API_KEY", "KIMI_API_KEY",
|
||||
"MINIMAX_API_KEY", "DEEPSEEK_API_KEY",
|
||||
)
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_provider_env(monkeypatch):
|
||||
for var in PROVIDER_ENV_VARS:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
class TestGeminiAliases:
|
||||
def test_explicit_gemini(self):
|
||||
assert resolve_provider("gemini") == "gemini"
|
||||
|
||||
def test_alias_google(self):
|
||||
assert resolve_provider("google") == "gemini"
|
||||
|
||||
def test_alias_google_gemini(self):
|
||||
assert resolve_provider("google-gemini") == "gemini"
|
||||
|
||||
def test_alias_google_ai_studio(self):
|
||||
assert resolve_provider("google-ai-studio") == "gemini"
|
||||
|
||||
def test_models_py_aliases(self):
|
||||
assert _PROVIDER_ALIASES.get("google") == "gemini"
|
||||
assert _PROVIDER_ALIASES.get("google-gemini") == "gemini"
|
||||
assert _PROVIDER_ALIASES.get("google-ai-studio") == "gemini"
|
||||
|
||||
def test_normalize_provider(self):
|
||||
assert normalize_provider("google") == "gemini"
|
||||
assert normalize_provider("gemini") == "gemini"
|
||||
assert normalize_provider("google-ai-studio") == "gemini"
|
||||
|
||||
|
||||
# ── Auto-detection ──
|
||||
|
||||
class TestGeminiAutoDetection:
|
||||
def test_auto_detects_google_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "test-google-key")
|
||||
assert resolve_provider("auto") == "gemini"
|
||||
|
||||
def test_auto_detects_gemini_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-gemini-key")
|
||||
assert resolve_provider("auto") == "gemini"
|
||||
|
||||
def test_google_api_key_priority_over_gemini(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "primary-key")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "alias-key")
|
||||
creds = resolve_api_key_provider_credentials("gemini")
|
||||
assert creds["api_key"] == "primary-key"
|
||||
assert creds["source"] == "GOOGLE_API_KEY"
|
||||
|
||||
|
||||
# ── Credential Resolution ──
|
||||
|
||||
class TestGeminiCredentials:
|
||||
def test_resolve_with_google_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "google-secret")
|
||||
creds = resolve_api_key_provider_credentials("gemini")
|
||||
assert creds["provider"] == "gemini"
|
||||
assert creds["api_key"] == "google-secret"
|
||||
assert creds["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
def test_resolve_with_gemini_api_key(self, monkeypatch):
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "gemini-secret")
|
||||
creds = resolve_api_key_provider_credentials("gemini")
|
||||
assert creds["api_key"] == "gemini-secret"
|
||||
|
||||
def test_resolve_with_custom_base_url(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "key")
|
||||
monkeypatch.setenv("GEMINI_BASE_URL", "https://custom.endpoint/v1")
|
||||
creds = resolve_api_key_provider_credentials("gemini")
|
||||
assert creds["base_url"] == "https://custom.endpoint/v1"
|
||||
|
||||
def test_runtime_gemini(self, monkeypatch):
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "google-key")
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
result = resolve_runtime_provider(requested="gemini")
|
||||
assert result["provider"] == "gemini"
|
||||
assert result["api_mode"] == "chat_completions"
|
||||
assert result["api_key"] == "google-key"
|
||||
assert result["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
|
||||
|
||||
# ── Model Catalog ──
|
||||
|
||||
class TestGeminiModelCatalog:
|
||||
def test_provider_models_exist(self):
|
||||
assert "gemini" in _PROVIDER_MODELS
|
||||
models = _PROVIDER_MODELS["gemini"]
|
||||
assert "gemini-2.5-pro" in models
|
||||
assert "gemini-2.5-flash" in models
|
||||
assert "gemma-4-31b-it" in models
|
||||
|
||||
def test_provider_models_has_3x(self):
|
||||
models = _PROVIDER_MODELS["gemini"]
|
||||
assert "gemini-3.1-pro-preview" in models
|
||||
assert "gemini-3-flash-preview" in models
|
||||
assert "gemini-3.1-flash-lite-preview" in models
|
||||
|
||||
def test_provider_label(self):
|
||||
assert "gemini" in _PROVIDER_LABELS
|
||||
assert _PROVIDER_LABELS["gemini"] == "Google AI Studio"
|
||||
|
||||
|
||||
# ── Model Normalization ──
|
||||
|
||||
class TestGeminiModelNormalization:
|
||||
def test_passthrough_bare_name(self):
|
||||
assert normalize_model_for_provider("gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
|
||||
|
||||
def test_strip_vendor_prefix(self):
|
||||
assert normalize_model_for_provider("google/gemini-2.5-flash", "gemini") == "google/gemini-2.5-flash"
|
||||
|
||||
def test_gemma_vendor_detection(self):
|
||||
assert detect_vendor("gemma-4-31b-it") == "google"
|
||||
|
||||
def test_gemini_vendor_detection(self):
|
||||
assert detect_vendor("gemini-2.5-flash") == "google"
|
||||
|
||||
def test_aggregator_prepends_vendor(self):
|
||||
result = normalize_model_for_provider("gemini-2.5-flash", "openrouter")
|
||||
assert result == "google/gemini-2.5-flash"
|
||||
|
||||
def test_gemma_aggregator_prepends_vendor(self):
|
||||
result = normalize_model_for_provider("gemma-4-31b-it", "openrouter")
|
||||
assert result == "google/gemma-4-31b-it"
|
||||
|
||||
|
||||
# ── Context Length ──
|
||||
|
||||
class TestGeminiContextLength:
|
||||
def test_gemma_4_31b_context(self):
|
||||
ctx = get_model_context_length("gemma-4-31b-it", provider="gemini")
|
||||
assert ctx == 256000
|
||||
|
||||
def test_gemma_4_26b_context(self):
|
||||
ctx = get_model_context_length("gemma-4-26b-it", provider="gemini")
|
||||
assert ctx == 256000
|
||||
|
||||
def test_gemini_3_context(self):
|
||||
ctx = get_model_context_length("gemini-3.1-pro-preview", provider="gemini")
|
||||
assert ctx == 1048576
|
||||
|
||||
|
||||
# ── Agent Init (no SyntaxError) ──
|
||||
|
||||
class TestGeminiAgentInit:
|
||||
def test_agent_imports_without_error(self):
|
||||
"""Verify run_agent.py has no SyntaxError (the critical bug)."""
|
||||
import importlib
|
||||
import run_agent
|
||||
importlib.reload(run_agent)
|
||||
|
||||
def test_gemini_agent_uses_chat_completions(self, monkeypatch):
|
||||
"""Gemini falls through to chat_completions — no special elif needed."""
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
|
||||
with patch("run_agent.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
from run_agent import AIAgent
|
||||
agent = AIAgent(
|
||||
model="gemini-2.5-flash",
|
||||
provider="gemini",
|
||||
api_key="test-key",
|
||||
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
)
|
||||
assert agent.api_mode == "chat_completions"
|
||||
assert agent.provider == "gemini"
|
||||
|
||||
|
||||
# ── models.dev Integration ──
|
||||
|
||||
class TestGeminiModelsDev:
|
||||
def test_gemini_mapped_to_google(self):
|
||||
assert PROVIDER_TO_MODELS_DEV.get("gemini") == "google"
|
||||
|
||||
def test_noise_filter_excludes_tts(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-2.5-pro-preview-tts")
|
||||
|
||||
def test_noise_filter_excludes_dated_preview(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-2.5-flash-preview-04-17")
|
||||
|
||||
def test_noise_filter_excludes_embedding(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-embedding-001")
|
||||
|
||||
def test_noise_filter_excludes_live(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-live-2.5-flash")
|
||||
|
||||
def test_noise_filter_excludes_image(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-2.5-flash-image")
|
||||
|
||||
def test_noise_filter_excludes_customtools(self):
|
||||
assert _NOISE_PATTERNS.search("gemini-3.1-pro-preview-customtools")
|
||||
|
||||
def test_noise_filter_passes_stable(self):
|
||||
assert not _NOISE_PATTERNS.search("gemini-2.5-flash")
|
||||
|
||||
def test_noise_filter_passes_preview(self):
|
||||
# Non-dated preview (e.g. gemini-3-flash-preview) should pass
|
||||
assert not _NOISE_PATTERNS.search("gemini-3-flash-preview")
|
||||
|
||||
def test_noise_filter_passes_gemma(self):
|
||||
assert not _NOISE_PATTERNS.search("gemma-4-31b-it")
|
||||
|
||||
def test_list_agentic_models_with_mock_data(self):
|
||||
"""list_agentic_models filters correctly from mock models.dev data."""
|
||||
mock_data = {
|
||||
"google": {
|
||||
"models": {
|
||||
"gemini-3-flash-preview": {"tool_call": True},
|
||||
"gemini-2.5-pro": {"tool_call": True},
|
||||
"gemini-embedding-001": {"tool_call": False},
|
||||
"gemini-2.5-flash-preview-tts": {"tool_call": False},
|
||||
"gemini-live-2.5-flash": {"tool_call": True},
|
||||
"gemini-2.5-flash-preview-04-17": {"tool_call": True},
|
||||
"gemma-4-31b-it": {"tool_call": True},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_data):
|
||||
result = list_agentic_models("gemini")
|
||||
assert "gemini-3-flash-preview" in result
|
||||
assert "gemini-2.5-pro" in result
|
||||
assert "gemma-4-31b-it" in result
|
||||
# Filtered out:
|
||||
assert "gemini-embedding-001" not in result # no tool_call
|
||||
assert "gemini-2.5-flash-preview-tts" not in result # no tool_call
|
||||
assert "gemini-live-2.5-flash" not in result # noise: live-
|
||||
assert "gemini-2.5-flash-preview-04-17" not in result # noise: dated preview
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for hermes_cli.model_normalize — provider-aware model name normalization.
|
||||
|
||||
Covers issue #5211: opencode-go model names with dots (e.g. minimax-m2.7)
|
||||
must NOT be mangled to hyphens (minimax-m2-7).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from hermes_cli.model_normalize import (
|
||||
normalize_model_for_provider,
|
||||
_DOT_TO_HYPHEN_PROVIDERS,
|
||||
_AGGREGATOR_PROVIDERS,
|
||||
detect_vendor,
|
||||
)
|
||||
|
||||
|
||||
# ── Regression: issue #5211 ────────────────────────────────────────────
|
||||
|
||||
class TestIssue5211OpenCodeGoDotPreservation:
|
||||
"""OpenCode Go model names with dots must pass through unchanged."""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("minimax-m2.7", "minimax-m2.7"),
|
||||
("minimax-m2.5", "minimax-m2.5"),
|
||||
("glm-4.5", "glm-4.5"),
|
||||
("kimi-k2.5", "kimi-k2.5"),
|
||||
("some-model-1.0.3", "some-model-1.0.3"),
|
||||
])
|
||||
def test_opencode_go_preserves_dots(self, model, expected):
|
||||
result = normalize_model_for_provider(model, "opencode-go")
|
||||
assert result == expected, f"Expected {expected!r}, got {result!r}"
|
||||
|
||||
def test_opencode_go_not_in_dot_to_hyphen_set(self):
|
||||
"""opencode-go must NOT be in the dot-to-hyphen provider set."""
|
||||
assert "opencode-go" not in _DOT_TO_HYPHEN_PROVIDERS
|
||||
|
||||
|
||||
# ── Anthropic dot-to-hyphen conversion (regression) ────────────────────
|
||||
|
||||
class TestAnthropicDotToHyphen:
|
||||
"""Anthropic API still needs dots→hyphens."""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("claude-sonnet-4.6", "claude-sonnet-4-6"),
|
||||
("claude-opus-4.5", "claude-opus-4-5"),
|
||||
])
|
||||
def test_anthropic_converts_dots(self, model, expected):
|
||||
result = normalize_model_for_provider(model, "anthropic")
|
||||
assert result == expected
|
||||
|
||||
def test_anthropic_strips_vendor_prefix(self):
|
||||
result = normalize_model_for_provider("anthropic/claude-sonnet-4.6", "anthropic")
|
||||
assert result == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
# ── OpenCode Zen regression ────────────────────────────────────────────
|
||||
|
||||
class TestOpenCodeZenDotToHyphen:
|
||||
"""OpenCode Zen follows Anthropic convention (dots→hyphens)."""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("claude-sonnet-4.6", "claude-sonnet-4-6"),
|
||||
("glm-4.5", "glm-4-5"),
|
||||
])
|
||||
def test_zen_converts_dots(self, model, expected):
|
||||
result = normalize_model_for_provider(model, "opencode-zen")
|
||||
assert result == expected
|
||||
|
||||
def test_zen_strips_vendor_prefix(self):
|
||||
result = normalize_model_for_provider("opencode-zen/claude-sonnet-4.6", "opencode-zen")
|
||||
assert result == "claude-sonnet-4-6"
|
||||
|
||||
|
||||
# ── Copilot dot preservation (regression) ──────────────────────────────
|
||||
|
||||
class TestCopilotDotPreservation:
|
||||
"""Copilot preserves dots in model names."""
|
||||
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("claude-sonnet-4.6", "claude-sonnet-4.6"),
|
||||
("gpt-5.4", "gpt-5.4"),
|
||||
])
|
||||
def test_copilot_preserves_dots(self, model, expected):
|
||||
result = normalize_model_for_provider(model, "copilot")
|
||||
assert result == expected
|
||||
|
||||
|
||||
# ── Aggregator providers (regression) ──────────────────────────────────
|
||||
|
||||
class TestAggregatorProviders:
|
||||
"""Aggregators need vendor/model slugs."""
|
||||
|
||||
def test_openrouter_prepends_vendor(self):
|
||||
result = normalize_model_for_provider("claude-sonnet-4.6", "openrouter")
|
||||
assert result == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
def test_nous_prepends_vendor(self):
|
||||
result = normalize_model_for_provider("gpt-5.4", "nous")
|
||||
assert result == "openai/gpt-5.4"
|
||||
|
||||
def test_vendor_already_present(self):
|
||||
result = normalize_model_for_provider("anthropic/claude-sonnet-4.6", "openrouter")
|
||||
assert result == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
|
||||
# ── detect_vendor ──────────────────────────────────────────────────────
|
||||
|
||||
class TestDetectVendor:
|
||||
@pytest.mark.parametrize("model,expected", [
|
||||
("claude-sonnet-4.6", "anthropic"),
|
||||
("gpt-5.4-mini", "openai"),
|
||||
("minimax-m2.7", "minimax"),
|
||||
("glm-4.5", "z-ai"),
|
||||
("kimi-k2.5", "moonshotai"),
|
||||
])
|
||||
def test_detects_known_vendors(self, model, expected):
|
||||
assert detect_vendor(model) == expected
|
||||
@@ -63,4 +63,4 @@ class TestCamofoxConfigDefaults:
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
# managed_persistence is auto-merged by _deep_merge, no version bump needed
|
||||
assert DEFAULT_CONFIG["_config_version"] == 11
|
||||
assert DEFAULT_CONFIG["_config_version"] == 12
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Firecrawl cloud browser provider."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Dict
|
||||
|
||||
import requests
|
||||
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://api.firecrawl.dev"
|
||||
|
||||
|
||||
class FirecrawlProvider(CloudBrowserProvider):
|
||||
"""Firecrawl (https://firecrawl.dev) cloud browser backend."""
|
||||
|
||||
def provider_name(self) -> str:
|
||||
return "Firecrawl"
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(os.environ.get("FIRECRAWL_API_KEY"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _api_url(self) -> str:
|
||||
return os.environ.get("FIRECRAWL_API_URL", _BASE_URL)
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
api_key = os.environ.get("FIRECRAWL_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"FIRECRAWL_API_KEY environment variable is required. "
|
||||
"Get your key at https://firecrawl.dev"
|
||||
)
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
ttl = int(os.environ.get("FIRECRAWL_BROWSER_TTL", "300"))
|
||||
|
||||
body: Dict[str, object] = {"ttl": ttl}
|
||||
|
||||
response = requests.post(
|
||||
f"{self._api_url()}/v2/browser",
|
||||
headers=self._headers(),
|
||||
json=body,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to create Firecrawl browser session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info("Created Firecrawl browser session %s", session_name)
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": data["id"],
|
||||
"cdp_url": data["cdpUrl"],
|
||||
"features": {"firecrawl": True},
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
response = requests.delete(
|
||||
f"{self._api_url()}/v2/browser/{session_id}",
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in (200, 201, 204):
|
||||
logger.debug("Successfully closed Firecrawl session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close Firecrawl session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Firecrawl session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
try:
|
||||
requests.delete(
|
||||
f"{self._api_url()}/v2/browser/{session_id}",
|
||||
headers=self._headers(),
|
||||
timeout=5,
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning("Cannot emergency-cleanup Firecrawl session %s — missing credentials", session_id)
|
||||
except Exception as e:
|
||||
logger.debug("Emergency cleanup failed for Firecrawl session %s: %s", session_id, e)
|
||||
@@ -79,6 +79,7 @@ except Exception:
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
from tools.browser_providers.browserbase import BrowserbaseProvider
|
||||
from tools.browser_providers.browser_use import BrowserUseProvider
|
||||
from tools.browser_providers.firecrawl import FirecrawlProvider
|
||||
from tools.tool_backend_helpers import normalize_browser_cloud_provider
|
||||
|
||||
# Camofox local anti-detection browser backend (optional).
|
||||
@@ -235,6 +236,7 @@ def _get_cdp_override() -> str:
|
||||
_PROVIDER_REGISTRY: Dict[str, type] = {
|
||||
"browserbase": BrowserbaseProvider,
|
||||
"browser-use": BrowserUseProvider,
|
||||
"firecrawl": FirecrawlProvider,
|
||||
}
|
||||
|
||||
_cached_cloud_provider: Optional[CloudBrowserProvider] = None
|
||||
@@ -2036,12 +2038,12 @@ def check_browser_requirements() -> bool:
|
||||
"""
|
||||
Check if browser tool requirements are met.
|
||||
|
||||
In **local mode** (no Browserbase credentials): only the ``agent-browser``
|
||||
CLI must be findable.
|
||||
In **local mode** (no cloud provider configured): only the
|
||||
``agent-browser`` CLI must be findable.
|
||||
|
||||
In **cloud mode** (Browserbase, Browser Use, or Firecrawl): the CLI
|
||||
*and* the provider's required credentials must be present.
|
||||
|
||||
In **cloud mode** (BROWSERBASE_API_KEY set): the CLI *and* both
|
||||
``BROWSERBASE_API_KEY`` / ``BROWSERBASE_PROJECT_ID`` must be present.
|
||||
|
||||
Returns:
|
||||
True if all requirements are met, False otherwise
|
||||
"""
|
||||
|
||||
@@ -22,14 +22,26 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Session-scoped list of credential files to mount.
|
||||
# Key: container_path (deduplicated), Value: host_path
|
||||
_registered_files: Dict[str, str] = {}
|
||||
# Backed by ContextVar to prevent cross-session data bleed in the gateway pipeline.
|
||||
_registered_files_var: ContextVar[Dict[str, str]] = ContextVar("_registered_files")
|
||||
|
||||
|
||||
def _get_registered() -> Dict[str, str]:
|
||||
"""Get or create the registered credential files dict for the current context/session."""
|
||||
try:
|
||||
return _registered_files_var.get()
|
||||
except LookupError:
|
||||
val: Dict[str, str] = {}
|
||||
_registered_files_var.set(val)
|
||||
return val
|
||||
|
||||
|
||||
# Cache for config-based file list (loaded once per process).
|
||||
_config_files: List[Dict[str, str]] | None = None
|
||||
@@ -86,7 +98,7 @@ def register_credential_file(
|
||||
return False
|
||||
|
||||
container_path = f"{container_base.rstrip('/')}/{relative_path}"
|
||||
_registered_files[container_path] = str(resolved)
|
||||
_get_registered()[container_path] = str(resolved)
|
||||
logger.debug("credential_files: registered %s -> %s", resolved, container_path)
|
||||
return True
|
||||
|
||||
@@ -174,7 +186,7 @@ def get_credential_file_mounts() -> List[Dict[str, str]]:
|
||||
mounts: Dict[str, str] = {}
|
||||
|
||||
# Skill-registered files
|
||||
for container_path, host_path in _registered_files.items():
|
||||
for container_path, host_path in _get_registered().items():
|
||||
# Re-check existence (file may have been deleted since registration)
|
||||
if Path(host_path).is_file():
|
||||
mounts[container_path] = host_path
|
||||
@@ -395,7 +407,7 @@ def iter_cache_files(
|
||||
|
||||
def clear_credential_files() -> None:
|
||||
"""Reset the skill-scoped registry (e.g. on session reset)."""
|
||||
_registered_files.clear()
|
||||
_get_registered().clear()
|
||||
|
||||
|
||||
def reset_config_cache() -> None:
|
||||
|
||||
@@ -112,6 +112,45 @@ def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash:
|
||||
return text or None
|
||||
|
||||
|
||||
def _validate_cron_script_path(script: Optional[str]) -> Optional[str]:
|
||||
"""Validate a cron job script path at the API boundary.
|
||||
|
||||
Scripts must be relative paths that resolve within HERMES_HOME/scripts/.
|
||||
Absolute paths and ~ expansion are rejected to prevent arbitrary script
|
||||
execution via prompt injection.
|
||||
|
||||
Returns an error string if blocked, else None (valid).
|
||||
"""
|
||||
if not script or not script.strip():
|
||||
return None # empty/None = clearing the field, always OK
|
||||
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
raw = script.strip()
|
||||
|
||||
# Reject absolute paths and ~ expansion at the API boundary.
|
||||
# Only relative paths within ~/.hermes/scripts/ are allowed.
|
||||
if raw.startswith(("/", "~")) or (len(raw) >= 2 and raw[1] == ":"):
|
||||
return (
|
||||
f"Script path must be relative to ~/.hermes/scripts/. "
|
||||
f"Got absolute or home-relative path: {raw!r}. "
|
||||
f"Place scripts in ~/.hermes/scripts/ and use just the filename."
|
||||
)
|
||||
|
||||
# Validate containment after resolution
|
||||
scripts_dir = get_hermes_home() / "scripts"
|
||||
scripts_dir.mkdir(parents=True, exist_ok=True)
|
||||
resolved = (scripts_dir / raw).resolve()
|
||||
try:
|
||||
resolved.relative_to(scripts_dir.resolve())
|
||||
except ValueError:
|
||||
return (
|
||||
f"Script path escapes the scripts directory via traversal: {raw!r}"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||
prompt = job.get("prompt", "")
|
||||
@@ -176,6 +215,12 @@ def cronjob(
|
||||
if scan_error:
|
||||
return json.dumps({"success": False, "error": scan_error}, indent=2)
|
||||
|
||||
# Validate script path before storing
|
||||
if script:
|
||||
script_error = _validate_cron_script_path(script)
|
||||
if script_error:
|
||||
return json.dumps({"success": False, "error": script_error}, indent=2)
|
||||
|
||||
job = create_job(
|
||||
prompt=prompt or "",
|
||||
schedule=schedule,
|
||||
@@ -272,6 +317,10 @@ def cronjob(
|
||||
updates["base_url"] = _normalize_optional_job_value(base_url, strip_trailing_slash=True)
|
||||
if script is not None:
|
||||
# Pass empty string to clear an existing script
|
||||
if script:
|
||||
script_error = _validate_cron_script_path(script)
|
||||
if script_error:
|
||||
return json.dumps({"success": False, "error": script_error}, indent=2)
|
||||
updates["script"] = _normalize_optional_job_value(script) if script else None
|
||||
if repeat is not None:
|
||||
# Normalize: treat 0 or negative as None (infinite)
|
||||
|
||||
@@ -21,13 +21,26 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Session-scoped set of env var names that should pass through to sandboxes.
|
||||
_allowed_env_vars: set[str] = set()
|
||||
# Backed by ContextVar to prevent cross-session data bleed in the gateway pipeline.
|
||||
_allowed_env_vars_var: ContextVar[set[str]] = ContextVar("_allowed_env_vars")
|
||||
|
||||
|
||||
def _get_allowed() -> set[str]:
|
||||
"""Get or create the allowed env vars set for the current context/session."""
|
||||
try:
|
||||
return _allowed_env_vars_var.get()
|
||||
except LookupError:
|
||||
val: set[str] = set()
|
||||
_allowed_env_vars_var.set(val)
|
||||
return val
|
||||
|
||||
|
||||
# Cache for the config-based allowlist (loaded once per process).
|
||||
_config_passthrough: frozenset[str] | None = None
|
||||
@@ -41,7 +54,7 @@ def register_env_passthrough(var_names: Iterable[str]) -> None:
|
||||
for name in var_names:
|
||||
name = name.strip()
|
||||
if name:
|
||||
_allowed_env_vars.add(name)
|
||||
_get_allowed().add(name)
|
||||
logger.debug("env passthrough: registered %s", name)
|
||||
|
||||
|
||||
@@ -78,19 +91,19 @@ def is_env_passthrough(var_name: str) -> bool:
|
||||
Returns ``True`` if the variable was registered by a skill or listed in
|
||||
the user's ``tools.env_passthrough`` config.
|
||||
"""
|
||||
if var_name in _allowed_env_vars:
|
||||
if var_name in _get_allowed():
|
||||
return True
|
||||
return var_name in _load_config_passthrough()
|
||||
|
||||
|
||||
def get_all_passthrough() -> frozenset[str]:
|
||||
"""Return the union of skill-registered and config-based passthrough vars."""
|
||||
return frozenset(_allowed_env_vars) | _load_config_passthrough()
|
||||
return frozenset(_get_allowed()) | _load_config_passthrough()
|
||||
|
||||
|
||||
def clear_env_passthrough() -> None:
|
||||
"""Reset the skill-scoped allowlist (e.g. on session reset)."""
|
||||
_allowed_env_vars.clear()
|
||||
_get_allowed().clear()
|
||||
|
||||
|
||||
def reset_config_cache() -> None:
|
||||
|
||||
@@ -478,10 +478,125 @@ To set persistent per-model defaults: My Models tab → gear icon on the model
|
||||
|
||||
---
|
||||
|
||||
### WSL2 Networking (Windows Users)
|
||||
|
||||
Since Hermes Agent requires a Unix environment, Windows users run it inside WSL2. If your model server (Ollama, LM Studio, etc.) runs on the **Windows host**, you need to bridge the network gap — WSL2 uses a virtual network adapter with its own subnet, so `localhost` inside WSL2 refers to the Linux VM, **not** the Windows host.
|
||||
|
||||
:::tip Both in WSL2? No problem.
|
||||
If your model server also runs inside WSL2 (common for vLLM, SGLang, and llama-server), `localhost` works as expected — they share the same network namespace. Skip this section.
|
||||
:::
|
||||
|
||||
#### Option 1: Mirrored Networking Mode (Recommended)
|
||||
|
||||
Available on **Windows 11 22H2+**, mirrored mode makes `localhost` work bidirectionally between Windows and WSL2 — the simplest fix.
|
||||
|
||||
1. Create or edit `%USERPROFILE%\.wslconfig` (e.g., `C:\Users\YourName\.wslconfig`):
|
||||
```ini
|
||||
[wsl2]
|
||||
networkingMode=mirrored
|
||||
```
|
||||
|
||||
2. Restart WSL from PowerShell:
|
||||
```powershell
|
||||
wsl --shutdown
|
||||
```
|
||||
|
||||
3. Reopen your WSL2 terminal. `localhost` now reaches Windows services:
|
||||
```bash
|
||||
curl http://localhost:11434/v1/models # Ollama on Windows — works
|
||||
```
|
||||
|
||||
:::note Hyper-V Firewall
|
||||
On some Windows 11 builds, the Hyper-V firewall blocks mirrored connections by default. If `localhost` still doesn't work after enabling mirrored mode, run this in an **Admin PowerShell**:
|
||||
```powershell
|
||||
Set-NetFirewallHyperVVMSetting -Name '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -DefaultInboundAction Allow
|
||||
```
|
||||
:::
|
||||
|
||||
#### Option 2: Use the Windows Host IP (Windows 10 / older builds)
|
||||
|
||||
If you can't use mirrored mode, find the Windows host IP from inside WSL2 and use that instead of `localhost`:
|
||||
|
||||
```bash
|
||||
# Get the Windows host IP (the default gateway of WSL2's virtual network)
|
||||
ip route show | grep -i default | awk '{ print $3 }'
|
||||
# Example output: 172.29.192.1
|
||||
```
|
||||
|
||||
Use that IP in your Hermes config:
|
||||
|
||||
```yaml
|
||||
model:
|
||||
default: qwen2.5-coder:32b
|
||||
provider: custom
|
||||
base_url: http://172.29.192.1:11434/v1 # Windows host IP, not localhost
|
||||
```
|
||||
|
||||
:::tip Dynamic helper
|
||||
The host IP can change on WSL2 restart. You can grab it dynamically in your shell:
|
||||
```bash
|
||||
export WSL_HOST=$(ip route show | grep -i default | awk '{ print $3 }')
|
||||
echo "Windows host at: $WSL_HOST"
|
||||
curl http://$WSL_HOST:11434/v1/models # Test Ollama
|
||||
```
|
||||
|
||||
Or use your machine's mDNS name (requires `libnss-mdns` in WSL2):
|
||||
```bash
|
||||
sudo apt install libnss-mdns
|
||||
curl http://$(hostname).local:11434/v1/models
|
||||
```
|
||||
:::
|
||||
|
||||
#### Server Bind Address (Required for NAT Mode)
|
||||
|
||||
If you're using **Option 2** (NAT mode with the host IP), the model server on Windows must accept connections from outside `127.0.0.1`. By default, most servers only listen on localhost — WSL2 connections in NAT mode come from a different virtual subnet and will be refused. In mirrored mode, `localhost` maps directly so the default `127.0.0.1` binding works fine.
|
||||
|
||||
| Server | Default bind | How to fix |
|
||||
|--------|-------------|------------|
|
||||
| **Ollama** | `127.0.0.1` | Set `OLLAMA_HOST=0.0.0.0` environment variable before starting Ollama (System Settings → Environment Variables on Windows, or edit the Ollama service) |
|
||||
| **LM Studio** | `127.0.0.1` | Enable **"Serve on Network"** in the Developer tab → Server settings |
|
||||
| **llama-server** | `127.0.0.1` | Add `--host 0.0.0.0` to the startup command |
|
||||
| **vLLM** | `0.0.0.0` | Already binds to all interfaces by default |
|
||||
| **SGLang** | `127.0.0.1` | Add `--host 0.0.0.0` to the startup command |
|
||||
|
||||
**Ollama on Windows (detailed):** Ollama runs as a Windows service. To set `OLLAMA_HOST`:
|
||||
1. Open **System Properties** → **Environment Variables**
|
||||
2. Add a new **System variable**: `OLLAMA_HOST` = `0.0.0.0`
|
||||
3. Restart the Ollama service (or reboot)
|
||||
|
||||
#### Windows Firewall
|
||||
|
||||
Windows Firewall treats WSL2 as a separate network (in both NAT and mirrored mode). If connections still fail after the steps above, add a firewall rule for your model server's port:
|
||||
|
||||
```powershell
|
||||
# Run in Admin PowerShell — replace PORT with your server's port
|
||||
New-NetFirewallRule -DisplayName "Allow WSL2 to Model Server" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 11434
|
||||
```
|
||||
|
||||
Common ports: Ollama `11434`, vLLM `8000`, SGLang `30000`, llama-server `8080`, LM Studio `1234`.
|
||||
|
||||
#### Quick Verification
|
||||
|
||||
From inside WSL2, test that you can reach your model server:
|
||||
|
||||
```bash
|
||||
# Replace URL with your server's address and port
|
||||
curl http://localhost:11434/v1/models # Mirrored mode
|
||||
curl http://172.29.192.1:11434/v1/models # NAT mode (use your actual host IP)
|
||||
```
|
||||
|
||||
If you get a JSON response listing your models, you're good. Use that same URL as the `base_url` in your Hermes config.
|
||||
|
||||
---
|
||||
|
||||
### Troubleshooting Local Models
|
||||
|
||||
These issues affect **all** local inference servers when used with Hermes.
|
||||
|
||||
#### "Connection refused" from WSL2 to a Windows-hosted model server
|
||||
|
||||
If you're running Hermes inside WSL2 and your model server on the Windows host, `http://localhost:<port>` won't work in WSL2's default NAT networking mode. See [WSL2 Networking](#wsl2-networking-windows-users) above for the fix.
|
||||
|
||||
#### Tool calls appear as text instead of executing
|
||||
|
||||
The model outputs something like `{"name": "web_search", "arguments": {...}}` as a message instead of actually calling the tool.
|
||||
|
||||
@@ -77,13 +77,14 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `PARALLEL_API_KEY` | AI-native web search ([parallel.ai](https://parallel.ai/)) |
|
||||
| `FIRECRAWL_API_KEY` | Web scraping ([firecrawl.dev](https://firecrawl.dev/)) |
|
||||
| `FIRECRAWL_API_KEY` | Web scraping and cloud browser ([firecrawl.dev](https://firecrawl.dev/)) |
|
||||
| `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) |
|
||||
| `TAVILY_API_KEY` | Tavily API key for AI-native web search, extract, and crawl ([app.tavily.com](https://app.tavily.com/home)) |
|
||||
| `EXA_API_KEY` | Exa API key for AI-native web search and contents ([exa.ai](https://exa.ai/)) |
|
||||
| `BROWSERBASE_API_KEY` | Browser automation ([browserbase.com](https://browserbase.com/)) |
|
||||
| `BROWSERBASE_PROJECT_ID` | Browserbase project ID |
|
||||
| `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) |
|
||||
| `FIRECRAWL_BROWSER_TTL` | Firecrawl browser session TTL in seconds (default: 300) |
|
||||
| `BROWSER_CDP_URL` | Chrome DevTools Protocol URL for local browser (set via `/browser connect`, e.g. `ws://localhost:9222`) |
|
||||
| `CAMOFOX_URL` | Camofox local anti-detection browser URL (default: `http://localhost:9377`) |
|
||||
| `BROWSER_INACTIVITY_TIMEOUT` | Browser session inactivity timeout in seconds |
|
||||
|
||||
@@ -11,6 +11,7 @@ Hermes Agent includes a full browser automation toolset with multiple backend op
|
||||
|
||||
- **Browserbase cloud mode** via [Browserbase](https://browserbase.com) for managed cloud browsers and anti-bot tooling
|
||||
- **Browser Use cloud mode** via [Browser Use](https://browser-use.com) as an alternative cloud browser provider
|
||||
- **Firecrawl cloud mode** via [Firecrawl](https://firecrawl.dev) for cloud browsers with built-in scraping
|
||||
- **Camofox local mode** via [Camofox](https://github.com/jo-inc/camofox-browser) for local anti-detection browsing (Firefox-based fingerprint spoofing)
|
||||
- **Local Chrome via CDP** — connect browser tools to your own Chrome instance using `/browser connect`
|
||||
- **Local browser mode** via the `agent-browser` CLI and a local Chromium installation
|
||||
@@ -23,7 +24,7 @@ Pages are represented as **accessibility trees** (text-based snapshots), making
|
||||
|
||||
Key capabilities:
|
||||
|
||||
- **Multi-provider cloud execution** — Browserbase or Browser Use, no local browser needed
|
||||
- **Multi-provider cloud execution** — Browserbase, Browser Use, or Firecrawl — no local browser needed
|
||||
- **Local Chrome integration** — attach to your running Chrome via CDP for hands-on browsing
|
||||
- **Built-in stealth** — random fingerprints, CAPTCHA solving, residential proxies (Browserbase)
|
||||
- **Session isolation** — each task gets its own browser session
|
||||
@@ -55,6 +56,32 @@ BROWSER_USE_API_KEY=***
|
||||
|
||||
Get your API key at [browser-use.com](https://browser-use.com). Browser Use provides a cloud browser via its REST API. If both Browserbase and Browser Use credentials are set, Browserbase takes priority.
|
||||
|
||||
### Firecrawl cloud mode
|
||||
|
||||
To use Firecrawl as your cloud browser provider, add:
|
||||
|
||||
```bash
|
||||
# Add to ~/.hermes/.env
|
||||
FIRECRAWL_API_KEY=fc-***
|
||||
```
|
||||
|
||||
Get your API key at [firecrawl.dev](https://firecrawl.dev). Then select Firecrawl as your browser provider:
|
||||
|
||||
```bash
|
||||
hermes setup tools
|
||||
# → Browser Automation → Firecrawl
|
||||
```
|
||||
|
||||
Optional settings:
|
||||
|
||||
```bash
|
||||
# Self-hosted Firecrawl instance (default: https://api.firecrawl.dev)
|
||||
FIRECRAWL_API_URL=http://localhost:3002
|
||||
|
||||
# Session TTL in seconds (default: 300)
|
||||
FIRECRAWL_BROWSER_TTL=600
|
||||
```
|
||||
|
||||
### Camofox local mode
|
||||
|
||||
[Camofox](https://github.com/jo-inc/camofox-browser) is a self-hosted Node.js server wrapping Camoufox (a Firefox fork with C++ fingerprint spoofing). It provides local anti-detection browsing without cloud dependencies.
|
||||
|
||||
Reference in New Issue
Block a user