Add PageIndex-style memory system + Genre Universe documentation

This commit is contained in:
Jake Shore 2026-01-26 16:14:58 -05:00
parent 4c8c82f6ae
commit a4f4e08ee4
32 changed files with 5308 additions and 1 deletions

View File

@ -8,6 +8,14 @@
"frontend-design": { "frontend-design": {
"version": "1.0.0", "version": "1.0.0",
"installedAt": 1769325240066 "installedAt": 1769325240066
},
"browser-use": {
"version": "1.0.0",
"installedAt": 1769422454571
},
"agent-browser": {
"version": "0.2.0",
"installedAt": 1769423250734
} }
} }
} }

1
.gitignore vendored
View File

@ -26,3 +26,4 @@ npm-debug.log*
package-lock.json package-lock.json
reonomy-scraper.log reonomy-scraper.log
Thumbs.db Thumbs.db
pageindex-framework/

View File

@ -40,3 +40,11 @@ This keeps identity, memory, and progress backed up. Consider making it private
## Discord-specific rule ## Discord-specific rule
- If you ever feel like you lack context in a Discord conversation, **proactively read the past few messages** in that channel using the message tool (action=search or action=read with before/after parameters) before asking for clarification. - If you ever feel like you lack context in a Discord conversation, **proactively read the past few messages** in that channel using the message tool (action=search or action=read with before/after parameters) before asking for clarification.
## Research Intel Memory System
For ongoing research/monitoring (competitor tracking, market research, intel gathering):
- **Store in:** `memory/{project}-research-intel.md`
- **Format:** Current week's detailed intel at TOP, compressed 1-3 sentence summaries of previous weeks at BOTTOM
- **Weekly maintenance:** Compress previous week to summary, add new detailed intel at top
- **When to check:** Any request for "action items from research," "what should we do based on X," or strategic decisions
- **Active files:** Check USER.md for list of active research intel files

12
USER.md
View File

@ -28,6 +28,7 @@
- **LSAT edtech company ("The Burton Method")** - **LSAT edtech company ("The Burton Method")**
- Tutoring + drilling platform with community, recurring revenue, and plans for **AI-driven customization**. - Tutoring + drilling platform with community, recurring revenue, and plans for **AI-driven customization**.
- Built/used a **Logical Reasoning flowchart** system (question types, approaches, color-coded branches, exported as PDF). - Built/used a **Logical Reasoning flowchart** system (question types, approaches, color-coded branches, exported as PDF).
- **Research intel:** `memory/burton-method-research-intel.md` — weekly competitor + EdTech digest with action items
- **Real estate / CRE CRM + onboarding automation ("CRE Sync CRM", "Real Connect V2")** - **Real estate / CRE CRM + onboarding automation ("CRE Sync CRM", "Real Connect V2")**
- Designing a **conditional onboarding flow** that routes users based on goals, lead sources, CRM usage, brokerage/GCI, recruiting/coaching, etc. - Designing a **conditional onboarding flow** that routes users based on goals, lead sources, CRM usage, brokerage/GCI, recruiting/coaching, etc.
@ -69,6 +70,17 @@
- **Git backup**: Run `cd ~/.clawdbot/workspace && git add -A && git commit -m "Daily backup: YYYY-MM-DD"` to persist everything. - **Git backup**: Run `cd ~/.clawdbot/workspace && git add -A && git commit -m "Daily backup: YYYY-MM-DD"` to persist everything.
- **Context refresh**: On session start, read today + yesterday's memory files. - **Context refresh**: On session start, read today + yesterday's memory files.
### Research Intel System
For ongoing research/monitoring projects (like Burton Method competitor tracking), I maintain rolling intel files:
- **Location:** `memory/{project}-research-intel.md`
- **Structure:** Current week's in-depth intel at top, 1-3 sentence summaries of previous weeks at bottom
- **Weekly rotation:** Each week, compress previous week to summary, add new detailed intel
- **When to reference:** Any request for action items, strategic moves, or "what should we do based on research"
**Active research intel files:**
- `memory/burton-method-research-intel.md` — Competitor + EdTech trends for The Burton Method
### Who you are (based on what youve shared) ### Who you are (based on what youve shared)
- **Jake** — a builder/operator who runs multiple tracks at once: edtech, real estate/CRM tooling, CFO-style business strategy, and creative projects. - **Jake** — a builder/operator who runs multiple tracks at once: edtech, real estate/CRM tooling, CFO-style business strategy, and creative projects.

View File

@ -0,0 +1,176 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "librosa>=0.10.0",
# "numpy",
# "scipy",
# "soundfile",
# ]
# ///
import librosa
import numpy as np
import sys
import json
import os
from pathlib import Path
def analyze_track(audio_path):
"""Analyze a single track and return features."""
print(f" Analyzing: {os.path.basename(audio_path)}")
y, sr = librosa.load(audio_path, sr=22050, duration=180)
# Tempo
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
tempo = float(tempo) if not hasattr(tempo, '__len__') else float(tempo[0])
# Energy
rms = librosa.feature.rms(y=y)[0]
energy = float(np.mean(rms))
energy_std = float(np.std(rms))
# Spectral features
spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
brightness = float(np.mean(spectral_centroid))
# Harmonic/Percussive
y_harmonic, y_percussive = librosa.effects.hpss(y)
harmonic_ratio = float(np.sum(np.abs(y_harmonic)) / (np.sum(np.abs(y)) + 1e-6))
# Chroma
chroma = librosa.feature.chroma_stft(y=y, sr=sr)
chroma_mean = float(np.mean(chroma))
# Onset strength
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
rhythmic_density = float(np.mean(onset_env))
# Zero crossing rate
zcr = librosa.feature.zero_crossing_rate(y)[0]
percussiveness = float(np.mean(zcr))
return {
"tempo_bpm": tempo,
"energy_rms": energy,
"energy_std": energy_std,
"brightness_hz": brightness,
"harmonic_ratio": harmonic_ratio,
"chroma_mean": chroma_mean,
"rhythmic_density": rhythmic_density,
"percussiveness": percussiveness,
}
def normalize_features(raw):
"""Convert raw features to 0-1 Genre Universe scales."""
# Tempo: 60-180 BPM
tempo_norm = np.clip((raw["tempo_bpm"] - 60) / 120, 0, 1)
# Energy: RMS 0.01-0.25
energy_norm = np.clip(raw["energy_rms"] / 0.2, 0, 1)
# Brightness: 1000-4000 Hz
brightness_norm = np.clip((raw["brightness_hz"] - 1000) / 3000, 0, 1)
# Organic score
organic_norm = np.clip(raw["harmonic_ratio"] * 1.2 - brightness_norm * 0.2, 0, 1)
# Valence estimate (rough)
valence_norm = np.clip(0.25 + brightness_norm * 0.25 + raw["chroma_mean"] * 0.3, 0, 1)
# Danceability
dance_tempo = 1 - abs(raw["tempo_bpm"] - 120) / 60
danceability = np.clip(dance_tempo * 0.4 + raw["rhythmic_density"] * 0.35 + energy_norm * 0.25, 0, 1)
# Acousticness (related to organic but different)
acousticness = np.clip(raw["harmonic_ratio"] * 0.7 + (1 - brightness_norm) * 0.3, 0, 1)
# Production density
prod_density = np.clip((1 - raw["harmonic_ratio"]) * 0.5 + raw["energy_std"] * 8 + energy_norm * 0.3, 0, 1)
return {
"position": {
"valence": round(float(valence_norm), 2),
"tempo": round(float(tempo_norm), 2),
"organic": round(float(organic_norm), 2),
},
"spikes": {
"energy": round(float(energy_norm), 2),
"acousticness": round(float(acousticness), 2),
"danceability": round(float(danceability), 2),
"production_density": round(float(prod_density), 2),
}
}
if __name__ == "__main__":
tracks_dir = Path("surya_tracks")
mp3_files = list(tracks_dir.glob("*.mp3"))
if not mp3_files:
print("No MP3 files found!")
sys.exit(1)
print(f"\n🎵 ANALYZING {len(mp3_files)} TRACKS FROM SURYA ALBUM\n")
print("="*60)
all_raw = []
track_results = []
for mp3 in sorted(mp3_files):
raw = analyze_track(str(mp3))
all_raw.append(raw)
normalized = normalize_features(raw)
track_results.append({
"track": mp3.stem,
"raw": raw,
"normalized": normalized
})
print(f" Tempo: {raw['tempo_bpm']:.1f} BPM | Energy: {raw['energy_rms']:.3f} | Harmonic: {raw['harmonic_ratio']:.2f}")
# Average all tracks
print("\n" + "="*60)
print("📊 ALBUM AVERAGES")
print("="*60)
avg_raw = {
key: np.mean([t[key] for t in all_raw])
for key in all_raw[0].keys()
}
print(f"\n Average Tempo: {avg_raw['tempo_bpm']:.1f} BPM")
print(f" Average Energy: {avg_raw['energy_rms']:.4f}")
print(f" Average Brightness: {avg_raw['brightness_hz']:.1f} Hz")
print(f" Average Harmonic Ratio: {avg_raw['harmonic_ratio']:.3f}")
avg_normalized = normalize_features(avg_raw)
print("\n" + "="*60)
print("🎯 RECOMMENDED DAS POSITION (Album Average)")
print("="*60)
pos = avg_normalized["position"]
spikes = avg_normalized["spikes"]
print(f"\n Position in Genre Universe:")
print(f" valence: {pos['valence']} (mood: {'sad' if pos['valence'] < 0.4 else 'happy' if pos['valence'] > 0.6 else 'bittersweet'})")
print(f" tempo: {pos['tempo']} ({'slow' if pos['tempo'] < 0.4 else 'fast' if pos['tempo'] > 0.6 else 'medium'})")
print(f" organic: {pos['organic']} ({'electronic' if pos['organic'] < 0.4 else 'organic' if pos['organic'] > 0.6 else 'balanced'})")
print(f"\n Spike Values:")
for key, val in spikes.items():
bar = "" * int(val * 20) + "" * (20 - int(val * 20))
print(f" {key:20} [{bar}] {val}")
# Output JSON for programmatic use
result = {
"tracks_analyzed": len(mp3_files),
"track_names": [mp3.stem for mp3 in sorted(mp3_files)],
"raw_averages": {k: round(v, 4) for k, v in avg_raw.items()},
"recommended_position": pos,
"recommended_spikes": spikes,
}
print("\n" + "="*60)
print("📄 JSON OUTPUT")
print("="*60)
print(json.dumps(result, indent=2))

View File

@ -0,0 +1,157 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "librosa>=0.10.0",
# "numpy",
# "scipy",
# "soundfile",
# ]
# ///
import librosa
import numpy as np
import sys
import json
def analyze_track(audio_path):
"""
Analyze an audio track and extract features for Genre Universe positioning.
"""
print(f"Loading: {audio_path}")
# Load the audio file
y, sr = librosa.load(audio_path, sr=22050, duration=180) # First 3 minutes
print("Analyzing audio features...")
# === TEMPO / RHYTHM ===
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
tempo = float(tempo) if not hasattr(tempo, '__len__') else float(tempo[0])
# === ENERGY ===
rms = librosa.feature.rms(y=y)[0]
energy = float(np.mean(rms))
energy_std = float(np.std(rms)) # Dynamic range indicator
# === SPECTRAL FEATURES ===
spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
brightness = float(np.mean(spectral_centroid)) # Higher = brighter/more electronic
spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr)[0]
rolloff = float(np.mean(spectral_rolloff))
spectral_contrast = librosa.feature.spectral_contrast(y=y, sr=sr)
contrast = float(np.mean(spectral_contrast))
# === ZERO CROSSING RATE (percussiveness) ===
zcr = librosa.feature.zero_crossing_rate(y)[0]
percussiveness = float(np.mean(zcr))
# === MFCC (timbral texture) ===
mfccs = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)
mfcc_mean = [float(np.mean(mfcc)) for mfcc in mfccs]
# === CHROMA (harmonic content) ===
chroma = librosa.feature.chroma_stft(y=y, sr=sr)
chroma_mean = float(np.mean(chroma))
# === ONSET STRENGTH (rhythmic density) ===
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
rhythmic_density = float(np.mean(onset_env))
# === HARMONIC/PERCUSSIVE SEPARATION ===
y_harmonic, y_percussive = librosa.effects.hpss(y)
harmonic_ratio = float(np.sum(np.abs(y_harmonic)) / (np.sum(np.abs(y)) + 1e-6))
# === NORMALIZE TO 0-1 SCALES ===
# These normalizations are rough estimates based on typical ranges
# Tempo: 60-180 BPM typical range
tempo_normalized = np.clip((tempo - 60) / 120, 0, 1)
# Energy: RMS typically 0.01-0.3
energy_normalized = np.clip(energy / 0.2, 0, 1)
# Brightness: spectral centroid typically 1000-4000 Hz
brightness_normalized = np.clip((brightness - 1000) / 3000, 0, 1)
# Organic vs Electronic (inverse of brightness + harmonic ratio)
organic_score = np.clip(harmonic_ratio * 1.5 - brightness_normalized * 0.3, 0, 1)
# Valence estimation (very rough - higher brightness + major key tendencies = happier)
# This is a simplification - real valence detection is complex
valence_estimate = np.clip(0.3 + brightness_normalized * 0.3 + chroma_mean * 0.2, 0, 1)
# Danceability (tempo in sweet spot + strong beats + rhythmic density)
dance_tempo_factor = 1 - abs(tempo - 120) / 60 # Peak at 120 BPM
danceability = np.clip(dance_tempo_factor * 0.5 + rhythmic_density * 0.3 + energy_normalized * 0.2, 0, 1)
results = {
"raw_features": {
"tempo_bpm": round(tempo, 1),
"energy_rms": round(energy, 4),
"energy_std": round(energy_std, 4),
"brightness_hz": round(brightness, 1),
"spectral_rolloff": round(rolloff, 1),
"spectral_contrast": round(contrast, 2),
"percussiveness": round(percussiveness, 4),
"rhythmic_density": round(rhythmic_density, 2),
"harmonic_ratio": round(harmonic_ratio, 3),
"chroma_mean": round(chroma_mean, 3),
},
"genre_universe_position": {
"valence": round(float(valence_estimate), 2),
"tempo": round(float(tempo_normalized), 2),
"organic": round(float(organic_score), 2),
},
"genre_universe_spikes": {
"energy": round(float(energy_normalized), 2),
"acousticness": round(float(organic_score * 0.8), 2),
"danceability": round(float(danceability), 2),
"production_density": round(float(1 - harmonic_ratio + energy_std * 5), 2),
},
"insights": {
"tempo_feel": "slow" if tempo < 90 else "medium" if tempo < 130 else "fast",
"energy_level": "low" if energy_normalized < 0.33 else "medium" if energy_normalized < 0.66 else "high",
"sonic_character": "organic/warm" if organic_score > 0.6 else "electronic/bright" if organic_score < 0.4 else "balanced",
}
}
return results
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python analyze_track.py <audio_file>")
sys.exit(1)
audio_path = sys.argv[1]
results = analyze_track(audio_path)
print("\n" + "="*60)
print("GENRE UNIVERSE AUDIO ANALYSIS")
print("="*60)
print("\n📊 RAW AUDIO FEATURES:")
for key, value in results["raw_features"].items():
print(f" {key}: {value}")
print("\n🎯 GENRE UNIVERSE POSITION (0-1 scale):")
pos = results["genre_universe_position"]
print(f" X (Valence/Mood): {pos['valence']} {'← sad' if pos['valence'] < 0.4 else '→ happy' if pos['valence'] > 0.6 else '~ neutral'}")
print(f" Y (Tempo): {pos['tempo']} {'← slow' if pos['tempo'] < 0.4 else '→ fast' if pos['tempo'] > 0.6 else '~ medium'}")
print(f" Z (Organic): {pos['organic']} {'← electronic' if pos['organic'] < 0.4 else '→ organic' if pos['organic'] > 0.6 else '~ balanced'}")
print("\n⚡ SPIKE VALUES (0-1 scale):")
for key, value in results["genre_universe_spikes"].items():
bar = "" * int(value * 20) + "" * (20 - int(value * 20))
print(f" {key:20} [{bar}] {value}")
print("\n💡 INSIGHTS:")
for key, value in results["insights"].items():
print(f" {key}: {value}")
print("\n" + "="*60)
# Also output JSON for programmatic use
print("\n📄 JSON OUTPUT:")
print(json.dumps(results, indent=2))

597
genre-viz/index.html Normal file
View File

@ -0,0 +1,597 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Genre Universe - Das Artist Positioning</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #000; font-family: 'Space Grotesk', system-ui, sans-serif; }
#canvas { display: block; }
.panel {
position: fixed;
background: linear-gradient(135deg, rgba(15, 15, 30, 0.95), rgba(5, 5, 15, 0.98));
border: 1px solid rgba(168, 85, 247, 0.15);
border-radius: 16px;
padding: 20px;
color: white;
backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 60px rgba(168, 85, 247, 0.05);
z-index: 100;
}
#info-panel { top: 20px; left: 20px; max-width: 280px; }
#info-panel h1 {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 12px;
background: linear-gradient(135deg, #00f5ff, #a855f7, #ff00ff);
background-size: 200% 200%;
animation: gradient-shift 3s ease infinite;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes gradient-shift { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
#info-panel p { font-size: 0.75rem; color: rgba(255,255,255,0.5); line-height: 1.6; }
.subtitle { margin-top: 10px; font-size: 0.65rem !important; font-family: 'JetBrains Mono', monospace; color: rgba(255,255,255,0.3) !important; }
#legend { bottom: 20px; left: 20px; max-height: 350px; overflow-y: auto; }
#legend::-webkit-scrollbar { width: 4px; }
#legend::-webkit-scrollbar-thumb { background: rgba(168,85,247,0.3); border-radius: 2px; }
.panel h3 { font-size: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; margin-bottom: 12px; color: rgba(255,255,255,0.4); }
.legend-item { display: flex; align-items: center; margin-bottom: 6px; font-size: 0.7rem; color: rgba(255,255,255,0.6); padding: 3px 6px; margin-left: -6px; border-radius: 4px; cursor: pointer; transition: all 0.2s; }
.legend-item:hover { background: rgba(168,85,247,0.1); color: white; }
.legend-color { width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; box-shadow: 0 0 6px currentColor; }
#axes-legend { bottom: 20px; right: 20px; }
.axis-item { display: flex; align-items: center; margin-bottom: 6px; font-size: 0.65rem; color: rgba(255,255,255,0.5); font-family: 'JetBrains Mono', monospace; }
.axis-color { width: 14px; height: 2px; margin-right: 8px; border-radius: 1px; }
#controls { top: 20px; right: 20px; }
.control-row { display: flex; align-items: center; margin-bottom: 10px; font-size: 0.7rem; }
.control-row label { flex: 1; color: rgba(255,255,255,0.5); }
.control-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: #a855f7; }
.control-row input[type="range"] { width: 90px; height: 3px; -webkit-appearance: none; background: rgba(255,255,255,0.1); border-radius: 2px; }
.control-row input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; background: linear-gradient(135deg, #00f5ff, #a855f7); border-radius: 50%; box-shadow: 0 0 10px rgba(168,85,247,0.5); }
button { background: linear-gradient(135deg, rgba(168,85,247,0.25), rgba(0,245,255,0.15)); border: 1px solid rgba(168,85,247,0.25); color: white; padding: 8px 14px; border-radius: 6px; font-size: 0.7rem; font-family: 'Space Grotesk', sans-serif; font-weight: 500; margin-top: 6px; width: 100%; cursor: pointer; text-transform: uppercase; letter-spacing: 0.05em; transition: all 0.3s; }
button:hover { background: linear-gradient(135deg, rgba(168,85,247,0.4), rgba(0,245,255,0.25)); box-shadow: 0 0 20px rgba(168,85,247,0.3); }
#tooltip { position: fixed; background: rgba(10,10,20,0.98); border: 1px solid rgba(168,85,247,0.3); border-radius: 10px; padding: 14px 18px; color: white; pointer-events: none; opacity: 0; transition: opacity 0.2s; z-index: 1000; max-width: 260px; backdrop-filter: blur(20px); }
#tooltip.visible { opacity: 1; }
#tooltip h4 { font-size: 0.95rem; font-weight: 600; margin-bottom: 8px; }
#tooltip .description { font-size: 0.65rem; color: rgba(255,255,255,0.4); margin-bottom: 10px; line-height: 1.5; font-style: italic; }
#tooltip .stats { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 12px; font-size: 0.65rem; font-family: 'JetBrains Mono', monospace; }
#tooltip .stat-label { color: rgba(255,255,255,0.35); }
#tooltip .stat-value { color: rgba(255,255,255,0.85); text-align: right; }
#loading { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; display: flex; align-items: center; justify-content: center; z-index: 9999; transition: opacity 0.8s; }
#loading.hidden { opacity: 0; pointer-events: none; }
.loader { width: 50px; height: 50px; border: 2px solid rgba(168,85,247,0.2); border-top-color: #a855f7; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="loading"><div class="loader"></div></div>
<canvas id="canvas"></canvas>
<div id="info-panel" class="panel">
<h1>🎵 Genre Universe</h1>
<p>Interactive 3D visualization of musical genre space. Artists positioned by sonic DNA, spikes show dimensional attributes.</p>
<p class="subtitle">DRAG → ROTATE • SCROLL → ZOOM • HOVER → INFO</p>
</div>
<div id="legend" class="panel">
<h3>Artists</h3>
<div id="artist-legend"></div>
</div>
<div id="axes-legend" class="panel">
<h3>Dimensions</h3>
<div class="axis-item"><div class="axis-color" style="background: #ff6b6b;"></div><span>↑ Energy</span></div>
<div class="axis-item"><div class="axis-color" style="background: #4ecdc4;"></div><span>↓ Acousticness</span></div>
<div class="axis-item"><div class="axis-color" style="background: #ffe66d;"></div><span>← Emotion</span></div>
<div class="axis-item"><div class="axis-color" style="background: #95e1d3;"></div><span>→ Danceability</span></div>
<h3 style="margin-top: 14px;">Position</h3>
<div class="axis-item"><div class="axis-color" style="background: #ff6b6b;"></div><span>X: Valence</span></div>
<div class="axis-item"><div class="axis-color" style="background: #4ade80;"></div><span>Y: Tempo</span></div>
<div class="axis-item"><div class="axis-color" style="background: #60a5fa;"></div><span>Z: Organic</span></div>
</div>
<div id="controls" class="panel">
<h3>Controls</h3>
<div class="control-row"><label>Auto-rotate</label><input type="checkbox" id="autoRotate" checked></div>
<div class="control-row"><label>Show grid</label><input type="checkbox" id="showGrid" checked></div>
<div class="control-row"><label>Show spikes</label><input type="checkbox" id="showSpikes" checked></div>
<div class="control-row"><label>Show connections</label><input type="checkbox" id="showConnections" checked></div>
<div class="control-row"><label>Bloom</label><input type="range" id="glowIntensity" min="0" max="2.5" step="0.1" value="1.5"></div>
<div class="control-row"><label>Spacing</label><input type="range" id="spacing" min="0.5" max="2" step="0.1" value="1"></div>
<button id="focusDas">Focus on Das ⭐</button>
<button id="resetCamera">Reset View</button>
</div>
<div id="tooltip">
<h4 id="tooltip-name"></h4>
<p class="description" id="tooltip-desc"></p>
<div class="stats" id="tooltip-stats"></div>
</div>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
// Artist data
const artists = [
{ name: "Das", color: 0x00ffff, position: { valence: 0.43, tempo: 0.35, organic: 0.7 }, spikes: { energy: 0.9, acousticness: 0.6, emotionalDepth: 0.95, danceability: 0.85, lyricComplexity: 0.85, productionDensity: 0.8 }, description: "Singer-songwriter meets bass. Organic vocals with melodic bass drops.", isMain: true },
{ name: "San Holo", color: 0x4a90d9, position: { valence: 0.55, tempo: 0.6, organic: 0.45 }, spikes: { energy: 0.7, acousticness: 0.35, emotionalDepth: 0.8, danceability: 0.65, lyricComplexity: 0.6, productionDensity: 0.7 }, description: "Future bass pioneer with guitar-driven sound." },
{ name: "Illenium", color: 0xff6b9d, position: { valence: 0.4, tempo: 0.7, organic: 0.35 }, spikes: { energy: 0.85, acousticness: 0.25, emotionalDepth: 0.9, danceability: 0.75, lyricComplexity: 0.7, productionDensity: 0.85 }, description: "Melodic dubstep with massive emotional builds." },
{ name: "Seven Lions", color: 0x9b59b6, position: { valence: 0.45, tempo: 0.75, organic: 0.25 }, spikes: { energy: 0.9, acousticness: 0.15, emotionalDepth: 0.85, danceability: 0.8, lyricComplexity: 0.65, productionDensity: 0.95 }, description: "Melodic dubstep meets trance." },
{ name: "Kygo", color: 0xf39c12, position: { valence: 0.75, tempo: 0.55, organic: 0.6 }, spikes: { energy: 0.55, acousticness: 0.5, emotionalDepth: 0.6, danceability: 0.7, lyricComplexity: 0.5, productionDensity: 0.5 }, description: "Tropical house pioneer." },
{ name: "Joji", color: 0xe74c3c, position: { valence: 0.25, tempo: 0.4, organic: 0.7 }, spikes: { energy: 0.35, acousticness: 0.6, emotionalDepth: 0.95, danceability: 0.4, lyricComplexity: 0.8, productionDensity: 0.55 }, description: "Lo-fi R&B with deep melancholy." },
{ name: "Porter Robinson", color: 0xe91e8c, position: { valence: 0.6, tempo: 0.65, organic: 0.35 }, spikes: { energy: 0.75, acousticness: 0.3, emotionalDepth: 0.9, danceability: 0.65, lyricComplexity: 0.75, productionDensity: 0.8 }, description: "Anime-inspired emotional electronic." },
{ name: "Odesza", color: 0x3498db, position: { valence: 0.6, tempo: 0.55, organic: 0.5 }, spikes: { energy: 0.65, acousticness: 0.45, emotionalDepth: 0.75, danceability: 0.7, lyricComplexity: 0.55, productionDensity: 0.7 }, description: "Atmospheric electronic journeys." },
{ name: "Subtronics", color: 0x8e44ad, position: { valence: 0.45, tempo: 0.85, organic: 0.1 }, spikes: { energy: 0.98, acousticness: 0.05, emotionalDepth: 0.4, danceability: 0.9, lyricComplexity: 0.2, productionDensity: 0.98 }, description: "Heavy dubstep and riddim." },
{ name: "Brakence", color: 0x00ff88, position: { valence: 0.3, tempo: 0.5, organic: 0.55 }, spikes: { energy: 0.6, acousticness: 0.4, emotionalDepth: 0.9, danceability: 0.5, lyricComplexity: 0.95, productionDensity: 0.75 }, description: "Hyperpop-adjacent emotional chaos." },
{ name: "Flume", color: 0xff6f61, position: { valence: 0.5, tempo: 0.55, organic: 0.45 }, spikes: { energy: 0.6, acousticness: 0.35, emotionalDepth: 0.7, danceability: 0.6, lyricComplexity: 0.5, productionDensity: 0.85 }, description: "Experimental glitchy electronic." },
{ name: "Frank Ocean", color: 0xffa500, position: { valence: 0.35, tempo: 0.45, organic: 0.55 }, spikes: { energy: 0.4, acousticness: 0.5, emotionalDepth: 0.98, danceability: 0.4, lyricComplexity: 0.95, productionDensity: 0.7 }, description: "Experimental R&B legend." },
{ name: "keshi", color: 0xffd700, position: { valence: 0.35, tempo: 0.42, organic: 0.6 }, spikes: { energy: 0.35, acousticness: 0.55, emotionalDepth: 0.9, danceability: 0.45, lyricComplexity: 0.8, productionDensity: 0.55 }, description: "Lo-fi R&B melancholy." },
{ name: "Eden", color: 0x2f4f4f, position: { valence: 0.2, tempo: 0.45, organic: 0.6 }, spikes: { energy: 0.45, acousticness: 0.55, emotionalDepth: 0.98, danceability: 0.35, lyricComplexity: 0.95, productionDensity: 0.7 }, description: "Dark indie electronic devastation." },
{ name: "Clairo", color: 0xffb6c1, position: { valence: 0.4, tempo: 0.42, organic: 0.7 }, spikes: { energy: 0.3, acousticness: 0.7, emotionalDepth: 0.8, danceability: 0.4, lyricComplexity: 0.7, productionDensity: 0.4 }, description: "Bedroom pop pioneer." },
{ name: "KSHMR", color: 0xff8c00, position: { valence: 0.55, tempo: 0.75, organic: 0.35 }, spikes: { energy: 0.85, acousticness: 0.3, emotionalDepth: 0.6, danceability: 0.8, lyricComplexity: 0.4, productionDensity: 0.9 }, description: "Indian/Egyptian big room fusion." },
{ name: "Rezz", color: 0x8b0000, position: { valence: 0.3, tempo: 0.6, organic: 0.15 }, spikes: { energy: 0.7, acousticness: 0.1, emotionalDepth: 0.6, danceability: 0.7, lyricComplexity: 0.15, productionDensity: 0.8 }, description: "Dark hypnotic midtempo." },
{ name: "Excision", color: 0x660066, position: { valence: 0.35, tempo: 0.85, organic: 0.05 }, spikes: { energy: 0.99, acousticness: 0.02, emotionalDepth: 0.3, danceability: 0.85, lyricComplexity: 0.1, productionDensity: 0.99 }, description: "Earth-shaking dubstep." },
{ name: "Marshmello", color: 0xffffff, position: { valence: 0.8, tempo: 0.7, organic: 0.2 }, spikes: { energy: 0.75, acousticness: 0.15, emotionalDepth: 0.5, danceability: 0.85, lyricComplexity: 0.35, productionDensity: 0.7 }, description: "Mainstream happy EDM." },
{ name: "Madeon", color: 0xff9ecd, position: { valence: 0.7, tempo: 0.7, organic: 0.35 }, spikes: { energy: 0.75, acousticness: 0.25, emotionalDepth: 0.7, danceability: 0.75, lyricComplexity: 0.6, productionDensity: 0.9 }, description: "Retro-future French electronic." },
{ name: "Daniel Caesar", color: 0xdaa520, position: { valence: 0.4, tempo: 0.4, organic: 0.7 }, spikes: { energy: 0.35, acousticness: 0.7, emotionalDepth: 0.9, danceability: 0.4, lyricComplexity: 0.75, productionDensity: 0.5 }, description: "Soulful gospel R&B." },
{ name: "Bonobo", color: 0x228b22, position: { valence: 0.5, tempo: 0.45, organic: 0.65 }, spikes: { energy: 0.5, acousticness: 0.6, emotionalDepth: 0.75, danceability: 0.55, lyricComplexity: 0.3, productionDensity: 0.65 }, description: "Organic downtempo." },
{ name: "Virtual Self", color: 0x00ffcc, position: { valence: 0.55, tempo: 0.8, organic: 0.15 }, spikes: { energy: 0.9, acousticness: 0.1, emotionalDepth: 0.7, danceability: 0.85, lyricComplexity: 0.4, productionDensity: 0.9 }, description: "Y2K trance revival." },
{ name: "Yung Lean", color: 0x483d8b, position: { valence: 0.25, tempo: 0.5, organic: 0.4 }, spikes: { energy: 0.45, acousticness: 0.25, emotionalDepth: 0.85, danceability: 0.5, lyricComplexity: 0.75, productionDensity: 0.7 }, description: "Cloud rap sad boy pioneer." }
];
// Scene setup
const canvas = document.getElementById('canvas');
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(10, 7, 10);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0';
labelRenderer.domElement.style.pointerEvents = 'none';
document.body.appendChild(labelRenderer.domElement);
// Post-processing
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.6, 0.7);
composer.addPass(bloomPass);
// Controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.25;
controls.minDistance = 5;
controls.maxDistance = 30;
// Lighting
scene.add(new THREE.AmbientLight(0xffffff, 0.15));
const lights = [
{ color: 0x00ffff, pos: [12, 12, 12] },
{ color: 0xff00ff, pos: [-12, -8, 12] },
{ color: 0xa855f7, pos: [0, 15, -10] },
{ color: 0x00ff88, pos: [-10, 5, -10] }
];
lights.forEach(l => {
const light = new THREE.PointLight(l.color, 0.6, 50);
light.position.set(...l.pos);
scene.add(light);
});
// =====================
// COSMIC BACKGROUND
// =====================
// Nebula particles
const nebulaCount = 4000;
const nebulaGeo = new THREE.BufferGeometry();
const nebulaPos = new Float32Array(nebulaCount * 3);
const nebulaCol = new Float32Array(nebulaCount * 3);
const nebulaSizes = new Float32Array(nebulaCount);
for (let i = 0; i < nebulaCount; i++) {
const r = 30 + Math.random() * 40;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
nebulaPos[i*3] = r * Math.sin(phi) * Math.cos(theta);
nebulaPos[i*3+1] = r * Math.sin(phi) * Math.sin(theta);
nebulaPos[i*3+2] = r * Math.cos(phi);
const colors = [[0.4, 0.2, 0.8], [0, 0.9, 1], [1, 0.4, 0.8], [0.2, 0.6, 1]];
const c = colors[Math.floor(Math.random() * colors.length)];
nebulaCol[i*3] = c[0]; nebulaCol[i*3+1] = c[1]; nebulaCol[i*3+2] = c[2];
nebulaSizes[i] = Math.random() * 3 + 1;
}
nebulaGeo.setAttribute('position', new THREE.BufferAttribute(nebulaPos, 3));
nebulaGeo.setAttribute('color', new THREE.BufferAttribute(nebulaCol, 3));
const nebulaMat = new THREE.PointsMaterial({ size: 0.15, vertexColors: true, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, sizeAttenuation: true });
const nebula = new THREE.Points(nebulaGeo, nebulaMat);
scene.add(nebula);
// Inner particles
const innerCount = 800;
const innerGeo = new THREE.BufferGeometry();
const innerPos = new Float32Array(innerCount * 3);
for (let i = 0; i < innerCount; i++) {
innerPos[i*3] = (Math.random() - 0.5) * 14;
innerPos[i*3+1] = (Math.random() - 0.5) * 14;
innerPos[i*3+2] = (Math.random() - 0.5) * 14;
}
innerGeo.setAttribute('position', new THREE.BufferAttribute(innerPos, 3));
const innerMat = new THREE.PointsMaterial({ size: 0.04, color: 0x8888ff, transparent: true, opacity: 0.4, blending: THREE.AdditiveBlending });
const innerParticles = new THREE.Points(innerGeo, innerMat);
scene.add(innerParticles);
// =====================
// HOLOGRAPHIC GRID
// =====================
const gridGroup = new THREE.Group();
scene.add(gridGroup);
const gridSize = 6;
// Outer cube frame with glow
const frameMat = new THREE.LineBasicMaterial({ color: 0xa855f7, transparent: true, opacity: 0.4 });
const frameGeo = new THREE.EdgesGeometry(new THREE.BoxGeometry(gridSize, gridSize, gridSize));
gridGroup.add(new THREE.LineSegments(frameGeo, frameMat));
// Animated scan planes
const scanPlaneMat = new THREE.MeshBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0.03, side: THREE.DoubleSide });
const scanPlanes = [];
for (let i = 0; i < 3; i++) {
const plane = new THREE.Mesh(new THREE.PlaneGeometry(gridSize, gridSize), scanPlaneMat.clone());
if (i === 0) plane.rotation.x = Math.PI / 2;
if (i === 2) plane.rotation.y = Math.PI / 2;
plane.userData.axis = i;
plane.userData.offset = Math.random() * Math.PI * 2;
scanPlanes.push(plane);
gridGroup.add(plane);
}
// Corner nodes
const cornerMat = new THREE.MeshBasicMaterial({ color: 0xa855f7 });
const corners = [[-1,-1,-1],[-1,-1,1],[-1,1,-1],[-1,1,1],[1,-1,-1],[1,-1,1],[1,1,-1],[1,1,1]];
corners.forEach(c => {
const sphere = new THREE.Mesh(new THREE.SphereGeometry(0.06, 16, 16), cornerMat);
sphere.position.set(c[0] * gridSize/2, c[1] * gridSize/2, c[2] * gridSize/2);
gridGroup.add(sphere);
});
// Floor grid with glow
const floorGrid = new THREE.GridHelper(gridSize * 2, 20, 0x4a1f7a, 0x1a0a2e);
floorGrid.position.y = -gridSize / 2 - 0.01;
floorGrid.material.transparent = true;
floorGrid.material.opacity = 0.3;
gridGroup.add(floorGrid);
// =====================
// AXIS LABELS
// =====================
function createAxisLabel(text, position, color) {
const div = document.createElement('div');
div.textContent = text;
div.style.cssText = `
color: ${color};
font-size: 11px;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.15em;
text-shadow: 0 0 10px ${color}, 0 0 20px rgba(0,0,0,0.8);
opacity: 0.8;
`;
const label = new CSS2DObject(div);
label.position.copy(position);
return label;
}
const axisLabels = [
{ text: 'SAD', pos: new THREE.Vector3(-gridSize/2 - 0.8, 0, 0), color: '#ff6b6b' },
{ text: 'HAPPY', pos: new THREE.Vector3(gridSize/2 + 0.8, 0, 0), color: '#4ade80' },
{ text: 'SLOW', pos: new THREE.Vector3(0, -gridSize/2 - 0.8, 0), color: '#60a5fa' },
{ text: 'FAST', pos: new THREE.Vector3(0, gridSize/2 + 0.8, 0), color: '#fbbf24' },
{ text: 'ELECTRONIC', pos: new THREE.Vector3(0, 0, -gridSize/2 - 1.0), color: '#a855f7' },
{ text: 'ORGANIC', pos: new THREE.Vector3(0, 0, gridSize/2 + 1.0), color: '#22d3ee' }
];
axisLabels.forEach(({ text, pos, color }) => {
gridGroup.add(createAxisLabel(text, pos, color));
});
// =====================
// ARTIST NODES
// =====================
const artistMeshes = [], artistLabels = [], spikeGroups = [], artistGroups = [];
const connectionLines = [];
const spikeColors = { energy: 0xff6b6b, acousticness: 0x4ecdc4, emotionalDepth: 0xffe66d, danceability: 0x95e1d3, lyricComplexity: 0xdda0dd, productionDensity: 0xf38181 };
const spikeDirections = {
energy: new THREE.Vector3(0, 1, 0), acousticness: new THREE.Vector3(0, -1, 0),
emotionalDepth: new THREE.Vector3(-1, 0, 0), danceability: new THREE.Vector3(1, 0, 0),
lyricComplexity: new THREE.Vector3(0.707, 0.707, 0), productionDensity: new THREE.Vector3(-0.707, -0.707, 0)
};
// Custom shader for orbs
const orbVertexShader = `
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
vNormal = normalize(normalMatrix * normal);
vPosition = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const orbFragmentShader = `
uniform vec3 uColor;
uniform float uTime;
uniform float uIntensity;
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
float fresnel = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.5);
vec3 glow = uColor * fresnel * 1.5;
float pulse = 0.9 + 0.1 * sin(uTime * 2.0);
vec3 core = uColor * uIntensity * pulse;
gl_FragColor = vec4(core + glow, 1.0);
}
`;
function createArtistNode(artist) {
const group = new THREE.Group();
const x = (artist.position.valence - 0.5) * 6;
const y = (artist.position.tempo - 0.5) * 6;
const z = (artist.position.organic - 0.5) * 6;
group.position.set(x, y, z);
const radius = artist.isMain ? 0.45 : 0.2;
const color = new THREE.Color(artist.color);
// Shader orb
const orbMat = new THREE.ShaderMaterial({
uniforms: { uColor: { value: color }, uTime: { value: 0 }, uIntensity: { value: artist.isMain ? 0.7 : 0.5 } },
vertexShader: orbVertexShader, fragmentShader: orbFragmentShader, transparent: true
});
const orb = new THREE.Mesh(new THREE.SphereGeometry(radius, 64, 64), orbMat);
orb.userData = { artist, type: 'artist', material: orbMat };
group.add(orb);
// Glow layers
[1.3, 1.8, 2.5].forEach((scale, i) => {
const glowMat = new THREE.MeshBasicMaterial({ color: artist.color, transparent: true, opacity: 0.15 - i * 0.04, side: THREE.BackSide });
group.add(new THREE.Mesh(new THREE.SphereGeometry(radius * scale, 24, 24), glowMat));
});
// Rings for main artist
if (artist.isMain) {
[1.6, 2.2, 2.8].forEach((r, i) => {
const ring = new THREE.Mesh(
new THREE.TorusGeometry(radius * r, 0.015, 16, 100),
new THREE.MeshBasicMaterial({ color: artist.color, transparent: true, opacity: 0.5 - i * 0.15 })
);
ring.rotation.x = Math.PI / 2 + i * 0.3;
ring.userData.rotSpeed = 0.3 + i * 0.2;
group.add(ring);
});
// Energy particles around Das
const particleCount = 50;
const particleGeo = new THREE.BufferGeometry();
const particlePos = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
const angle = (i / particleCount) * Math.PI * 2;
const r = radius * 3 + Math.random() * 0.5;
particlePos[i*3] = Math.cos(angle) * r;
particlePos[i*3+1] = (Math.random() - 0.5) * 1.5;
particlePos[i*3+2] = Math.sin(angle) * r;
}
particleGeo.setAttribute('position', new THREE.BufferAttribute(particlePos, 3));
const particleMat = new THREE.PointsMaterial({ size: 0.05, color: artist.color, transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending });
const particles = new THREE.Points(particleGeo, particleMat);
particles.userData.isOrbital = true;
group.add(particles);
}
// Spikes
const spikeGroup = new THREE.Group();
for (const [key, value] of Object.entries(artist.spikes)) {
const length = value * 1.0;
const dir = spikeDirections[key].clone();
const color = spikeColors[key];
// Spike line
const points = [new THREE.Vector3(0,0,0).add(dir.clone().multiplyScalar(radius)), dir.clone().multiplyScalar(radius + length)];
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
const lineMat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.7 });
spikeGroup.add(new THREE.Line(lineGeo, lineMat));
// Tip
const tip = new THREE.Mesh(new THREE.SphereGeometry(0.04, 8, 8), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 }));
tip.position.copy(dir.clone().multiplyScalar(radius + length));
spikeGroup.add(tip);
}
group.add(spikeGroup);
spikeGroups.push(spikeGroup);
// Label
const labelDiv = document.createElement('div');
labelDiv.textContent = artist.name;
labelDiv.style.cssText = `color: #${artist.color.toString(16).padStart(6,'0')}; font-size: ${artist.isMain ? '12px' : '9px'}; font-weight: ${artist.isMain ? '600' : '400'}; font-family: 'Space Grotesk', sans-serif; text-shadow: 0 0 8px rgba(0,0,0,0.9), 0 0 16px #${artist.color.toString(16).padStart(6,'0')}40; letter-spacing: 0.05em;`;
const label = new CSS2DObject(labelDiv);
label.position.set(0, radius + 0.3, 0);
group.add(label);
artistLabels.push(label);
scene.add(group);
artistMeshes.push(orb);
artistGroups.push(group);
return group;
}
artists.forEach(createArtistNode);
// =====================
// CONNECTION LINES
// =====================
const connectionGroup = new THREE.Group();
scene.add(connectionGroup);
function createConnections() {
connectionGroup.clear();
for (let i = 0; i < artistGroups.length; i++) {
for (let j = i + 1; j < artistGroups.length; j++) {
const dist = artistGroups[i].position.distanceTo(artistGroups[j].position);
if (dist < 2.5) {
const opacity = Math.max(0, 0.2 - dist * 0.06);
const points = [artistGroups[i].position.clone(), artistGroups[j].position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color: 0x8855ff, transparent: true, opacity });
connectionGroup.add(new THREE.Line(geo, mat));
}
}
}
}
createConnections();
// Legend
const legendContainer = document.getElementById('artist-legend');
artists.forEach(a => {
const item = document.createElement('div');
item.className = 'legend-item';
item.innerHTML = `<div class="legend-color" style="background:#${a.color.toString(16).padStart(6,'0')};color:#${a.color.toString(16).padStart(6,'0')};"></div><span>${a.name}${a.isMain?' ⭐':''}</span>`;
legendContainer.appendChild(item);
});
// Raycasting
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let hoveredArtist = null;
const tooltip = document.getElementById('tooltip');
window.addEventListener('mousemove', (e) => {
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(artistMeshes);
if (intersects.length > 0) {
const artist = intersects[0].object.userData.artist;
if (hoveredArtist !== artist) {
hoveredArtist = artist;
document.getElementById('tooltip-name').textContent = artist.name;
document.getElementById('tooltip-name').style.color = '#' + artist.color.toString(16).padStart(6, '0');
document.getElementById('tooltip-desc').textContent = artist.description || '';
document.getElementById('tooltip-stats').innerHTML = `
<span class="stat-label">Valence</span><span class="stat-value">${(artist.position.valence*100).toFixed(0)}%</span>
<span class="stat-label">Tempo</span><span class="stat-value">${(artist.position.tempo*100).toFixed(0)}%</span>
<span class="stat-label">Organic</span><span class="stat-value">${(artist.position.organic*100).toFixed(0)}%</span>
<span class="stat-label">Energy</span><span class="stat-value">${(artist.spikes.energy*100).toFixed(0)}%</span>
`;
tooltip.classList.add('visible');
}
tooltip.style.left = Math.min(e.clientX + 15, window.innerWidth - 280) + 'px';
tooltip.style.top = Math.min(e.clientY + 15, window.innerHeight - 180) + 'px';
document.body.style.cursor = 'pointer';
} else {
hoveredArtist = null;
tooltip.classList.remove('visible');
document.body.style.cursor = 'default';
}
});
// Controls
document.getElementById('autoRotate').addEventListener('change', e => controls.autoRotate = e.target.checked);
document.getElementById('showGrid').addEventListener('change', e => gridGroup.visible = e.target.checked);
document.getElementById('showSpikes').addEventListener('change', e => spikeGroups.forEach(g => g.visible = e.target.checked));
document.getElementById('showConnections').addEventListener('change', e => connectionGroup.visible = e.target.checked);
document.getElementById('glowIntensity').addEventListener('input', e => bloomPass.strength = parseFloat(e.target.value));
const originalPositions = artistGroups.map(g => g.position.clone());
document.getElementById('spacing').addEventListener('input', e => {
const scale = parseFloat(e.target.value);
artistGroups.forEach((g, i) => g.position.copy(originalPositions[i].clone().multiplyScalar(scale)));
gridGroup.scale.setScalar(scale);
createConnections();
});
document.getElementById('focusDas').addEventListener('click', () => {
const das = artistGroups.find(g => g.children[0].userData.artist?.isMain);
if (das) { controls.target.copy(das.position); camera.position.set(das.position.x + 4, das.position.y + 2, das.position.z + 4); }
});
document.getElementById('resetCamera').addEventListener('click', () => { controls.target.set(0, 0, 0); camera.position.set(10, 7, 10); });
// Animation
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
nebula.rotation.y = t * 0.01;
innerParticles.rotation.y = -t * 0.02;
innerParticles.rotation.x = Math.sin(t * 0.1) * 0.1;
// Scan planes
scanPlanes.forEach(p => {
const pos = Math.sin(t * 0.5 + p.userData.offset) * 3;
if (p.userData.axis === 0) p.position.y = pos;
else if (p.userData.axis === 1) p.position.z = pos;
else p.position.x = pos;
});
// Update orb shaders
artistMeshes.forEach((m, i) => {
if (m.userData.material) m.userData.material.uniforms.uTime.value = t;
m.scale.setScalar(1 + Math.sin(t * 2 + i) * 0.06);
});
// Rotate Das rings and particles
artistGroups.forEach(g => {
g.children.forEach(c => {
if (c.userData.rotSpeed) c.rotation.z += 0.005 * c.userData.rotSpeed;
if (c.userData.isOrbital) c.rotation.y += 0.01;
});
});
controls.update();
composer.render();
labelRenderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.setSize(window.innerWidth, window.innerHeight);
});
setTimeout(() => document.getElementById('loading').classList.add('hidden'), 600);
animate();
console.log('✨ Genre Universe v2 loaded!');
</script>
</body>
</html>

View File

@ -0,0 +1,5 @@
#!/bin/bash
cd /Users/jakeshore/.clawdbot/workspace/genre-viz
/usr/bin/python3 -m http.server 8420 &
sleep 2
/opt/homebrew/bin/cloudflared tunnel --url http://localhost:8420

10
genre-viz/vercel.json Normal file
View File

@ -0,0 +1,10 @@
{
"version": 2,
"name": "genre-universe",
"builds": [
{ "src": "index.html", "use": "@vercel/static" }
],
"routes": [
{ "src": "/(.*)", "dest": "/index.html" }
]
}

View File

@ -0,0 +1,90 @@
# Genre Universe - 3D Artist Positioning Visualization
**Created:** 2026-01-26
**Location:** `~/.clawdbot/workspace/genre-viz/`
**Live URL:** http://localhost:8420 (service running on Mac mini)
## What It Is
Interactive Three.js 3D visualization showing where Das sits in the genre landscape relative to other artists. Built to help with artist positioning, playlist pitching, and understanding "where does Das fit in the landscape" visually.
## Technical Stack
- **Frontend:** Vanilla HTML + Three.js (no build step)
- **Features:** OrbitControls, UnrealBloomPass post-processing, CSS2DRenderer for labels
- **Hosting:** Python http.server on port 8420, launchd service for auto-start
## Dimensions
### Position (3D axes)
- **X axis (left/right):** SAD ← → HAPPY (valence)
- **Y axis (up/down):** SLOW ← → FAST (tempo)
- **Z axis (front/back):** ELECTRONIC ← → ORGANIC
### Spike Extensions (6 directions per artist)
- ↑ Energy
- ↓ Acousticness
- ← Emotional Depth
- → Danceability
- ↗ Lyrical Complexity
- ↙ Production Density
## Das's Profile
**Position:**
- Valence: 0.43 (slightly melancholy)
- Tempo: 0.35 (slower, breathing tracks)
- Organic: **0.70** (high - singer-songwriter core)
**Spikes:**
- Emotional Depth: 0.95 (highest)
- Energy: 0.90
- Danceability: 0.85
- Lyric Complexity: 0.85
- Acousticness: 0.60
- Production Density: 0.80
## Artist Count
**Peak:** 56 artists mapped (as of 09:49 UTC)
**Current:** 24 artists (accidentally reduced during visual updates)
Artists included: Das, San Holo, Illenium, Seven Lions, Kygo, Joji, Porter Robinson, Odesza, Subtronics, Brakence, Flume, Frank Ocean, keshi, Eden, Clairo, KSHMR, Rezz, Excision, Marshmello, Madeon, Daniel Caesar, Bonobo, Virtual Self, Yung Lean
## Files
- `/Users/jakeshore/.clawdbot/workspace/genre-viz/index.html` - Main visualization
- `/Users/jakeshore/.clawdbot/workspace/genre-viz/analyzer/` - Audio analysis scripts
- `/Users/jakeshore/.clawdbot/workspace/genre-viz/start-genre-universe.sh` - Startup script
## Key Insights
Das bridges the **bedroom pop / singer-songwriter world** with the **bass music world** in a way not many artists do. His organic score is high despite being in electronic music because:
- He actually SINGS, not just processed vox samples
- Pop songwriting structure (verse-chorus-bridge)
- Harmonic richness from melodic content
His lane: **"Organic core + electronic layers"** = singer-songwriter who produces bass music rather than bass producer who adds vocals.
## Sub-genre Name Ideas Generated
1. **Tidal Bass** - Oceanic/Polynesian groove + bass foundation
2. **Pacific Melodic** - Geographic nod + melodic bass core
3. **Ethereal Catharsis** - Describes the emotional release function
4. **Sunset Bass** - Golden hour energy, beautiful-sad
5. **Tearwave** - Play on synthwave, emphasizes emotional release
## Product Vision
Could become a real product where artists:
1. Connect Spotify OAuth → auto-pull audio features
2. Upload track → AI analyzes audio
3. Get positioned in 3D space
4. Receive AI-generated sub-genre names + positioning insights
## TODO
- [ ] Restore 56 artists from Discord history
- [ ] Deploy to permanent URL (Cloudflare named tunnel)
- [ ] Add more artists to fill gaps
- [ ] Build audio upload feature for new artist positioning

79
memory/2026-01-26.md Normal file
View File

@ -0,0 +1,79 @@
# 2026-01-26
## Genre Universe - 3D Artist Visualization
Built a full Three.js interactive 3D visualization showing where Das sits in genre space relative to other artists. See `2026-01-26-genre-universe.md` for full details.
**Key files:** `~/.clawdbot/workspace/genre-viz/`
**Live:** http://localhost:8420
---
## PageIndex Memory System
Implemented PageIndex-style hierarchical memory structure at `memory/INDEX.json`. Tree-based navigation with:
- Node IDs for each knowledge domain
- File references with line numbers
- Summaries and keywords for reasoning-based retrieval
- No vector search - uses tree traversal + reasoning
---
## Reonomy Scraper v13 - Complete Rebuild
### What We Built Today
**1. Production Scraper (`reonomy-scraper-v13.js`)**
- Full anti-detection: random delays (3-8s), shuffled property order, occasional breaks
- Daily limits: tracks properties scraped per day, caps at 50/day
- Session management: saves/loads auth state, auto-relogins if expired
- Appends to existing leads file (doesn't overwrite)
**2. Quick Demo Script (`reonomy-quick-demo.js`)**
- Single-contact extraction in ~60 seconds
- Opens headed browser so you can show someone
- Leaves browser open for inspection
### How to Run
```bash
# Demo (show someone how it works)
node ~/.clawdbot/workspace/reonomy-quick-demo.js
# Production scrape (20 properties, anti-detection)
node ~/.clawdbot/workspace/reonomy-scraper-v13.js
# Custom limits
MAX_PROPERTIES=5 node ~/.clawdbot/workspace/reonomy-scraper-v13.js
```
### Proven Extraction Workflow
1. Login → https://app.reonomy.com/#!/login
2. Search with filters → `/search/{search-id}`
3. Click property card → `/property/{id}/building`
4. Click "Owner" tab → `/property/{id}/ownership`
5. Click "View Contacts (X)" → navigates to company page
6. Click person link → `/person/{person-id}`
7. Click "Contact" button → modal with phones/emails
8. Extract from modal:
- Phones: `button "XXX-XXX-XXXX Company Name"`
- Emails: `button "email@domain.com"`
### Anti-Detection Features
- Random delays 3-8 seconds between actions
- Shuffled property order (not sequential)
- 20% chance of "coffee break" (8-15s pause)
- 30% chance of random scroll/hover actions
- Daily limit of 50 properties
- Session reuse (doesn't login/logout constantly)
### Files
- `/Users/jakeshore/.clawdbot/workspace/reonomy-scraper-v13.js` - Production
- `/Users/jakeshore/.clawdbot/workspace/reonomy-quick-demo.js` - Demo
- `/Users/jakeshore/.clawdbot/workspace/reonomy-leads-v13.json` - Output
- `/Users/jakeshore/.clawdbot/workspace/reonomy-daily-stats.json` - Daily tracking
- `/Users/jakeshore/.clawdbot/workspace/reonomy-auth.json` - Saved session
### Search ID (with phone+email filters)
`bacfd104-fed5-4cc4-aba1-933f899de3f8` - FL Multifamily with phone filter

185
memory/INDEX.json Normal file
View File

@ -0,0 +1,185 @@
{
"doc_name": "Buba Memory System",
"version": "1.0.0",
"created": "2026-01-26",
"description": "PageIndex-style hierarchical memory structure for Clawdbot agent. Enables reasoning-based retrieval over knowledge domains.",
"structure": [
{
"title": "Projects",
"node_id": "0100",
"summary": "Active and past projects Jake is working on",
"nodes": [
{
"title": "Remix Sniper (Remi)",
"node_id": "0101",
"file": "2026-01-19-remix-sniper-setup.md",
"summary": "Discord bot scanning music charts for remix opportunities. Auto-runs daily scans, weekly stats.",
"keywords": ["discord", "bot", "music", "spotify", "charts", "remix", "remi"]
},
{
"title": "Genre Universe Visualization",
"node_id": "0102",
"file": "2026-01-26-genre-universe.md",
"summary": "Three.js 3D visualization showing artist positioning in genre space. Built for Das. 56 artists mapped.",
"keywords": ["threejs", "3d", "visualization", "das", "genre", "artists", "music"]
},
{
"title": "Burton Method (LSAT EdTech)",
"node_id": "0103",
"file": "burton-method-research-intel.md",
"summary": "LSAT tutoring platform with AI-driven customization. Logical reasoning flowcharts.",
"keywords": ["lsat", "edtech", "tutoring", "education", "burton"]
},
{
"title": "CRE Sync CRM / Real Connect V2",
"node_id": "0104",
"file": null,
"summary": "Real estate CRM with conditional onboarding flow, Supabase admin visibility, Calendly integration.",
"keywords": ["crm", "real estate", "supabase", "onboarding"]
},
{
"title": "Reonomy Scraper",
"node_id": "0105",
"file": "2026-01-15.md",
"line_start": 50,
"summary": "Browser automation to extract property owner contacts from Reonomy. Multiple versions (v9-v13).",
"keywords": ["reonomy", "scraper", "real estate", "contacts", "puppeteer", "agent-browser"]
}
]
},
{
"title": "People & Contacts",
"node_id": "0200",
"summary": "Key people, contacts, and relationships",
"nodes": [
{
"title": "Jake Shore (User)",
"node_id": "0201",
"file": "../USER.md",
"summary": "Primary user. Builder/operator. Runs edtech, real estate CRM, music management, automation projects.",
"keywords": ["jake", "user", "owner"]
},
{
"title": "Das (Artist)",
"node_id": "0202",
"file": "2026-01-26-genre-universe.md",
"summary": "Music artist Jake manages. @das-wav on SoundCloud. Los Angeles. Melodic bass with organic songwriting.",
"keywords": ["das", "artist", "music", "soundcloud", "melodic bass"]
},
{
"title": "Discord Contacts",
"node_id": "0203",
"file": "CRITICAL-REFERENCE.md",
"summary": "Discord user IDs and server references",
"keywords": ["discord", "contacts", "servers"]
}
]
},
{
"title": "Security & Rules",
"node_id": "0300",
"summary": "Security protocols, access rules, incident history",
"nodes": [
{
"title": "Core Security Rules",
"node_id": "0301",
"file": "../SOUL.md",
"summary": "ABSOLUTE SECURITY RULE #1. Only trust Jake (Discord 938238002528911400, Phone 914-500-9208). Password gating for iMessage.",
"keywords": ["security", "password", "trust", "verification"]
},
{
"title": "iMessage Security",
"node_id": "0302",
"file": "imessage-security-rules.md",
"summary": "Password JAJAJA2026 required. Mention gating (Buba). Never reveal password.",
"keywords": ["imessage", "password", "bluebubbles"]
},
{
"title": "Security Incident 2026-01-25",
"node_id": "0303",
"file": "2026-01-25.md",
"summary": "Reed breach incident. Contact memory poisoning. Password leaked. Rules updated.",
"keywords": ["breach", "incident", "reed", "security"]
}
]
},
{
"title": "Tools & Skills",
"node_id": "0400",
"summary": "External tools, CLIs, and skills learned",
"nodes": [
{
"title": "agent-browser",
"node_id": "0401",
"file": "2026-01-15.md",
"line_start": 100,
"summary": "Vercel Labs headless browser CLI. Ref-based navigation, semantic locators, state persistence.",
"keywords": ["browser", "automation", "playwright", "scraping"]
},
{
"title": "GOG (Google Workspace)",
"node_id": "0402",
"file": "2026-01-14.md",
"summary": "Google Workspace CLI. 3 accounts configured: jake@burtonmethod.com, jake@localbosses.org, jakeshore98@gmail.com",
"keywords": ["google", "gmail", "calendar", "drive", "gog"]
}
]
},
{
"title": "Daily Logs",
"node_id": "0500",
"summary": "Chronological daily memory logs",
"nodes": [
{
"title": "2026-01-26",
"node_id": "0501",
"file": "2026-01-26.md",
"summary": "Reonomy v13 scraper. Genre Universe visualization for Das.",
"keywords": ["reonomy", "genre", "das", "scraper"]
},
{
"title": "2026-01-25",
"node_id": "0502",
"file": "2026-01-25.md",
"summary": "Security breach incident. Reed contact poisoning. Password rotation.",
"keywords": ["security", "breach", "reed"]
},
{
"title": "2026-01-19",
"node_id": "0503",
"file": "2026-01-19-remix-sniper-setup.md",
"summary": "Remix Sniper bot setup. PostgreSQL, cron jobs, launchd.",
"keywords": ["remix", "sniper", "postgres", "cron"]
},
{
"title": "2026-01-15",
"node_id": "0504",
"file": "2026-01-15.md",
"summary": "agent-browser setup. Reonomy URL research. Video clip editing.",
"keywords": ["agent-browser", "reonomy", "video"]
},
{
"title": "2026-01-14",
"node_id": "0505",
"file": "2026-01-14.md",
"summary": "GOG configuration. Memory system initialized.",
"keywords": ["gog", "memory", "init"]
}
]
},
{
"title": "Research Intel",
"node_id": "0600",
"summary": "Ongoing research and competitor tracking",
"nodes": [
{
"title": "Burton Method Research",
"node_id": "0601",
"file": "burton-method-research-intel.md",
"summary": "Weekly competitor + EdTech trends digest. Current week detail at top, compressed summaries below.",
"keywords": ["burton", "lsat", "competitors", "edtech", "research"]
}
]
}
]
}

View File

@ -0,0 +1,90 @@
# Burton Method Research Intel
> **How this works:** Current week's in-depth intel lives at the top. Each week, I compress the previous week into 1-3 sentences and move it to the archive at the bottom. Reference this file when asked about competitor moves, EdTech trends, or strategic action items.
---
## 📊 Current Week Intel (Week of Jan 20-26, 2026)
### 🚨 CRITICAL: LSAC Format Change
**Reading Comp removed comparative passages** in January 2026 administration. Confirmed by Blueprint and PowerScore.
- **Impact:** Any RC curriculum teaching comparative passage strategy is now outdated
- **Opportunity:** First to fully adapt = trust signal to students
---
### Competitor Movements
**7Sage**
- Full site redesign launched (better analytics, cleaner UI)
- **NEW FREE FEATURE:** Application tracker showing interview/accept/reject/waitlist outcomes
- $10,000 giveaway promotion tied to tracker
- Heavy ABA 509 report coverage
- ADHD accommodations content series + 1L survival guides
- **Strategic read:** Pushing hard into admissions territory, not just LSAT. Creates stickiness + data network effects.
**LSAT Demon**
- **"Ugly Mode"** (Jan 19) — transforms interface to match exact official LSAT layout
- Tuition Roll Call on scholarship estimator — visualizes what students actually paid
- Veteran outreach program with dedicated liaison
- **Strategic read:** Daily podcast creates parasocial relationships. Demon is personality-driven; Burton is methodology-driven. Different lanes.
**PowerScore**
- **Dave Killoran departed** (HUGE personnel change)
- Jon Denning continuing solo, covering January LSAT chaos extensively
- Crystal Ball webinars still running
- **Strategic read:** Industry veteran leaving creates uncertainty. Watch for quality/content changes.
**Blueprint**
- First to report RC comparative passages removal
- Non-traditional student content (LSAT at 30/40/50+)
- Score plateau breakthrough guides
- 2025-26 admissions cycle predictions
- **Strategic read:** Solid content machine, "fun" brand positioning.
**Kaplan**
- $200 off all LSAT prep **extended through Jan 26** (expires TODAY)
- Applies to On Demand, Live Online, In Person, and Standard Tutoring
- Bar prep also discounted ($750 off through Feb 27)
- New 2026 edition book with "99th percentile instructor videos"
- **Strategic read:** Mass-market, price-conscious positioning continues. Heavy discounting signals competitive pressure.
**Magoosh**
- Updated for post-Logic Games LSAT
- Budget positioning continues
- LSAC remote proctoring option coverage
**LSAC (Official)**
- February 2026 scheduling opened Jan 20
- January registration closed; score release Jan 28
- Mainland China testing unavailable for Jan 2026
- Reminder to disable grammar-checking programs for Argumentative Writing
---
### EdTech Trends (Week of Jan 25)
| Story | Score | Key Insight |
|-------|-------|-------------|
| AI Can Deepen Learning | 8/10 | AI mistakes spark deeper learning; productive friction > shortcuts |
| Beyond Memorization: Redefining Rigor | 8/10 | LSAT-relevant: adaptability + critical thinking > memorization |
| Teaching Machines to Spot Human Errors | 7/10 | Eedi Labs predicting student misconceptions; human-in-the-loop AI tutoring |
| Learning As Addictive As TikTok? | 7/10 | Dopamine science for engagement; make progress feel attainable |
| What Students Want From Edtech | 6/10 | UX research: clarity > gimmicks; meaningful gamification only |
---
### 📌 Identified Action Items
1. **URGENT:** Update RC content to remove/deprioritize comparative passage strategy
2. **Content opportunity:** Blog post "What the RC Changes Mean for Your Score" — be fast, be definitive
3. **Positioning clarity:** 7Sage → admissions features, Demon → personality, Burton → systematic methodology that transcends format changes
4. **Product opportunity:** Consider "productive friction" AI features that make students think, not just answer
5. **Watch:** PowerScore post-Killoran quality — potential talent acquisition or market share opportunity
---
## 📚 Previous Weeks Archive
*(No previous weeks yet — this section will grow as weeks pass)*

193
reonomy-auth.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,322 @@
# Reonomy Contact Extractor - Video Storyboard
**Duration:** 37 seconds (1100 frames @ 30fps)
**Resolution:** 1920 × 1080
**Style:** Product demo with Canvas Viewport technique (camera moves WITHIN screenshots)
---
## Scene 1: Title Card
**Frames:** 0120 (4 seconds)
**Source:** Generated background (gradient)
### Visual Content
- Deep purple-to-indigo gradient background (`#0f172a → #1e1b4b → #0f172a`)
- Centered title: "🏢 Reonomy Contact Extractor"
- Subtitle below: "CRE Leads → Your CRM … On Demand"
### Motion
- **Title animation:** Fades in + slides up from 40px below (spring damping: 25)
- **Subtitle animation:** Same motion, delayed 25 frames (staggered entrance)
- **Background:** Subtle static gradient, no movement
### Typography
- Title: 72px, weight 800, white
- Subtitle: 28px, weight 400, slate-400 (`#94a3b8`)
### Transition Out
- Crossfade (20 frames overlap with next scene)
---
## Scene 2: Login
**Frames:** 110260 (5 seconds)
**Source:** `02-login-filled.png` (Reonomy login page with email pre-filled)
### Visual Content
- Reonomy login modal showing:
- Logo at top
- "Log In" / "Sign Up" tabs
- "Sign in with Google" button
- "Sign in with Salesforce" button
- Email field showing `henry@realestateenhanced.com`
- Step badge in bottom-left: "1" + "Login to Reonomy"
### Motion (Canvas Viewport)
| Time | X | Y | Zoom | What's Visible |
|------|---|---|------|----------------|
| Start | 0.5 | 0.2 | 2.0× | Reonomy logo and top of login form |
| End | 0.5 | 0.7 | 2.5× | Login buttons and email field (zoomed tight) |
**Camera movement:** Slow vertical pan DOWN the login form, zooming IN as it descends to draw attention to the authentication area.
### Overlays
- Radial vignette (transparent center, 50% black at edges)
- Step badge animates in with spring (delay: 10 frames after scene start)
### Audio Cue (if added)
- Soft "whoosh" on scene entrance
---
## Scene 3: Search Interface
**Frames:** 250400 (5 seconds)
**Source:** `04-search-results.png` (Reonomy home/search page)
### Visual Content
- Reonomy search interface showing:
- Navigation bar with "Saved Searches", "Data Upload"
- Hero section: "What would you like to search for today?"
- Large search input with placeholder text
- Illustrated cityscape background
- Step badge: "2" + "Search & Filter"
### Motion (Canvas Viewport)
| Time | X | Y | Zoom | What's Visible |
|------|---|---|------|----------------|
| Start | 0.0 | 0.0 | 1.8× | Top-left: nav bar and start of search area |
| End | 0.3 | 0.5 | 1.6× | Centered on search bar, slight pull-back to show context |
**Camera movement:** Diagonal drift from top-left corner toward center, slight zoom OUT to reveal the full search interface. Creates sense of "arriving" at the workspace.
### Overlays
- Same vignette
- Step badge with spring animation
---
## Scene 4: Property Selection
**Frames:** 390530 (4.7 seconds)
**Source:** `05-property-detail.png` (Property detail page with satellite map)
### Visual Content
- Property detail page showing:
- Satellite/aerial view of property (Orlando, FL area)
- Yellow property boundary outline on map
- Address: "...od Bay Dr, Orlando, FL 32821"
- Map controls (zoom, rotate, layers)
- Mini map inset on right
- Step badge: "3" + "Select Property"
### Motion (Canvas Viewport)
| Time | X | Y | Zoom | What's Visible |
|------|---|---|------|----------------|
| Start | 0.3 | 0.0 | 1.6× | Top portion: property header and map top |
| End | 0.5 | 0.4 | 1.4× | Centered on satellite view, showing property outline |
**Camera movement:** Gentle drift down and right, slight zoom OUT. Mimics "exploring" the property details. The yellow boundary should be clearly visible mid-scene.
### Overlays
- Vignette
- Step badge
---
## Scene 5: Owner Tab
**Frames:** 520660 (4.7 seconds)
**Source:** `06-owner-tab.png` (Property page with Owner tab visible)
### Visual Content
- Same property view but scrolled/panned to show:
- Satellite map (upper portion)
- Address visible
- Tab navigation at bottom: "Sales", "Debt", "Tax", "Demographics", "Notes"
- Owner tab should be highlighted/selected
- Step badge: "4" + "Owner Tab"
### Motion (Canvas Viewport)
| Time | X | Y | Zoom | What's Visible |
|------|---|---|------|----------------|
| Start | 0.5 | 0.2 | 1.5× | Map and upper property info |
| End | 0.6 | 0.5 | 1.8× | Lower portion with tabs visible, zoomed into Owner area |
**Camera movement:** Pan DOWN and slightly RIGHT while zooming IN. The motion "discovers" the Owner tab, guiding the viewer's eye to click target.
### Overlays
- Vignette
- Step badge
---
## Scene 6: Contact Extraction
**Frames:** 650830 (6 seconds)
**Source:** `09-contact-modal.png` (Contact modal with phone numbers and emails)
### Visual Content
- Contact modal/panel showing:
- "Phone Numbers" header
- Multiple phone contacts with company names:
- 919-469-9553 (Greystone Property Management)
- 727-341-0186 (Seaside Villas Gulfport)
- 903-566-9506 (Apartment Income Reit, L.P.)
- 407-671-2400 (F.P. Management, Inc.)
- Property type pie chart visible in background
- Company name "INTERNATIONAL LLC +5 more companies"
- Step badge: "5" + "Extract Contacts"
### Motion (Canvas Viewport)
| Time | X | Y | Zoom | What's Visible |
|------|---|---|------|----------------|
| Start | 0.6 | 0.1 | 1.8× | Top of contact list, first phone number |
| End | 0.6 | 0.6 | 2.2× | Scrolled down, showing multiple contacts |
**Camera movement:** Vertical scroll DOWN through the contact list while zooming IN. This is the "money shot" — the viewer sees real contact data appearing. Slow, deliberate movement to let them read the numbers.
### Overlays
- Vignette
- Step badge
- **Key moment:** Camera should pause slightly on each phone number (achieved via easing, not literal pauses)
---
## Scene 7: Results Summary
**Frames:** 8201100 (9.3 seconds)
**Source:** Generated (React components, not screenshot)
### Visual Content
#### Phase 1: Title (frames 820870)
- "🎉 Contacts Extracted" title
- Spring animation entrance
#### Phase 2: Contact Lists (frames 870980)
Two columns side by side:
**Left column — Phone Numbers (green accent)**
```
📞 Phone Numbers
┌─────────────────┐
│ 919-469-9553 │
│ 727-341-0186 │
│ 903-566-9506 │
│ 407-671-2400 │
│ 407-382-2683 │
└─────────────────┘
```
**Right column — Email Addresses (purple accent)**
```
📧 Email Addresses
┌──────────────────────────────┐
│ berrizoro@gmail.com │
│ aberriz@hotmail.com │
│ jasonhitch1@gmail.com │
│ albert@annarborusa.org │
│ albertb@sterlinghousing.com │
└──────────────────────────────┘
```
#### Phase 3: Stats (frames 9801100)
Four stat counters across bottom:
| Stat | Value | Color |
|------|-------|-------|
| Contacts | 10 | Purple (`#6366f1`) |
| Phones | 5 | Green (`#10b981`) |
| Emails | 5 | Amber (`#f59e0b`) |
| Time | 60s | Pink (`#ec4899`) |
### Motion
- **Title:** Fade in + slide up (spring, damping 25)
- **Column headers:** Staggered fade in (phones at frame 870, emails at 875)
- **Contact items:** Cascade animation — each item slides in from the side with 8-frame stagger
- Phones: slide in from LEFT (`translateX(-30px → 0)`)
- Emails: slide in from RIGHT (`translateX(30px → 0)`)
- **Stats:** Scale up from 0.5× to 1× with spring, 10-frame stagger between each
### Typography
- Title: 52px, weight 800, white
- Column headers: 22px, weight 700, accent color
- Contact items: 18px, weight 600, slate-800 on white cards
- Stat values: 48px, weight 800, accent color
- Stat labels: 16px, weight 400, slate-400
### Background
- Same gradient as title card (`#0f172a → #1e1b4b → #0f172a`)
### Transition Out
- Global fade to black over final 30 frames
---
## Global Elements
### Vignette Overlay
Applied to all screenshot scenes (26):
```css
background: radial-gradient(
ellipse at center,
transparent 30%,
rgba(0, 0, 0, 0.5) 100%
);
```
Creates focus on center content and masks rough edges of zoomed screenshots.
### Step Badge Component
Persistent UI element in bottom-left during screenshot scenes:
- Purple circle (48px) with step number
- Black pill with step description
- Spring entrance animation (damping: 20)
- Positioned: `bottom: 50px, left: 50px`
### Easing
All camera movements use: `Easing.inOut(Easing.cubic)`
All UI animations use: `spring({ damping: 18-25 })`
### Color Palette
| Use | Color | Hex |
|-----|-------|-----|
| Background dark | Slate 900 | `#0f172a` |
| Background accent | Indigo 950 | `#1e1b4b` |
| Primary | Indigo 500 | `#6366f1` |
| Secondary | Violet 500 | `#8b5cf6` |
| Success | Emerald 500 | `#10b981` |
| Warning | Amber 500 | `#f59e0b` |
| Accent | Pink 500 | `#ec4899` |
| Text primary | White | `#ffffff` |
| Text secondary | Slate 400 | `#94a3b8` |
| Text on cards | Slate 800 | `#1e293b` |
---
## Asset Checklist
### Screenshots Needed
- [x] `02-login-filled.png` — Login page with email filled
- [x] `04-search-results.png` — Search/home interface
- [x] `05-property-detail.png` — Property with satellite map
- [x] `06-owner-tab.png` — Property showing owner tab
- [x] `09-contact-modal.png` — Contact list modal
### Generated Assets
- [ ] Intro background gradient (or use CSS gradient)
- [ ] Outro background gradient (same as intro)
### Audio (Optional Future)
- [ ] Background music (upbeat, corporate-friendly)
- [ ] Whoosh sounds on scene transitions
- [ ] "Pop" sounds on stat reveals
---
## Implementation Notes
### Canvas Viewport Technique
Each screenshot scene uses the `CanvasViewport` component which:
1. Renders the image at 2.5× the viewport size
2. Positions a clipping viewport over a portion of the image
3. Animates X, Y, and Zoom values to create camera movement
4. Creates the illusion of moving THROUGH the UI rather than just showing it
### Key Insight
The zoom values (1.4× to 2.5×) mean we're always cropped IN — never showing the whole screenshot. This:
- Hides any rough edges or UI inconsistencies
- Forces focus on the relevant area
- Creates cinematic "documentary" feel
- Allows lower-res source images to still look sharp
### Timing Philosophy
- **Scene 1 (Title):** 4 seconds — enough to read, not too long
- **Scenes 2-5 (UI flow):** ~5 seconds each — enough for camera to move meaningfully
- **Scene 6 (Contacts):** 6 seconds — the payoff, give it room
- **Scene 7 (Results):** 9 seconds — celebration, let animations complete, end strong

View File

@ -0,0 +1,20 @@
{
"name": "reonomy-demo-video",
"version": "1.0.0",
"description": "Reonomy Contact Extraction Demo Video",
"scripts": {
"start": "remotion studio",
"build": "remotion render ReonomyDemo out/reonomy-demo.mp4",
"render": "remotion render ReonomyDemo out/reonomy-demo.mp4"
},
"dependencies": {
"@remotion/cli": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remotion": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,4 @@
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);

View File

@ -0,0 +1,576 @@
import React from "react";
import {
AbsoluteFill,
Img,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Sequence,
staticFile,
Easing,
} from "remotion";
// ============================================
// CANVAS VIEWPORT - Zoom and pan WITHIN an image
// ============================================
const CanvasViewport: React.FC<{
src: string;
startX: number; // Starting viewport position (0-1)
startY: number;
startZoom: number; // Starting zoom level
endX: number; // Ending viewport position
endY: number;
endZoom: number;
progress: number; // Animation progress (0-1)
}> = ({ src, startX, startY, startZoom, endX, endY, endZoom, progress }) => {
const { width, height } = useVideoConfig();
// Interpolate current position
const x = interpolate(progress, [0, 1], [startX, endX]);
const y = interpolate(progress, [0, 1], [startY, endY]);
const zoom = interpolate(progress, [0, 1], [startZoom, endZoom]);
// Canvas size (image is rendered larger than viewport)
const canvasWidth = width * zoom;
const canvasHeight = height * zoom;
// Calculate offset to pan to the target position
const offsetX = (canvasWidth - width) * x;
const offsetY = (canvasHeight - height) * y;
return (
<div
style={{
width,
height,
overflow: "hidden",
position: "relative",
}}
>
<Img
src={src}
style={{
position: "absolute",
width: canvasWidth,
height: canvasHeight,
left: -offsetX,
top: -offsetY,
objectFit: "cover",
}}
/>
</div>
);
};
// ============================================
// STEP BADGE
// ============================================
const StepBadge: React.FC<{
step: number;
text: string;
}> = ({ step, text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame: frame - 10, fps, config: { damping: 20 } });
return (
<div
style={{
position: "absolute",
bottom: 50,
left: 50,
display: "flex",
alignItems: "center",
gap: 14,
opacity: progress,
transform: `translateX(${interpolate(progress, [0, 1], [-40, 0])}px)`,
zIndex: 100,
}}
>
<div
style={{
width: 52,
height: 52,
borderRadius: "50%",
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 26,
fontWeight: 700,
color: "white",
boxShadow: "0 8px 30px rgba(99, 102, 241, 0.5)",
}}
>
{step}
</div>
<div
style={{
background: "rgba(0, 0, 0, 0.85)",
backdropFilter: "blur(10px)",
padding: "14px 28px",
borderRadius: 10,
fontSize: 20,
fontWeight: 600,
color: "white",
}}
>
{text}
</div>
</div>
);
};
// ============================================
// TITLE CARD
// ============================================
const TitleCard: React.FC<{
title: string;
subtitle?: string;
}> = ({ title, subtitle }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame: frame - 15, fps, config: { damping: 25 } });
const subtitleProgress = spring({ frame: frame - 40, fps, config: { damping: 25 } });
return (
<AbsoluteFill
style={{
background: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
fontSize: 72,
fontWeight: 800,
color: "white",
textAlign: "center",
opacity: progress,
transform: `translateY(${interpolate(progress, [0, 1], [40, 0])}px)`,
}}
>
{title}
</div>
{subtitle && (
<div
style={{
fontSize: 28,
color: "#94a3b8",
marginTop: 20,
opacity: subtitleProgress,
transform: `translateY(${interpolate(subtitleProgress, [0, 1], [20, 0])}px)`,
}}
>
{subtitle}
</div>
)}
</AbsoluteFill>
);
};
// ============================================
// SCENE WRAPPER with crossfade
// ============================================
const Scene: React.FC<{
children: React.ReactNode;
fadeIn?: number;
fadeOut?: number;
}> = ({ children, fadeIn = 20, fadeOut = 20 }) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const opacity = Math.min(
interpolate(frame, [0, fadeIn], [0, 1], { extrapolateRight: "clamp" }),
interpolate(frame, [durationInFrames - fadeOut, durationInFrames], [1, 0], { extrapolateLeft: "clamp" })
);
return <AbsoluteFill style={{ opacity }}>{children}</AbsoluteFill>;
};
// ============================================
// INDIVIDUAL SCENES with canvas viewport movement
// ============================================
const SceneLogin: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
// Eased progress through the scene
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
easing: Easing.inOut(Easing.cubic),
});
return (
<Scene>
<AbsoluteFill style={{ background: "#0f172a" }}>
<CanvasViewport
src={staticFile("assets/02-login-filled.png")}
startX={0.5}
startY={0.2}
startZoom={2.0} // Start zoomed into the logo/header
endX={0.5}
endY={0.7}
endZoom={2.5} // End zoomed into login button
progress={progress}
/>
{/* Vignette */}
<div
style={{
position: "absolute",
inset: 0,
background: "radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)",
pointerEvents: "none",
}}
/>
<StepBadge step={1} text="Login to Reonomy" />
</AbsoluteFill>
</Scene>
);
};
const SceneSearch: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
easing: Easing.inOut(Easing.cubic),
});
return (
<Scene>
<AbsoluteFill style={{ background: "#0f172a" }}>
<CanvasViewport
src={staticFile("assets/04-search-results.png")}
startX={0.0}
startY={0.0}
startZoom={1.8} // Start on search bar area
endX={0.3}
endY={0.5}
endZoom={1.6} // Pan down to results
progress={progress}
/>
<div
style={{
position: "absolute",
inset: 0,
background: "radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)",
pointerEvents: "none",
}}
/>
<StepBadge step={2} text="Search & Filter" />
</AbsoluteFill>
</Scene>
);
};
const SceneProperty: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
easing: Easing.inOut(Easing.cubic),
});
return (
<Scene>
<AbsoluteFill style={{ background: "#0f172a" }}>
<CanvasViewport
src={staticFile("assets/05-property-detail.png")}
startX={0.3}
startY={0.0}
startZoom={1.6} // Start on property header/image
endX={0.5}
endY={0.4}
endZoom={1.4} // Pan to property details
progress={progress}
/>
<div
style={{
position: "absolute",
inset: 0,
background: "radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)",
pointerEvents: "none",
}}
/>
<StepBadge step={3} text="Select Property" />
</AbsoluteFill>
</Scene>
);
};
const SceneOwner: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
easing: Easing.inOut(Easing.cubic),
});
return (
<Scene>
<AbsoluteFill style={{ background: "#0f172a" }}>
<CanvasViewport
src={staticFile("assets/06-owner-tab.png")}
startX={0.5}
startY={0.2}
startZoom={1.5} // Start on tabs
endX={0.6}
endY={0.5}
endZoom={1.8} // Zoom into owner info
progress={progress}
/>
<div
style={{
position: "absolute",
inset: 0,
background: "radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)",
pointerEvents: "none",
}}
/>
<StepBadge step={4} text="Owner Tab" />
</AbsoluteFill>
</Scene>
);
};
const SceneContacts: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
easing: Easing.inOut(Easing.cubic),
});
return (
<Scene>
<AbsoluteFill style={{ background: "#0f172a" }}>
<CanvasViewport
src={staticFile("assets/09-contact-modal.png")}
startX={0.6}
startY={0.1}
startZoom={1.8} // Start on contact name
endX={0.6}
endY={0.6}
endZoom={2.2} // Pan down through all contacts
progress={progress}
/>
<div
style={{
position: "absolute",
inset: 0,
background: "radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%)",
pointerEvents: "none",
}}
/>
<StepBadge step={5} text="Extract Contacts" />
</AbsoluteFill>
</Scene>
);
};
const SceneResults: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const phones = [
"919-469-9553",
"727-341-0186",
"903-566-9506",
"407-671-2400",
"407-382-2683",
];
const emails = [
"berrizoro@gmail.com",
"aberriz@hotmail.com",
"jasonhitch1@gmail.com",
"albert@annarborusa.org",
"albertb@sterlinghousing.com",
];
return (
<Scene>
<AbsoluteFill
style={{
background: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)",
padding: 80,
}}
>
{/* Title */}
<div style={{ textAlign: "center", marginBottom: 50 }}>
<div
style={{
fontSize: 52,
fontWeight: 800,
color: "white",
opacity: spring({ frame: frame - 10, fps, config: { damping: 25 } }),
}}
>
🎉 Contacts Extracted
</div>
</div>
{/* Two columns */}
<div style={{ display: "flex", gap: 60, justifyContent: "center" }}>
{/* Phones */}
<div>
<div
style={{
fontSize: 22,
color: "#10b981",
marginBottom: 20,
fontWeight: 700,
opacity: spring({ frame: frame - 30, fps }),
}}
>
📞 Phone Numbers
</div>
{phones.map((phone, i) => {
const p = spring({ frame: frame - 40 - i * 8, fps, config: { damping: 18 } });
return (
<div
key={phone}
style={{
background: "rgba(255,255,255,0.95)",
padding: "14px 24px",
borderRadius: 10,
marginBottom: 10,
fontSize: 18,
fontWeight: 600,
color: "#1e293b",
opacity: p,
transform: `translateX(${interpolate(p, [0, 1], [-30, 0])}px)`,
}}
>
{phone}
</div>
);
})}
</div>
{/* Emails */}
<div>
<div
style={{
fontSize: 22,
color: "#6366f1",
marginBottom: 20,
fontWeight: 700,
opacity: spring({ frame: frame - 35, fps }),
}}
>
📧 Email Addresses
</div>
{emails.map((email, i) => {
const p = spring({ frame: frame - 45 - i * 8, fps, config: { damping: 18 } });
return (
<div
key={email}
style={{
background: "rgba(255,255,255,0.95)",
padding: "14px 24px",
borderRadius: 10,
marginBottom: 10,
fontSize: 18,
fontWeight: 600,
color: "#1e293b",
opacity: p,
transform: `translateX(${interpolate(p, [0, 1], [30, 0])}px)`,
}}
>
{email}
</div>
);
})}
</div>
</div>
{/* Stats */}
<div
style={{
display: "flex",
justifyContent: "center",
gap: 60,
marginTop: 50,
}}
>
{[
{ value: "10", label: "Contacts", color: "#6366f1" },
{ value: "5", label: "Phones", color: "#10b981" },
{ value: "5", label: "Emails", color: "#f59e0b" },
{ value: "60s", label: "Time", color: "#ec4899" },
].map((stat, i) => {
const p = spring({ frame: frame - 100 - i * 10, fps, config: { damping: 20 } });
return (
<div
key={stat.label}
style={{
textAlign: "center",
opacity: p,
transform: `scale(${interpolate(p, [0, 1], [0.5, 1])})`,
}}
>
<div style={{ fontSize: 48, fontWeight: 800, color: stat.color }}>{stat.value}</div>
<div style={{ fontSize: 16, color: "#94a3b8" }}>{stat.label}</div>
</div>
);
})}
</div>
</AbsoluteFill>
</Scene>
);
};
// ============================================
// MAIN COMPOSITION
// ============================================
export const ReonomyCanvasDemo: React.FC = () => {
return (
<AbsoluteFill style={{ backgroundColor: "#0f172a" }}>
{/* Intro */}
<Sequence from={0} durationInFrames={120}>
<TitleCard
title="🏢 Reonomy Contact Extractor"
subtitle="CRE Leads → Your CRM … On Demand"
/>
</Sequence>
{/* Login - camera moves down to login button */}
<Sequence from={110} durationInFrames={150}>
<SceneLogin />
</Sequence>
{/* Search - camera pans across search results */}
<Sequence from={250} durationInFrames={150}>
<SceneSearch />
</Sequence>
{/* Property - camera explores property details */}
<Sequence from={390} durationInFrames={140}>
<SceneProperty />
</Sequence>
{/* Owner - camera moves to owner section */}
<Sequence from={520} durationInFrames={140}>
<SceneOwner />
</Sequence>
{/* Contacts - camera pans down contact list */}
<Sequence from={650} durationInFrames={180}>
<SceneContacts />
</Sequence>
{/* Results */}
<Sequence from={820} durationInFrames={280}>
<SceneResults />
</Sequence>
</AbsoluteFill>
);
};

View File

@ -0,0 +1,635 @@
import React from "react";
import {
AbsoluteFill,
Img,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Series,
staticFile,
} from "remotion";
// ============================================
// SPRING CONFIGS (from best practices)
// ============================================
const SPRING_SMOOTH = { damping: 200 };
const SPRING_SNAPPY = { damping: 20, stiffness: 200 };
const SPRING_BOUNCY = { damping: 12 };
// ============================================
// CAMERA - Simple wrapper, ONE motion at a time
// ============================================
const Camera: React.FC<{
children: React.ReactNode;
zoom?: number;
x?: number;
y?: number;
}> = ({ children, zoom = 1, x = 0, y = 0 }) => (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
transform: `scale(${zoom}) translate(${x}px, ${y}px)`,
transformOrigin: "center center",
}}
>
{children}
</div>
);
// ============================================
// KINETIC TEXT - Word by word reveal
// ============================================
const KineticText: React.FC<{
text: string;
delay?: number;
stagger?: number;
style?: React.CSSProperties;
}> = ({ text, delay = 0, stagger = 4, style = {} }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = text.split(" ");
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.3em", ...style }}>
{words.map((word, i) => {
const wordDelay = delay + i * stagger;
const progress = spring({
frame: frame - wordDelay,
fps,
config: SPRING_SNAPPY,
});
return (
<span
key={i}
style={{
display: "inline-block",
transform: `translateY(${interpolate(progress, [0, 1], [40, 0])}px)`,
opacity: progress,
}}
>
{word}
</span>
);
})}
</div>
);
};
// ============================================
// BROWSER MOCKUP
// ============================================
const BrowserMockup: React.FC<{
src: string;
width?: number;
}> = ({ src, width = 1000 }) => {
const height = width * 0.5625;
return (
<div
style={{
backgroundColor: "#1e293b",
borderRadius: 12,
overflow: "hidden",
boxShadow: "0 25px 60px rgba(0,0,0,0.4)",
}}
>
<div
style={{
height: 36,
backgroundColor: "#0f172a",
display: "flex",
alignItems: "center",
padding: "0 14px",
gap: 7,
}}
>
<div style={{ width: 11, height: 11, borderRadius: "50%", backgroundColor: "#ef4444" }} />
<div style={{ width: 11, height: 11, borderRadius: "50%", backgroundColor: "#f59e0b" }} />
<div style={{ width: 11, height: 11, borderRadius: "50%", backgroundColor: "#22c55e" }} />
<div
style={{
flex: 1,
marginLeft: 14,
height: 22,
backgroundColor: "#1e293b",
borderRadius: 5,
paddingLeft: 10,
fontSize: 11,
color: "#64748b",
display: "flex",
alignItems: "center",
}}
>
app.reonomy.com
</div>
</div>
<Img src={src} style={{ width, height, objectFit: "cover" }} />
</div>
);
};
// ============================================
// STEP LABEL
// ============================================
const StepLabel: React.FC<{
step: number;
text: string;
color?: string;
}> = ({ step, text, color = "#6366f1" }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame: frame - 10, fps, config: SPRING_SNAPPY });
return (
<div
style={{
position: "absolute",
bottom: 50,
left: 50,
transform: `translateY(${interpolate(progress, [0, 1], [30, 0])}px)`,
opacity: progress,
}}
>
<div
style={{
background: `linear-gradient(135deg, ${color}, ${color}dd)`,
padding: "14px 28px",
borderRadius: 10,
boxShadow: `0 10px 30px ${color}40`,
}}
>
<span style={{ fontSize: 20, fontWeight: 700, color: "white" }}>
Step {step}: {text}
</span>
</div>
</div>
);
};
// ============================================
// CONTACT CARD
// ============================================
const ContactCard: React.FC<{
type: "phone" | "email";
value: string;
source?: string;
delay: number;
}> = ({ type, value, source, delay }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame: frame - delay, fps, config: SPRING_SNAPPY });
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "12px 16px",
backgroundColor: "rgba(255,255,255,0.95)",
borderRadius: 10,
marginBottom: 8,
boxShadow: "0 4px 20px rgba(0,0,0,0.1)",
transform: `translateY(${interpolate(progress, [0, 1], [20, 0])}px)`,
opacity: progress,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 8,
background:
type === "phone"
? "linear-gradient(135deg, #10b981, #059669)"
: "linear-gradient(135deg, #6366f1, #4f46e5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 18,
}}
>
{type === "phone" ? "📞" : "📧"}
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: "#1e293b" }}>{value}</div>
{source && <div style={{ fontSize: 11, color: "#64748b" }}>{source}</div>}
</div>
</div>
);
};
// ============================================
// ANIMATED STAT
// ============================================
const AnimatedStat: React.FC<{
value: number | string;
label: string;
color: string;
delay: number;
}> = ({ value, label, color, delay }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame: frame - delay, fps, config: SPRING_SMOOTH });
const numericValue =
typeof value === "number" ? Math.round(interpolate(progress, [0, 1], [0, value])) : value;
return (
<div
style={{
textAlign: "center",
transform: `scale(${interpolate(progress, [0, 1], [0.8, 1])})`,
opacity: progress,
}}
>
<div style={{ fontSize: 48, fontWeight: 800, color }}>{numericValue}</div>
<div style={{ fontSize: 14, color: "#94a3b8", marginTop: 4 }}>{label}</div>
</div>
);
};
// ============================================
// SCENES
// ============================================
const SceneIntro: React.FC = () => {
const frame = useCurrentFrame();
// Slow, elegant zoom settle: 1.12 -> 1.0 over longer duration
const zoom = interpolate(frame, [0, 120], [1.12, 1.0], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<Camera zoom={zoom}>
<div style={{ textAlign: "center" }}>
<KineticText
text="🏢 Reonomy Contact Extractor"
delay={20}
stagger={6}
style={{ fontSize: 64, fontWeight: 800, color: "white", justifyContent: "center" }}
/>
<div style={{ marginTop: 20 }}>
<KineticText
text="CRE Leads → Your CRM … On Demand"
delay={70}
stagger={5}
style={{ fontSize: 28, fontWeight: 500, color: "#94a3b8", justifyContent: "center" }}
/>
</div>
</div>
</Camera>
</AbsoluteFill>
);
};
const SceneLogin: React.FC = () => {
const frame = useCurrentFrame();
// Slow push in toward login area: 0.92 -> 1.08 over 140 frames
const zoom = interpolate(frame, [0, 140], [0.92, 1.08], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<Camera zoom={zoom}>
<BrowserMockup src={staticFile("assets/02-login-filled.png")} width={1000} />
</Camera>
<StepLabel step={1} text="Login" />
</AbsoluteFill>
);
};
const SceneSearch: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Slower settle: start wide, ease in over 100 frames
const zoom = interpolate(frame, [0, 100], [0.88, 1.0], {
extrapolateRight: "clamp",
});
const filterProgress1 = spring({ frame: frame - 50, fps, config: SPRING_SNAPPY });
const filterProgress2 = spring({ frame: frame - 70, fps, config: SPRING_SNAPPY });
return (
<AbsoluteFill>
<Camera zoom={zoom}>
<div style={{ position: "relative" }}>
<BrowserMockup src={staticFile("assets/04-search-results.png")} width={1050} />
{/* Filter badges - staggered */}
<div
style={{
position: "absolute",
left: -160,
top: 140,
}}
>
<div
style={{
background: "rgba(16, 185, 129, 0.9)",
padding: "10px 18px",
borderRadius: 20,
marginBottom: 10,
boxShadow: "0 4px 20px rgba(16, 185, 129, 0.3)",
opacity: filterProgress1,
transform: `translateX(${interpolate(filterProgress1, [0, 1], [-30, 0])}px)`,
}}
>
<span style={{ color: "white", fontWeight: 600, fontSize: 14 }}> Has Phone</span>
</div>
<div
style={{
background: "rgba(99, 102, 241, 0.9)",
padding: "10px 18px",
borderRadius: 20,
boxShadow: "0 4px 20px rgba(99, 102, 241, 0.3)",
opacity: filterProgress2,
transform: `translateX(${interpolate(filterProgress2, [0, 1], [-30, 0])}px)`,
}}
>
<span style={{ color: "white", fontWeight: 600, fontSize: 14 }}> Has Email</span>
</div>
</div>
</div>
</Camera>
<StepLabel step={2} text="Filtered Search" />
</AbsoluteFill>
);
};
const SceneProperty: React.FC = () => {
const frame = useCurrentFrame();
// Slow, cinematic pan down over 120 frames
const y = interpolate(frame, [0, 120], [-25, 15], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<Camera y={y}>
<BrowserMockup src={staticFile("assets/05-property-detail.png")} width={1000} />
</Camera>
<StepLabel step={3} text="Property Details" />
</AbsoluteFill>
);
};
const SceneOwner: React.FC = () => {
const frame = useCurrentFrame();
// Slow zoom in to owner section over 120 frames
const zoom = interpolate(frame, [0, 120], [1.0, 1.12], {
extrapolateRight: "clamp",
});
return (
<AbsoluteFill>
<Camera zoom={zoom}>
<BrowserMockup src={staticFile("assets/06-owner-tab.png")} width={1000} />
</Camera>
<StepLabel step={4} text="Owner Tab" color="#8b5cf6" />
</AbsoluteFill>
);
};
const SceneModal: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Slower pop in then settle over longer duration
const zoom = interpolate(frame, [0, 50, 100], [0.85, 1.04, 1.0], {
extrapolateRight: "clamp",
});
const labelProgress = spring({ frame: frame - 70, fps, config: SPRING_BOUNCY });
return (
<AbsoluteFill>
<Camera zoom={zoom}>
<BrowserMockup src={staticFile("assets/09-contact-modal.png")} width={1050} />
</Camera>
{/* Success label */}
<div
style={{
position: "absolute",
bottom: 50,
left: "50%",
transform: `translateX(-50%) translateY(${interpolate(labelProgress, [0, 1], [40, 0])}px)`,
opacity: labelProgress,
}}
>
<div
style={{
background: "linear-gradient(135deg, #10b981, #059669)",
padding: "16px 40px",
borderRadius: 12,
boxShadow: "0 15px 40px rgba(16, 185, 129, 0.4)",
}}
>
<span style={{ fontSize: 24, fontWeight: 700, color: "white" }}>
🎉 Contacts Extracted!
</span>
</div>
</div>
</AbsoluteFill>
);
};
const SceneResults: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Slow, elegant zoom out over 150 frames
const zoom = interpolate(frame, [0, 150], [1.06, 1.0], {
extrapolateRight: "clamp",
});
const phones = [
{ value: "919-469-9553", source: "Greystone Property Mgmt" },
{ value: "727-341-0186", source: "Seaside Villas" },
{ value: "903-566-9506", source: "Apartment Income Reit" },
{ value: "407-671-2400", source: "E R Management" },
{ value: "407-382-2683", source: "Bellagio Apartments" },
];
const emails = [
{ value: "berrizoro@gmail.com" },
{ value: "aberriz@hotmail.com" },
{ value: "jasonhitch1@gmail.com" },
{ value: "albert@annarborusa.org" },
{ value: "albertb@sterlinghousing.com" },
];
const headerProgress = spring({ frame: frame - 10, fps, config: SPRING_SMOOTH });
const phoneHeaderProgress = spring({ frame: frame - 60, fps, config: SPRING_SMOOTH });
const emailHeaderProgress = spring({ frame: frame - 70, fps, config: SPRING_SMOOTH });
return (
<AbsoluteFill>
<Camera zoom={zoom}>
<div style={{ width: 1600, padding: 40 }}>
{/* Title */}
<div style={{ textAlign: "center", marginBottom: 30 }}>
<div
style={{
opacity: headerProgress,
transform: `translateY(${interpolate(headerProgress, [0, 1], [20, 0])}px)`,
}}
>
<span style={{ fontSize: 42, fontWeight: 800, color: "white" }}>
CRE Leads Delivered Direct to Your CRM
</span>
</div>
<div
style={{
marginTop: 10,
opacity: spring({ frame: frame - 25, fps, config: SPRING_SMOOTH }),
}}
>
<span
style={{
fontSize: 24,
fontWeight: 600,
background: "linear-gradient(90deg, #6366f1, #a855f7, #ec4899)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
On Demand 🚀
</span>
</div>
</div>
{/* Contact columns */}
<div style={{ display: "flex", gap: 40, paddingLeft: 80, paddingRight: 80 }}>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: 18,
color: "#10b981",
marginBottom: 12,
fontWeight: 700,
opacity: phoneHeaderProgress,
}}
>
📞 Phone Numbers
</div>
{phones.map((p, i) => (
<ContactCard
key={p.value}
type="phone"
value={p.value}
source={p.source}
delay={80 + i * 15}
/>
))}
</div>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: 18,
color: "#6366f1",
marginBottom: 12,
fontWeight: 700,
opacity: emailHeaderProgress,
}}
>
📧 Email Addresses
</div>
{emails.map((e, i) => (
<ContactCard key={e.value} type="email" value={e.value} delay={90 + i * 15} />
))}
</div>
</div>
{/* Stats footer */}
<div
style={{
marginTop: 30,
display: "flex",
justifyContent: "space-around",
background: "rgba(255,255,255,0.05)",
backdropFilter: "blur(10px)",
padding: "20px 50px",
borderRadius: 14,
marginLeft: 80,
marginRight: 80,
}}
>
<AnimatedStat value={10} label="Total Contacts" color="#6366f1" delay={200} />
<AnimatedStat value={5} label="Phone Numbers" color="#10b981" delay={220} />
<AnimatedStat value={5} label="Emails" color="#f59e0b" delay={240} />
<AnimatedStat value="~60s" label="Extraction Time" color="#ef4444" delay={260} />
</div>
</div>
</Camera>
</AbsoluteFill>
);
};
// ============================================
// MAIN COMPOSITION
// ============================================
export const ReonomyDemo: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
// Global fade in/out (20 frames as recommended)
const fadeIn = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: "clamp" });
const fadeOut = interpolate(frame, [durationInFrames - 20, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
});
return (
<AbsoluteFill
style={{
background: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)",
opacity: Math.min(fadeIn, fadeOut),
}}
>
<Series>
<Series.Sequence durationInFrames={210}>
<SceneIntro />
</Series.Sequence>
<Series.Sequence durationInFrames={180}>
<SceneLogin />
</Series.Sequence>
<Series.Sequence durationInFrames={210}>
<SceneSearch />
</Series.Sequence>
<Series.Sequence durationInFrames={180}>
<SceneProperty />
</Series.Sequence>
<Series.Sequence durationInFrames={180}>
<SceneOwner />
</Series.Sequence>
<Series.Sequence durationInFrames={210}>
<SceneModal />
</Series.Sequence>
<Series.Sequence durationInFrames={420}>
<SceneResults />
</Series.Sequence>
</Series>
</AbsoluteFill>
);
};

View File

@ -0,0 +1,819 @@
import React from "react";
import {
AbsoluteFill,
Img,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
Series,
staticFile,
Easing,
} from "remotion";
// ============================================
// SPRING CONFIGS
// ============================================
const SPRING_SMOOTH = { damping: 200 };
const SPRING_SNAPPY = { damping: 15, stiffness: 200 };
const SPRING_BOUNCY = { damping: 8, stiffness: 150 };
const SPRING_PUNCH = { damping: 12, stiffness: 300 };
// ============================================
// CAMERA with more dramatic capabilities
// ============================================
const Camera: React.FC<{
children: React.ReactNode;
zoom?: number;
x?: number;
y?: number;
rotate?: number;
}> = ({ children, zoom = 1, x = 0, y = 0, rotate = 0 }) => (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
transform: `scale(${zoom}) translate(${x}px, ${y}px) rotate(${rotate}deg)`,
transformOrigin: "center center",
}}
>
{children}
</div>
);
// ============================================
// GLOW PULSE - adds energy to key moments
// ============================================
const GlowPulse: React.FC<{
color: string;
size?: number;
delay?: number;
intensity?: number;
}> = ({ color, size = 400, delay = 0, intensity = 0.5 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame: frame - delay, fps, config: SPRING_PUNCH });
const pulse = Math.sin((frame - delay) / 8) * 0.2 + 0.8;
return (
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: size * progress,
height: size * progress,
background: `radial-gradient(ellipse, ${color}${Math.round(intensity * pulse * 255).toString(16).padStart(2, '0')} 0%, transparent 70%)`,
pointerEvents: "none",
}}
/>
);
};
// ============================================
// IMPACT FLASH - visual punch on transitions
// ============================================
const ImpactFlash: React.FC<{ delay: number; color?: string }> = ({ delay, color = "#ffffff" }) => {
const frame = useCurrentFrame();
const localFrame = frame - delay;
if (localFrame < 0 || localFrame > 15) return null;
const opacity = interpolate(localFrame, [0, 3, 15], [0, 0.4, 0], { extrapolateRight: "clamp" });
return (
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: color,
opacity,
pointerEvents: "none",
}}
/>
);
};
// ============================================
// PARTICLE BURST - celebratory effect
// ============================================
const ParticleBurst: React.FC<{ delay: number; count?: number }> = ({ delay, count = 20 }) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const localFrame = frame - delay;
if (localFrame < 0 || localFrame > 60) return null;
const progress = interpolate(localFrame, [0, 60], [0, 1], { extrapolateRight: "clamp" });
return (
<>
{[...Array(count)].map((_, i) => {
const angle = (i / count) * Math.PI * 2;
const distance = 200 + (i % 3) * 100;
const x = Math.cos(angle) * distance * progress;
const y = Math.sin(angle) * distance * progress - (progress * progress * 200);
const opacity = 1 - progress;
const colors = ["#6366f1", "#10b981", "#f59e0b", "#ec4899", "#8b5cf6"];
return (
<div
key={i}
style={{
position: "absolute",
left: width / 2 + x,
top: height / 2 + y,
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: colors[i % colors.length],
opacity,
boxShadow: `0 0 10px ${colors[i % colors.length]}`,
}}
/>
);
})}
</>
);
};
// ============================================
// KINETIC TEXT - more punchy
// ============================================
const KineticText: React.FC<{
text: string;
delay?: number;
stagger?: number;
style?: React.CSSProperties;
punch?: boolean;
}> = ({ text, delay = 0, stagger = 3, style = {}, punch = false }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const words = text.split(" ");
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.3em", ...style }}>
{words.map((word, i) => {
const wordDelay = delay + i * stagger;
const config = punch ? SPRING_PUNCH : SPRING_SNAPPY;
const progress = spring({ frame: frame - wordDelay, fps, config });
const scale = punch
? interpolate(progress, [0, 0.5, 1], [0.3, 1.15, 1])
: interpolate(progress, [0, 1], [0.8, 1]);
const y = interpolate(progress, [0, 1], [50, 0]);
return (
<span
key={i}
style={{
display: "inline-block",
transform: `translateY(${y}px) scale(${scale})`,
opacity: progress,
}}
>
{word}
</span>
);
})}
</div>
);
};
// ============================================
// BROWSER MOCKUP with glow option
// ============================================
const BrowserMockup: React.FC<{
src: string;
width?: number;
glow?: boolean;
glowColor?: string;
}> = ({ src, width = 1000, glow = false, glowColor = "#6366f1" }) => {
const frame = useCurrentFrame();
const height = width * 0.5625;
const glowPulse = glow ? 0.3 + Math.sin(frame / 10) * 0.15 : 0;
return (
<div
style={{
backgroundColor: "#1e293b",
borderRadius: 12,
overflow: "hidden",
boxShadow: glow
? `0 25px 60px rgba(0,0,0,0.4), 0 0 80px ${glowColor}${Math.round(glowPulse * 255).toString(16).padStart(2, '0')}`
: "0 25px 60px rgba(0,0,0,0.4)",
}}
>
<div
style={{
height: 36,
backgroundColor: "#0f172a",
display: "flex",
alignItems: "center",
padding: "0 14px",
gap: 7,
}}
>
<div style={{ width: 11, height: 11, borderRadius: "50%", backgroundColor: "#ef4444" }} />
<div style={{ width: 11, height: 11, borderRadius: "50%", backgroundColor: "#f59e0b" }} />
<div style={{ width: 11, height: 11, borderRadius: "50%", backgroundColor: "#22c55e" }} />
<div
style={{
flex: 1,
marginLeft: 14,
height: 22,
backgroundColor: "#1e293b",
borderRadius: 5,
paddingLeft: 10,
fontSize: 11,
color: "#64748b",
display: "flex",
alignItems: "center",
}}
>
app.reonomy.com
</div>
</div>
<Img src={src} style={{ width, height, objectFit: "cover" }} />
</div>
);
};
// ============================================
// STEP LABEL - more dynamic
// ============================================
const StepLabel: React.FC<{
step: number;
text: string;
color?: string;
}> = ({ step, text, color = "#6366f1" }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame: frame - 8, fps, config: SPRING_PUNCH });
const scale = interpolate(progress, [0, 0.6, 1], [0.5, 1.1, 1]);
return (
<div
style={{
position: "absolute",
bottom: 50,
left: 50,
transform: `translateY(${interpolate(progress, [0, 1], [40, 0])}px) scale(${scale})`,
opacity: progress,
}}
>
<div
style={{
background: `linear-gradient(135deg, ${color}, ${color}dd)`,
padding: "14px 28px",
borderRadius: 10,
boxShadow: `0 10px 30px ${color}60`,
}}
>
<span style={{ fontSize: 20, fontWeight: 700, color: "white" }}>
Step {step}: {text}
</span>
</div>
</div>
);
};
// ============================================
// CONTACT CARD - snappier entrance
// ============================================
const ContactCard: React.FC<{
type: "phone" | "email";
value: string;
source?: string;
delay: number;
}> = ({ type, value, source, delay }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame: frame - delay, fps, config: SPRING_PUNCH });
const scale = interpolate(progress, [0, 0.6, 1], [0.7, 1.08, 1]);
const x = interpolate(progress, [0, 1], [type === "phone" ? -80 : 80, 0]);
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "12px 16px",
backgroundColor: "rgba(255,255,255,0.95)",
borderRadius: 10,
marginBottom: 8,
boxShadow: "0 4px 20px rgba(0,0,0,0.1)",
transform: `translateX(${x}px) scale(${scale})`,
opacity: progress,
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 8,
background:
type === "phone"
? "linear-gradient(135deg, #10b981, #059669)"
: "linear-gradient(135deg, #6366f1, #4f46e5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 18,
}}
>
{type === "phone" ? "📞" : "📧"}
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: "#1e293b" }}>{value}</div>
{source && <div style={{ fontSize: 11, color: "#64748b" }}>{source}</div>}
</div>
</div>
);
};
// ============================================
// ANIMATED STAT - with count up and punch
// ============================================
const AnimatedStat: React.FC<{
value: number | string;
label: string;
color: string;
delay: number;
}> = ({ value, label, color, delay }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame: frame - delay, fps, config: SPRING_PUNCH });
const scale = interpolate(progress, [0, 0.5, 1], [0.3, 1.2, 1]);
const numericValue =
typeof value === "number" ? Math.round(interpolate(progress, [0, 1], [0, value])) : value;
return (
<div
style={{
textAlign: "center",
transform: `scale(${scale})`,
opacity: progress,
}}
>
<div style={{
fontSize: 56,
fontWeight: 800,
color,
textShadow: `0 0 30px ${color}80`,
}}>
{numericValue}
</div>
<div style={{ fontSize: 14, color: "#94a3b8", marginTop: 4 }}>{label}</div>
</div>
);
};
// ============================================
// FLOATING LIGHT STREAKS
// ============================================
const LightStreaks: React.FC = () => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
return (
<>
{[...Array(5)].map((_, i) => {
const speed = 0.5 + (i % 3) * 0.3;
const y = (i * 200 + frame * speed * 2) % (height + 200) - 100;
const opacity = 0.1 + (i % 3) * 0.05;
return (
<div
key={i}
style={{
position: "absolute",
left: 100 + i * 350,
top: y,
width: 2,
height: 150,
background: `linear-gradient(180deg, transparent, rgba(99, 102, 241, ${opacity}), transparent)`,
transform: "rotate(15deg)",
}}
/>
);
})}
</>
);
};
// ============================================
// SCENES - More Exciting versions
// ============================================
const SceneIntro: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Dramatic zoom out with slight overshoot
const zoomProgress = spring({ frame, fps, config: { damping: 15, stiffness: 80 } });
const zoom = interpolate(zoomProgress, [0, 1], [1.4, 1.0]);
// Subtle rotation settle
const rotate = interpolate(frame, [0, 60], [2, 0], { extrapolateRight: "clamp" });
return (
<AbsoluteFill>
<LightStreaks />
<GlowPulse color="#6366f1" size={800} delay={0} intensity={0.3} />
<Camera zoom={zoom} rotate={rotate}>
<div style={{ textAlign: "center" }}>
<KineticText
text="🏢 Reonomy Contact Extractor"
delay={15}
stagger={4}
punch
style={{ fontSize: 68, fontWeight: 800, color: "white", justifyContent: "center" }}
/>
<div style={{ marginTop: 24 }}>
<KineticText
text="CRE Leads → Your CRM … On Demand"
delay={55}
stagger={4}
style={{ fontSize: 30, fontWeight: 500, color: "#94a3b8", justifyContent: "center" }}
/>
</div>
</div>
</Camera>
<ImpactFlash delay={0} />
</AbsoluteFill>
);
};
const SceneLogin: React.FC = () => {
const frame = useCurrentFrame();
// Push in with slight overshoot
const zoom = interpolate(frame, [0, 80, 120], [0.85, 1.12, 1.08], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
return (
<AbsoluteFill>
<LightStreaks />
<Camera zoom={zoom}>
<BrowserMockup src={staticFile("assets/02-login-filled.png")} width={1000} glow glowColor="#6366f1" />
</Camera>
<StepLabel step={1} text="Login" />
<ImpactFlash delay={0} color="#6366f1" />
</AbsoluteFill>
);
};
const SceneSearch: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const zoom = interpolate(frame, [0, 60, 100], [0.8, 1.02, 1.0], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
const filter1 = spring({ frame: frame - 40, fps, config: SPRING_PUNCH });
const filter2 = spring({ frame: frame - 55, fps, config: SPRING_PUNCH });
return (
<AbsoluteFill>
<LightStreaks />
<Camera zoom={zoom}>
<div style={{ position: "relative" }}>
<BrowserMockup src={staticFile("assets/04-search-results.png")} width={1050} glow glowColor="#10b981" />
<div style={{ position: "absolute", left: -170, top: 140 }}>
<div
style={{
background: "linear-gradient(135deg, #10b981, #059669)",
padding: "12px 20px",
borderRadius: 25,
marginBottom: 12,
boxShadow: "0 8px 30px rgba(16, 185, 129, 0.5)",
transform: `translateX(${interpolate(filter1, [0, 1], [-50, 0])}px) scale(${interpolate(filter1, [0, 0.5, 1], [0.5, 1.1, 1])})`,
opacity: filter1,
}}
>
<span style={{ color: "white", fontWeight: 700, fontSize: 15 }}> Has Phone</span>
</div>
<div
style={{
background: "linear-gradient(135deg, #6366f1, #4f46e5)",
padding: "12px 20px",
borderRadius: 25,
boxShadow: "0 8px 30px rgba(99, 102, 241, 0.5)",
transform: `translateX(${interpolate(filter2, [0, 1], [-50, 0])}px) scale(${interpolate(filter2, [0, 0.5, 1], [0.5, 1.1, 1])})`,
opacity: filter2,
}}
>
<span style={{ color: "white", fontWeight: 700, fontSize: 15 }}> Has Email</span>
</div>
</div>
</div>
</Camera>
<StepLabel step={2} text="Filtered Search" />
<ImpactFlash delay={0} color="#10b981" />
</AbsoluteFill>
);
};
const SceneProperty: React.FC = () => {
const frame = useCurrentFrame();
// Dramatic pan with zoom
const y = interpolate(frame, [0, 100], [-30, 20], { extrapolateRight: "clamp" });
const zoom = interpolate(frame, [0, 50, 100], [0.95, 1.05, 1.02], { extrapolateRight: "clamp" });
return (
<AbsoluteFill>
<LightStreaks />
<Camera y={y} zoom={zoom}>
<BrowserMockup src={staticFile("assets/05-property-detail.png")} width={1000} glow />
</Camera>
<StepLabel step={3} text="Property Details" />
<ImpactFlash delay={0} />
</AbsoluteFill>
);
};
const SceneOwner: React.FC = () => {
const frame = useCurrentFrame();
// Punch zoom in
const zoom = interpolate(frame, [0, 40, 80], [0.95, 1.18, 1.12], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
return (
<AbsoluteFill>
<LightStreaks />
<GlowPulse color="#8b5cf6" size={600} delay={30} intensity={0.25} />
<Camera zoom={zoom}>
<BrowserMockup src={staticFile("assets/06-owner-tab.png")} width={1000} glow glowColor="#8b5cf6" />
</Camera>
<StepLabel step={4} text="Owner Tab" color="#8b5cf6" />
<ImpactFlash delay={0} color="#8b5cf6" />
</AbsoluteFill>
);
};
const SceneModal: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Big dramatic zoom punch
const zoom = interpolate(frame, [0, 30, 70], [0.7, 1.15, 1.05], {
extrapolateRight: "clamp",
easing: Easing.out(Easing.back(1.5)),
});
const labelProgress = spring({ frame: frame - 50, fps, config: SPRING_BOUNCY });
return (
<AbsoluteFill>
<LightStreaks />
<GlowPulse color="#10b981" size={900} delay={20} intensity={0.4} />
<Camera zoom={zoom}>
<BrowserMockup src={staticFile("assets/09-contact-modal.png")} width={1050} glow glowColor="#10b981" />
</Camera>
{/* Success label with punch */}
<div
style={{
position: "absolute",
bottom: 60,
left: "50%",
transform: `translateX(-50%) translateY(${interpolate(labelProgress, [0, 1], [60, 0])}px) scale(${interpolate(labelProgress, [0, 0.5, 1], [0.5, 1.15, 1])})`,
opacity: labelProgress,
}}
>
<div
style={{
background: "linear-gradient(135deg, #10b981, #059669)",
padding: "18px 50px",
borderRadius: 14,
boxShadow: "0 20px 60px rgba(16, 185, 129, 0.6)",
}}
>
<span style={{ fontSize: 26, fontWeight: 700, color: "white" }}>
🎉 Contacts Extracted!
</span>
</div>
</div>
<ImpactFlash delay={0} color="#10b981" />
<ParticleBurst delay={50} count={25} />
</AbsoluteFill>
);
};
const SceneResults: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Elegant zoom out with slight movement
const zoom = interpolate(frame, [0, 100], [1.1, 1.0], { extrapolateRight: "clamp" });
const y = interpolate(frame, [0, 100], [20, 0], { extrapolateRight: "clamp" });
const phones = [
{ value: "919-469-9553", source: "Greystone Property Mgmt" },
{ value: "727-341-0186", source: "Seaside Villas" },
{ value: "903-566-9506", source: "Apartment Income Reit" },
{ value: "407-671-2400", source: "E R Management" },
{ value: "407-382-2683", source: "Bellagio Apartments" },
];
const emails = [
{ value: "berrizoro@gmail.com" },
{ value: "aberriz@hotmail.com" },
{ value: "jasonhitch1@gmail.com" },
{ value: "albert@annarborusa.org" },
{ value: "albertb@sterlinghousing.com" },
];
const headerProgress = spring({ frame: frame - 5, fps, config: SPRING_PUNCH });
return (
<AbsoluteFill>
<LightStreaks />
<GlowPulse color="#6366f1" size={1200} delay={0} intensity={0.2} />
<Camera zoom={zoom} y={y}>
<div style={{ width: 1600, padding: 40 }}>
{/* Title with punch */}
<div style={{ textAlign: "center", marginBottom: 30 }}>
<div
style={{
transform: `scale(${interpolate(headerProgress, [0, 0.5, 1], [0.5, 1.1, 1])})`,
opacity: headerProgress,
}}
>
<span style={{
fontSize: 46,
fontWeight: 800,
color: "white",
textShadow: "0 0 40px rgba(99, 102, 241, 0.5)",
}}>
CRE Leads Delivered Direct to Your CRM
</span>
</div>
<div
style={{
marginTop: 12,
opacity: spring({ frame: frame - 20, fps, config: SPRING_SNAPPY }),
transform: `scale(${interpolate(spring({ frame: frame - 20, fps, config: SPRING_PUNCH }), [0, 0.5, 1], [0.5, 1.15, 1])})`,
}}
>
<span
style={{
fontSize: 28,
fontWeight: 600,
background: "linear-gradient(90deg, #6366f1, #a855f7, #ec4899)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
On Demand 🚀
</span>
</div>
</div>
{/* Contact columns */}
<div style={{ display: "flex", gap: 40, paddingLeft: 80, paddingRight: 80 }}>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: 20,
color: "#10b981",
marginBottom: 14,
fontWeight: 700,
opacity: spring({ frame: frame - 50, fps, config: SPRING_SMOOTH }),
textShadow: "0 0 20px rgba(16, 185, 129, 0.5)",
}}
>
📞 Phone Numbers
</div>
{phones.map((p, i) => (
<ContactCard
key={p.value}
type="phone"
value={p.value}
source={p.source}
delay={60 + i * 10}
/>
))}
</div>
<div style={{ flex: 1 }}>
<div
style={{
fontSize: 20,
color: "#6366f1",
marginBottom: 14,
fontWeight: 700,
opacity: spring({ frame: frame - 55, fps, config: SPRING_SMOOTH }),
textShadow: "0 0 20px rgba(99, 102, 241, 0.5)",
}}
>
📧 Email Addresses
</div>
{emails.map((e, i) => (
<ContactCard key={e.value} type="email" value={e.value} delay={65 + i * 10} />
))}
</div>
</div>
{/* Stats footer */}
<div
style={{
marginTop: 35,
display: "flex",
justifyContent: "space-around",
background: "rgba(255,255,255,0.08)",
backdropFilter: "blur(10px)",
padding: "25px 60px",
borderRadius: 16,
marginLeft: 80,
marginRight: 80,
border: "1px solid rgba(255,255,255,0.1)",
}}
>
<AnimatedStat value={10} label="Total Contacts" color="#6366f1" delay={160} />
<AnimatedStat value={5} label="Phone Numbers" color="#10b981" delay={175} />
<AnimatedStat value={5} label="Emails" color="#f59e0b" delay={190} />
<AnimatedStat value="~60s" label="Extraction Time" color="#ec4899" delay={205} />
</div>
</div>
</Camera>
<ImpactFlash delay={0} />
</AbsoluteFill>
);
};
// ============================================
// MAIN COMPOSITION
// ============================================
export const ReonomyDemoExciting: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const fadeIn = interpolate(frame, [0, 15], [0, 1], { extrapolateRight: "clamp" });
const fadeOut = interpolate(frame, [durationInFrames - 15, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
});
return (
<AbsoluteFill
style={{
background: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)",
opacity: Math.min(fadeIn, fadeOut),
}}
>
<Series>
<Series.Sequence durationInFrames={180}>
<SceneIntro />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<SceneLogin />
</Series.Sequence>
<Series.Sequence durationInFrames={170}>
<SceneSearch />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<SceneProperty />
</Series.Sequence>
<Series.Sequence durationInFrames={150}>
<SceneOwner />
</Series.Sequence>
<Series.Sequence durationInFrames={180}>
<SceneModal />
</Series.Sequence>
<Series.Sequence durationInFrames={360}>
<SceneResults />
</Series.Sequence>
</Series>
</AbsoluteFill>
);
};

View File

@ -0,0 +1,35 @@
import { Composition } from "remotion";
import { ReonomyDemo } from "./ReonomyDemo";
import { ReonomyDemoExciting } from "./ReonomyDemoExciting";
import { ReonomyCanvasDemo } from "./ReonomyCanvasDemo";
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="ReonomyDemo"
component={ReonomyDemo}
durationInFrames={1590} // 53 seconds at 30fps
fps={30}
width={1920}
height={1080}
/>
<Composition
id="ReonomyDemoExciting"
component={ReonomyDemoExciting}
durationInFrames={1340} // ~45 seconds at 30fps
fps={30}
width={1920}
height={1080}
/>
<Composition
id="ReonomyCanvasDemo"
component={ReonomyCanvasDemo}
durationInFrames={1100} // ~37 seconds at 30fps
fps={30}
width={1920}
height={1080}
/>
</>
);
};

View File

@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root";
registerRoot(RemotionRoot);

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2018",
"module": "commonjs",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

124
reonomy-demo.sh Executable file
View File

@ -0,0 +1,124 @@
#!/bin/bash
# Reonomy Demo - Quick single contact extraction
# Usage: ./reonomy-demo.sh
# Shows: Login → Search → Property → Owner → Contact modal with phones/emails
set -e
echo "🎯 Reonomy Contact Extraction Demo"
echo "=================================="
echo ""
# Config
EMAIL="${REONOMY_EMAIL:-henry@realestateenhanced.com}"
PASSWORD="${REONOMY_PASSWORD:-9082166532}"
SEARCH_ID="${REONOMY_SEARCH_ID:-bacfd104-fed5-4cc4-aba1-933f899de3f8}"
# Random delay helper
delay() {
local min=$1
local max=$2
local ms=$(( (RANDOM % (max - min + 1)) + min ))
sleep $(echo "scale=2; $ms/1000" | bc)
}
echo "📍 Step 1: Opening Reonomy..."
agent-browser open "https://app.reonomy.com/#!/login" --headed
delay 2000 4000
echo "📍 Step 2: Logging in..."
agent-browser snapshot -i > /dev/null
agent-browser fill @e2 "$EMAIL"
delay 500 1000
agent-browser fill @e3 "$PASSWORD"
delay 500 1000
agent-browser click @e5
echo " ⏳ Waiting for login..."
sleep 12
# Check login
URL=$(agent-browser eval "window.location.href" 2>/dev/null)
if [[ "$URL" == *"auth.reonomy"* ]]; then
echo " ❌ Login failed"
exit 1
fi
echo " ✅ Logged in!"
echo ""
echo "📍 Step 3: Loading search with filters..."
agent-browser open "https://app.reonomy.com/#!/search/$SEARCH_ID"
delay 6000 8000
echo "📍 Step 4: Clicking first property..."
# Get fresh snapshot and click first property-looking button
agent-browser snapshot -i > /tmp/reonomy-demo-snap.txt
# Find a property (address pattern)
PROP_REF=$(grep -oE 'button "[0-9]+ [^"]+" \[ref=e[0-9]+\]' /tmp/reonomy-demo-snap.txt | head -1 | grep -oE 'e[0-9]+')
if [ -z "$PROP_REF" ]; then
# Try another pattern
PROP_REF="e8"
fi
agent-browser click @$PROP_REF
delay 5000 7000
echo "📍 Step 5: Clicking Owner tab..."
agent-browser find role tab click --name "Owner"
delay 4000 6000
echo "📍 Step 6: Clicking View Contacts..."
agent-browser snapshot -i > /tmp/reonomy-demo-snap.txt
VC_REF=$(grep -oE 'button "View Contacts[^"]*" \[ref=e[0-9]+\]' /tmp/reonomy-demo-snap.txt | grep -oE 'e[0-9]+')
if [ -z "$VC_REF" ]; then
echo " ⚠️ No View Contacts button found"
agent-browser screenshot /tmp/reonomy-demo-nocontacts.png
echo " Screenshot: /tmp/reonomy-demo-nocontacts.png"
exit 1
fi
agent-browser click @$VC_REF
delay 3000 5000
echo "📍 Step 7: Finding contact person..."
agent-browser snapshot > /tmp/reonomy-demo-snap.txt
# Look for person link
PERSON_URL=$(grep -oE '/!/person/[a-f0-9-]+' /tmp/reonomy-demo-snap.txt | head -1)
if [ -z "$PERSON_URL" ]; then
echo " ⚠️ No person link found, checking for direct contacts..."
agent-browser snapshot -i
exit 0
fi
echo " Found: $PERSON_URL"
agent-browser open "https://app.reonomy.com$PERSON_URL"
delay 5000 7000
echo "📍 Step 8: Opening contact modal..."
agent-browser snapshot -i > /tmp/reonomy-demo-snap.txt
CONTACT_REF=$(grep -oE 'button "Contact" \[ref=e[0-9]+\]' /tmp/reonomy-demo-snap.txt | grep -oE 'e[0-9]+')
if [ -z "$CONTACT_REF" ]; then
echo " ⚠️ No Contact button found"
exit 1
fi
agent-browser click @$CONTACT_REF
delay 2000 3000
echo ""
echo "🎉 CONTACT INFO EXTRACTED!"
echo "=========================="
agent-browser snapshot -i | grep -E '(button "[0-9]{3}-[0-9]{3}-[0-9]{4}|button "[a-zA-Z0-9._%+-]+@)' | while read line; do
if [[ "$line" == *"@"* ]]; then
EMAIL=$(echo "$line" | grep -oE '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}')
echo " 📧 $EMAIL"
else
PHONE=$(echo "$line" | grep -oE '[0-9]{3}-[0-9]{3}-[0-9]{4}')
echo " 📞 $PHONE"
fi
done
echo ""
echo "📸 Taking screenshot..."
agent-browser screenshot /tmp/reonomy-demo-result.png
echo " Saved: /tmp/reonomy-demo-result.png"
echo ""
echo "✅ Demo complete! Browser left open for inspection."
echo " Run 'agent-browser close' when done."

6
reonomy-leads-v13.json Normal file
View File

@ -0,0 +1,6 @@
{
"scrapeDate": "2026-01-26T10:45:52.897Z",
"searchId": "bacfd104-fed5-4cc4-aba1-933f899de3f8",
"leadCount": 0,
"leads": []
}

125
reonomy-quick-demo.js Executable file
View File

@ -0,0 +1,125 @@
#!/usr/bin/env node
/**
* Reonomy Quick Demo - Single contact extraction
* Shows the full flow in ~60 seconds
*
* Usage: node reonomy-quick-demo.js
* Or: buba reonomy demo
*/
const { execSync } = require('child_process');
const EMAIL = process.env.REONOMY_EMAIL || 'henry@realestateenhanced.com';
const PASSWORD = process.env.REONOMY_PASSWORD || '9082166532';
const SEARCH_ID = process.env.REONOMY_SEARCH_ID || 'bacfd104-fed5-4cc4-aba1-933f899de3f8';
function ab(cmd) {
console.log(`${cmd}`);
try {
return execSync(`agent-browser ${cmd}`, { encoding: 'utf8', timeout: 30000 }).trim();
} catch (e) {
console.log(` ⚠️ ${e.message.substring(0, 50)}`);
return null;
}
}
function wait(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function demo() {
console.log('\n🎯 REONOMY CONTACT EXTRACTION DEMO');
console.log('===================================\n');
// Login
console.log('📍 Step 1: Login');
ab('open "https://app.reonomy.com/#!/login" --headed');
await wait(4000);
ab(`fill @e2 "${EMAIL}"`);
await wait(500);
ab(`fill @e3 "${PASSWORD}"`);
await wait(500);
ab('click @e5');
console.log(' ⏳ Logging in...');
await wait(12000);
// Search
console.log('\n📍 Step 2: Load filtered search');
ab(`open "https://app.reonomy.com/#!/search/${SEARCH_ID}"`);
await wait(8000);
// Click property
console.log('\n📍 Step 3: Select property');
const snap1 = ab('snapshot -i');
const propMatch = snap1?.match(/button "(\d+[^"]+)" \[ref=(e\d+)\]/);
if (propMatch) {
console.log(` Property: ${propMatch[1].substring(0, 50)}...`);
ab(`click @${propMatch[2]}`);
} else {
ab('click @e8');
}
await wait(6000);
// Owner tab
console.log('\n📍 Step 4: Owner tab');
ab('find role tab click --name "Owner"');
await wait(5000);
// View Contacts
console.log('\n📍 Step 5: View Contacts');
const snap2 = ab('snapshot -i');
const vcMatch = snap2?.match(/button "View Contacts[^"]*" \[ref=(e\d+)\]/);
if (vcMatch) {
ab(`click @${vcMatch[1]}`);
await wait(5000);
}
// Find person
console.log('\n📍 Step 6: Navigate to person');
const snap3 = ab('snapshot');
const personMatch = snap3?.match(/\/url: \/!\/person\/([a-f0-9-]+)/);
if (personMatch) {
ab(`open "https://app.reonomy.com/!/person/${personMatch[1]}"`);
await wait(6000);
}
// Click Contact
console.log('\n📍 Step 7: Open contact modal');
const snap4 = ab('snapshot -i');
const contactMatch = snap4?.match(/button "Contact" \[ref=(e\d+)\]/);
if (contactMatch) {
ab(`click @${contactMatch[1]}`);
await wait(3000);
}
// Extract
console.log('\n📍 Step 8: EXTRACT CONTACTS');
const snap5 = ab('snapshot -i');
console.log('\n🎉 CONTACTS FOUND:');
console.log('==================');
// Phones
const phones = snap5?.matchAll(/button "(\d{3}-\d{3}-\d{4})\s+([^"]+)"/g) || [];
for (const p of phones) {
console.log(` 📞 ${p[1]} (${p[2].substring(0, 30)})`);
}
// Emails
const emails = snap5?.matchAll(/button "([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})"/g) || [];
for (const e of emails) {
console.log(` 📧 ${e[1]}`);
}
// Screenshot
ab('screenshot /tmp/reonomy-demo-final.png');
console.log('\n📸 Screenshot: /tmp/reonomy-demo-final.png');
console.log('\n✅ Demo complete! Browser left open.');
console.log(' Run: agent-browser close');
}
demo().catch(e => {
console.error('Demo failed:', e.message);
process.exit(1);
});

442
reonomy-scraper-v13.js Executable file
View File

@ -0,0 +1,442 @@
#!/usr/bin/env node
/**
* Reonomy Scraper v13 - Agent-Browser Edition (Anti-Detection)
*
* ANTI-DETECTION FEATURES:
* - Random delays (human-like timing)
* - Random property order
* - Occasional "distraction" actions
* - Session limits (max per run)
* - Daily tracking to avoid over-scraping
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
// Config
const CONFIG = {
authStatePath: path.join(process.env.HOME, '.clawdbot/workspace/reonomy-auth.json'),
outputPath: path.join(process.env.HOME, '.clawdbot/workspace/reonomy-leads-v13.json'),
logPath: path.join(process.env.HOME, '.clawdbot/workspace/reonomy-scraper-v13.log'),
dailyLogPath: path.join(process.env.HOME, '.clawdbot/workspace/reonomy-daily-stats.json'),
searchId: process.env.REONOMY_SEARCH_ID || 'bacfd104-fed5-4cc4-aba1-933f899de3f8',
maxProperties: parseInt(process.env.MAX_PROPERTIES) || 20,
maxDailyProperties: 50, // Don't exceed this per day
headless: process.env.HEADLESS !== 'false',
email: process.env.REONOMY_EMAIL || 'henry@realestateenhanced.com',
password: process.env.REONOMY_PASSWORD || '9082166532',
};
// Anti-detection: Random delay between min and max ms
function randomDelay(minMs, maxMs) {
const delay = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
return new Promise(resolve => setTimeout(resolve, delay));
}
// Anti-detection: Shuffle array (Fisher-Yates)
function shuffle(array) {
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Logging
function log(msg) {
const timestamp = new Date().toISOString();
const line = `[${timestamp}] ${msg}`;
console.log(line);
fs.appendFileSync(CONFIG.logPath, line + '\n');
}
// Run agent-browser command
function ab(cmd, options = {}) {
const fullCmd = `agent-browser ${cmd}`;
if (options.verbose !== false) {
log(` 🔧 ${fullCmd}`);
}
try {
const result = execSync(fullCmd, {
encoding: 'utf8',
timeout: options.timeout || 30000,
stdio: ['pipe', 'pipe', 'pipe']
});
return { success: true, output: result.trim() };
} catch (err) {
const stderr = err.stderr?.toString() || err.message;
if (options.verbose !== false) {
log(` ❌ Error: ${stderr.substring(0, 100)}`);
}
return { success: false, error: stderr };
}
}
// Anti-detection: Random "human" actions
async function humanize() {
const actions = [
() => ab('scroll down 200', { verbose: false }),
() => ab('scroll up 100', { verbose: false }),
() => randomDelay(500, 1500),
() => randomDelay(1000, 2000),
];
// 30% chance to do a random action
if (Math.random() < 0.3) {
const action = actions[Math.floor(Math.random() * actions.length)];
await action();
}
}
// Daily stats tracking
function getDailyStats() {
const today = new Date().toISOString().split('T')[0];
try {
const data = JSON.parse(fs.readFileSync(CONFIG.dailyLogPath, 'utf8'));
if (data.date === today) {
return data;
}
} catch (e) {}
return { date: today, propertiesScraped: 0, leadsFound: 0 };
}
function saveDailyStats(stats) {
fs.writeFileSync(CONFIG.dailyLogPath, JSON.stringify(stats, null, 2));
}
// Login to Reonomy
async function login() {
log(' Navigating to login page...');
ab('open "https://app.reonomy.com/#!/login"');
await randomDelay(3000, 5000);
const snapshot = ab('snapshot -i');
if (!snapshot.output?.includes('textbox "Email"')) {
const urlCheck = ab('eval "window.location.href"');
if (urlCheck.output?.includes('app.reonomy.com') && !urlCheck.output?.includes('login')) {
log(' Already logged in!');
return true;
}
throw new Error('Login form not found');
}
const emailMatch = snapshot.output.match(/textbox "Email" \[ref=(e\d+)\]/);
const passMatch = snapshot.output.match(/textbox "Password" \[ref=(e\d+)\]/);
const loginMatch = snapshot.output.match(/button "Log In" \[ref=(e\d+)\]/);
if (!emailMatch || !passMatch || !loginMatch) {
throw new Error('Could not find login form elements');
}
log(' Filling credentials...');
ab(`fill @${emailMatch[1]} "${CONFIG.email}"`);
await randomDelay(800, 1500);
ab(`fill @${passMatch[1]} "${CONFIG.password}"`);
await randomDelay(800, 1500);
log(' Clicking login...');
ab(`click @${loginMatch[1]}`);
await randomDelay(12000, 16000); // Human-like wait for login
const postLoginUrl = ab('eval "window.location.href"');
if (postLoginUrl.output?.includes('auth.reonomy.com') || postLoginUrl.output?.includes('login')) {
throw new Error('Login failed - still on login page');
}
log(' Saving auth state...');
ab(`state save "${CONFIG.authStatePath}"`);
log(' ✅ Login successful!');
return true;
}
// Extract contacts from modal snapshot
function extractContacts(snapshot) {
const phones = [];
const emails = [];
const phoneMatches = snapshot.matchAll(/button "(\d{3}-\d{3}-\d{4})\s+([^"]+)"/g);
for (const match of phoneMatches) {
phones.push({ number: match[1], source: match[2].trim() });
}
const emailMatches = snapshot.matchAll(/button "([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})"/g);
for (const match of emailMatches) {
emails.push(match[1]);
}
return { phones, emails };
}
// Main scraping function
async function scrape() {
log('🚀 Starting Reonomy Scraper v13 (ANTI-DETECTION MODE)');
// Check daily limits
const dailyStats = getDailyStats();
if (dailyStats.propertiesScraped >= CONFIG.maxDailyProperties) {
log(`⚠️ Daily limit reached (${dailyStats.propertiesScraped}/${CONFIG.maxDailyProperties}). Try again tomorrow.`);
return [];
}
const remainingToday = CONFIG.maxDailyProperties - dailyStats.propertiesScraped;
const maxThisRun = Math.min(CONFIG.maxProperties, remainingToday);
log(`📊 Daily stats: ${dailyStats.propertiesScraped} scraped today, ${remainingToday} remaining`);
log(`📊 This run: max ${maxThisRun} properties`);
const leads = [];
try {
// Step 1: Auth
log('\n📍 Step 1: Authenticating...');
let needsLogin = true;
if (fs.existsSync(CONFIG.authStatePath)) {
log(' Found existing auth state, testing...');
ab(`state load "${CONFIG.authStatePath}"`);
ab('open "https://app.reonomy.com/#!/home"');
await randomDelay(4000, 6000);
const testUrl = ab('eval "window.location.href"');
if (testUrl.output?.includes('app.reonomy.com') &&
!testUrl.output?.includes('auth.reonomy.com') &&
!testUrl.output?.includes('login')) {
log(' ✅ Session still valid!');
needsLogin = false;
} else {
log(' ⚠️ Session expired...');
}
}
if (needsLogin) {
await login();
}
// Step 2: Navigate to search
log('\n📍 Step 2: Navigating to search results...');
const searchUrl = `https://app.reonomy.com/#!/search/${CONFIG.searchId}`;
ab(`open "${searchUrl}"`);
await randomDelay(6000, 10000);
let urlCheck = ab('eval "window.location.href"');
if (urlCheck.output?.includes('auth.reonomy.com') || urlCheck.output?.includes('login')) {
log(' Session invalid, logging in...');
await login();
ab(`open "${searchUrl}"`);
await randomDelay(6000, 10000);
}
// Step 3: Get property list
log('\n📍 Step 3: Getting property list...');
await humanize();
const iSnapshot = ab('snapshot -i');
const properties = [];
// Find property buttons (addresses)
const buttonMatches = iSnapshot.output?.matchAll(/button "([^"]+)" \[ref=(e\d+)\]/g) || [];
for (const match of buttonMatches) {
if (match[1].includes('Saved Searches') ||
match[1].includes('Help Center') ||
match[1].includes('More filters') ||
match[1].length < 10) {
continue;
}
if (/\d+.*(?:st|ave|blvd|dr|ln|rd|way|ct|highway)/i.test(match[1])) {
properties.push({
name: match[1].substring(0, 60),
ref: match[2]
});
}
}
log(` Found ${properties.length} properties`);
if (properties.length === 0) {
ab('screenshot /tmp/reonomy-v13-no-properties.png');
throw new Error('No properties found');
}
// Anti-detection: Shuffle and limit
const shuffledProps = shuffle(properties).slice(0, maxThisRun);
log(` Processing ${shuffledProps.length} properties (randomized order)`);
// Step 4: Process properties
log('\n📍 Step 4: Processing properties...');
for (let i = 0; i < shuffledProps.length; i++) {
const prop = shuffledProps[i];
log(`\n --- Property ${i + 1}/${shuffledProps.length}: ${prop.name.substring(0, 40)}... ---`);
await humanize();
try {
// Click property
ab(`click @${prop.ref}`);
await randomDelay(5000, 8000);
const propUrl = ab('eval "window.location.href"');
const propIdMatch = propUrl.output?.match(/property\/([a-f0-9-]+)/);
const propertyId = propIdMatch ? propIdMatch[1] : 'unknown';
let propertyAddress = prop.name;
const titleSnap = ab('snapshot');
const headingMatch = titleSnap.output?.match(/heading "([^"]+)"/);
if (headingMatch) propertyAddress = headingMatch[1];
// Click Owner tab
log(' Clicking Owner tab...');
await humanize();
ab('find role tab click --name "Owner"');
await randomDelay(4000, 6000);
// Find View Contacts
const ownerSnap = ab('snapshot -i');
const vcMatch = ownerSnap.output?.match(/button "View Contacts \((\d+)\)" \[ref=(e\d+)\]/);
if (!vcMatch) {
log(' ⚠️ No View Contacts button');
ab('back');
await randomDelay(3000, 5000);
dailyStats.propertiesScraped++;
continue;
}
log(` Found ${vcMatch[1]} contacts`);
ab(`click @${vcMatch[2]}`);
await randomDelay(4000, 6000);
// Find person link
const companySnap = ab('snapshot');
const personMatch = companySnap.output?.match(/\/url: \/!\/person\/([a-f0-9-]+)/);
if (!personMatch) {
log(' ⚠️ No person link found');
ab('back');
await randomDelay(2000, 4000);
ab('back');
await randomDelay(3000, 5000);
dailyStats.propertiesScraped++;
continue;
}
const personId = personMatch[1];
// Get person name
const personNameMatch = companySnap.output?.match(/link "([^"]+)"[^\n]*\/url: \/!\/person/);
const personName = personNameMatch ? personNameMatch[1] : 'Unknown';
log(` Person: ${personName}`);
ab(`open "https://app.reonomy.com/!/person/${personId}"`);
await randomDelay(5000, 8000);
// Click Contact button
await humanize();
const personSnap = ab('snapshot -i');
const contactMatch = personSnap.output?.match(/button "Contact" \[ref=(e\d+)\]/);
if (!contactMatch) {
log(' ⚠️ No Contact button');
ab('back');
await randomDelay(3000, 5000);
dailyStats.propertiesScraped++;
continue;
}
ab(`click @${contactMatch[1]}`);
await randomDelay(2000, 4000);
// Extract contacts
const modalSnap = ab('snapshot -i');
const contacts = extractContacts(modalSnap.output || '');
log(` 📞 ${contacts.phones.length} phones, 📧 ${contacts.emails.length} emails`);
if (contacts.phones.length > 0 || contacts.emails.length > 0) {
leads.push({
scrapeDate: new Date().toISOString(),
propertyId,
propertyAddress,
personName,
personId,
phones: contacts.phones,
emails: contacts.emails
});
dailyStats.leadsFound++;
log(' ✅ Lead captured!');
}
dailyStats.propertiesScraped++;
// Close modal and return to search
ab('press Escape');
await randomDelay(1000, 2000);
ab(`open "https://app.reonomy.com/#!/search/${CONFIG.searchId}"`);
await randomDelay(5000, 8000);
// Occasional longer break (anti-detection)
if (Math.random() < 0.2) {
log(' ☕ Taking a short break...');
await randomDelay(8000, 15000);
}
} catch (propError) {
log(` ❌ Error: ${propError.message}`);
ab(`open "https://app.reonomy.com/#!/search/${CONFIG.searchId}"`);
await randomDelay(5000, 8000);
dailyStats.propertiesScraped++;
}
// Save progress
saveDailyStats(dailyStats);
}
// Step 5: Save results
log('\n📍 Step 5: Saving results...');
// Append to existing leads if file exists
let allLeads = [];
try {
const existing = JSON.parse(fs.readFileSync(CONFIG.outputPath, 'utf8'));
allLeads = existing.leads || [];
} catch (e) {}
allLeads = [...allLeads, ...leads];
const output = {
lastUpdated: new Date().toISOString(),
searchId: CONFIG.searchId,
totalLeads: allLeads.length,
leads: allLeads
};
fs.writeFileSync(CONFIG.outputPath, JSON.stringify(output, null, 2));
log(`✅ Saved ${leads.length} new leads (${allLeads.length} total)`);
saveDailyStats(dailyStats);
log(`📊 Daily total: ${dailyStats.propertiesScraped} properties, ${dailyStats.leadsFound} leads`);
} catch (error) {
log(`\n❌ Fatal error: ${error.message}`);
ab('screenshot /tmp/reonomy-v13-error.png');
throw error;
} finally {
log('\n🧹 Closing browser...');
ab('close');
}
return leads;
}
// Run
scrape()
.then(leads => {
log(`\n🎉 Done! Scraped ${leads.length} leads this run.`);
process.exit(0);
})
.catch(err => {
log(`\n💥 Scraper failed: ${err.message}`);
process.exit(1);
});

View File

@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawdhub.com",
"slug": "agent-browser",
"installedVersion": "0.2.0",
"installedAt": 1769423250734
}

View File

@ -0,0 +1,63 @@
# Contributing to Agent Browser Skill
This skill wraps the agent-browser CLI. Determine where the problem lies before reporting issues.
## Issue Reporting Guide
### Open an issue in this repository if
- The skill documentation is unclear or missing
- Examples in SKILL.md do not work
- You need help using the CLI with this skill wrapper
- The skill is missing a command or feature
### Open an issue at the agent-browser repository if
- The CLI crashes or throws errors
- Commands do not behave as documented
- You found a bug in the browser automation
- You need a new feature in the CLI
## Before Opening an Issue
1. Install the latest version
```bash
npm install -g agent-browser@latest
```
2. Test the command in your terminal to isolate the issue
## Issue Report Template
Use this template to provide necessary information.
```markdown
### Description
[Provide a clear and concise description of the bug]
### Reproduction Steps
1. [First Step]
2. [Second Step]
3. [Observe error]
### Expected Behavior
[Describe what you expected to happen]
### Environment Details
- **Skill Version:** [e.g. 1.0.2]
- **agent-browser Version:** [output of agent-browser --version]
- **Node.js Version:** [output of node -v]
- **Operating System:** [e.g. macOS Sonoma, Windows 11, Ubuntu 22.04]
### Additional Context
- [Full error output or stack trace]
- [Screenshots]
- [Website URLs where the failure occurred]
```
## Adding New Commands to the Skill
Update SKILL.md when the upstream CLI adds new commands.
- Keep the Installation section
- Add new commands in the correct category
- Include usage examples

View File

@ -0,0 +1,328 @@
---
name: Agent Browser
description: A fast Rust-based headless browser automation CLI with Node.js fallback that enables AI agents to navigate, click, type, and snapshot pages via structured commands.
read_when:
- Automating web interactions
- Extracting structured data from pages
- Filling forms programmatically
- Testing web UIs
metadata: {"clawdbot":{"emoji":"🌐","requires":{"bins":["node","npm"]}}}
allowed-tools: Bash(agent-browser:*)
---
# Browser Automation with agent-browser
## Installation
### npm recommended
```bash
npm install -g agent-browser
agent-browser install
agent-browser install --with-deps
```
### From Source
```bash
git clone https://github.com/vercel-labs/agent-browser
cd agent-browser
pnpm install
pnpm build
agent-browser install
```
## Quick start
```bash
agent-browser open <url> # Navigate to page
agent-browser snapshot -i # Get interactive elements with refs
agent-browser click @e1 # Click element by ref
agent-browser fill @e2 "text" # Fill input by ref
agent-browser close # Close browser
```
## Core workflow
1. Navigate: `agent-browser open <url>`
2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`)
3. Interact using refs from the snapshot
4. Re-snapshot after navigation or significant DOM changes
## Commands
### Navigation
```bash
agent-browser open <url> # Navigate to URL
agent-browser back # Go back
agent-browser forward # Go forward
agent-browser reload # Reload page
agent-browser close # Close browser
```
### Snapshot (page analysis)
```bash
agent-browser snapshot # Full accessibility tree
agent-browser snapshot -i # Interactive elements only (recommended)
agent-browser snapshot -c # Compact output
agent-browser snapshot -d 3 # Limit depth to 3
agent-browser snapshot -s "#main" # Scope to CSS selector
```
### Interactions (use @refs from snapshot)
```bash
agent-browser click @e1 # Click
agent-browser dblclick @e1 # Double-click
agent-browser focus @e1 # Focus element
agent-browser fill @e2 "text" # Clear and type
agent-browser type @e2 "text" # Type without clearing
agent-browser press Enter # Press key
agent-browser press Control+a # Key combination
agent-browser keydown Shift # Hold key down
agent-browser keyup Shift # Release key
agent-browser hover @e1 # Hover
agent-browser check @e1 # Check checkbox
agent-browser uncheck @e1 # Uncheck checkbox
agent-browser select @e1 "value" # Select dropdown
agent-browser scroll down 500 # Scroll page
agent-browser scrollintoview @e1 # Scroll element into view
agent-browser drag @e1 @e2 # Drag and drop
agent-browser upload @e1 file.pdf # Upload files
```
### Get information
```bash
agent-browser get text @e1 # Get element text
agent-browser get html @e1 # Get innerHTML
agent-browser get value @e1 # Get input value
agent-browser get attr @e1 href # Get attribute
agent-browser get title # Get page title
agent-browser get url # Get current URL
agent-browser get count ".item" # Count matching elements
agent-browser get box @e1 # Get bounding box
```
### Check state
```bash
agent-browser is visible @e1 # Check if visible
agent-browser is enabled @e1 # Check if enabled
agent-browser is checked @e1 # Check if checked
```
### Screenshots & PDF
```bash
agent-browser screenshot # Screenshot to stdout
agent-browser screenshot path.png # Save to file
agent-browser screenshot --full # Full page
agent-browser pdf output.pdf # Save as PDF
```
### Video recording
```bash
agent-browser record start ./demo.webm # Start recording (uses current URL + state)
agent-browser click @e1 # Perform actions
agent-browser record stop # Stop and save video
agent-browser record restart ./take2.webm # Stop current + start new recording
```
Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it automatically returns to your current page. For smooth demos, explore first, then start recording.
### Wait
```bash
agent-browser wait @e1 # Wait for element
agent-browser wait 2000 # Wait milliseconds
agent-browser wait --text "Success" # Wait for text
agent-browser wait --url "/dashboard" # Wait for URL pattern
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --fn "window.ready" # Wait for JS condition
```
### Mouse control
```bash
agent-browser mouse move 100 200 # Move mouse
agent-browser mouse down left # Press button
agent-browser mouse up left # Release button
agent-browser mouse wheel 100 # Scroll wheel
```
### Semantic locators (alternative to refs)
```bash
agent-browser find role button click --name "Submit"
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find first ".item" click
agent-browser find nth 2 "a" text
```
### Browser settings
```bash
agent-browser set viewport 1920 1080 # Set viewport size
agent-browser set device "iPhone 14" # Emulate device
agent-browser set geo 37.7749 -122.4194 # Set geolocation
agent-browser set offline on # Toggle offline mode
agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers
agent-browser set credentials user pass # HTTP basic auth
agent-browser set media dark # Emulate color scheme
```
### Cookies & Storage
```bash
agent-browser cookies # Get all cookies
agent-browser cookies set name value # Set cookie
agent-browser cookies clear # Clear cookies
agent-browser storage local # Get all localStorage
agent-browser storage local key # Get specific key
agent-browser storage local set k v # Set value
agent-browser storage local clear # Clear all
```
### Network
```bash
agent-browser network route <url> # Intercept requests
agent-browser network route <url> --abort # Block requests
agent-browser network route <url> --body '{}' # Mock response
agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests
agent-browser network requests --filter api # Filter requests
```
### Tabs & Windows
```bash
agent-browser tab # List tabs
agent-browser tab new [url] # New tab
agent-browser tab 2 # Switch to tab
agent-browser tab close # Close tab
agent-browser window new # New window
```
### Frames
```bash
agent-browser frame "#iframe" # Switch to iframe
agent-browser frame main # Back to main frame
```
### Dialogs
```bash
agent-browser dialog accept [text] # Accept dialog
agent-browser dialog dismiss # Dismiss dialog
```
### JavaScript
```bash
agent-browser eval "document.title" # Run JavaScript
```
### State management
```bash
agent-browser state save auth.json # Save session state
agent-browser state load auth.json # Load saved state
```
## Example: Form submission
```bash
agent-browser open https://example.com/form
agent-browser snapshot -i
# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3]
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i # Check result
```
## Example: Authentication with saved state
```bash
# Login once
agent-browser open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "username"
agent-browser fill @e2 "password"
agent-browser click @e3
agent-browser wait --url "/dashboard"
agent-browser state save auth.json
# Later sessions: load saved state
agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard
```
## Sessions (parallel browsers)
```bash
agent-browser --session test1 open site-a.com
agent-browser --session test2 open site-b.com
agent-browser session list
```
## JSON output (for parsing)
Add `--json` for machine-readable output:
```bash
agent-browser snapshot -i --json
agent-browser get text @e1 --json
```
## Debugging
```bash
agent-browser open example.com --headed # Show browser window
agent-browser console # View console messages
agent-browser console --clear # Clear console
agent-browser errors # View page errors
agent-browser errors --clear # Clear errors
agent-browser highlight @e1 # Highlight element
agent-browser trace start # Start recording trace
agent-browser trace stop trace.zip # Stop and save trace
agent-browser record start ./debug.webm # Record from current page
agent-browser record stop # Save recording
agent-browser --cdp 9222 snapshot # Connect via CDP
```
## Troubleshooting
- If the command is not found on Linux ARM64, use the full path in the bin folder.
- If an element is not found, use snapshot to find the correct ref.
- If the page is not loaded, add a wait command after navigation.
- Use --headed to see the browser window for debugging.
## Options
- --session <name> uses an isolated session.
- --json provides JSON output.
- --full takes a full page screenshot.
- --headed shows the browser window.
- --timeout sets the command timeout in milliseconds.
- --cdp <port> connects via Chrome DevTools Protocol.
## Notes
- Refs are stable per page load but change on navigation.
- Always snapshot after navigation to get new refs.
- Use fill instead of type for input fields to ensure existing text is cleared.
## Reporting Issues
- Skill issues: Open an issue at https://github.com/TheSethRose/Agent-Browser-CLI
- agent-browser CLI issues: Open an issue at https://github.com/vercel-labs/agent-browser

View File

@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawdhub.com",
"slug": "browser-use",
"installedVersion": "1.0.0",
"installedAt": 1769422454569
}

162
skills/browser-use/SKILL.md Normal file
View File

@ -0,0 +1,162 @@
---
name: browser-use
description: Use Browser Use cloud API to spin up cloud browsers for Clawdbot and run autonomous browser tasks. Primary use is creating browser sessions with profiles (persisted logins/cookies) that Clawdbot can control. Secondary use is running task subagents for fast autonomous browser automation. Docs at docs.browser-use.com and docs.cloud.browser-use.com.
---
# Browser Use
Browser Use provides cloud browsers and autonomous browser automation via API.
**Docs:**
- Open source library: https://docs.browser-use.com
- Cloud API: https://docs.cloud.browser-use.com
## Setup
**API Key** is read from clawdbot config at `skills.entries.browser-use.apiKey`.
If not configured, tell the user:
> To use Browser Use, you need an API key. Get one at https://cloud.browser-use.com (new signups get $10 free credit). Then configure it:
> ```
> clawdbot config set skills.entries.browser-use.apiKey "bu_your_key_here"
> ```
Base URL: `https://api.browser-use.com/api/v2`
All requests need header: `X-Browser-Use-API-Key: <apiKey>`
---
## 1. Browser Sessions (Primary)
Spin up cloud browsers for Clawdbot to control directly. Use profiles to persist logins and cookies.
### Create browser session
```bash
# With profile (recommended - keeps you logged in)
curl -X POST "https://api.browser-use.com/api/v2/browsers" \
-H "X-Browser-Use-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"profileId": "<profile-uuid>", "timeout": 60}'
# Without profile (fresh browser)
curl -X POST "https://api.browser-use.com/api/v2/browsers" \
-H "X-Browser-Use-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"timeout": 60}'
```
**Response:**
```json
{
"id": "session-uuid",
"cdpUrl": "https://<id>.cdp2.browser-use.com",
"liveUrl": "https://...",
"status": "active"
}
```
### Connect Clawdbot to the browser
```bash
gateway config.patch '{"browser":{"profiles":{"browseruse":{"cdpUrl":"<cdpUrl-from-response>"}}}}'
```
Now use the `browser` tool with `profile=browseruse` to control it.
### List/stop browser sessions
```bash
# List active sessions
curl "https://api.browser-use.com/api/v2/browsers" -H "X-Browser-Use-API-Key: $API_KEY"
# Get session status
curl "https://api.browser-use.com/api/v2/browsers/<session-id>" -H "X-Browser-Use-API-Key: $API_KEY"
# Stop session (unused time is refunded)
curl -X PATCH "https://api.browser-use.com/api/v2/browsers/<session-id>" \
-H "X-Browser-Use-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "stopped"}'
```
**Pricing:** $0.06/hour (Pay As You Go) or $0.03/hour (Business). Max 4 hours per session. Billed per minute, refunded for unused time.
---
## 2. Profiles
Profiles persist cookies and login state across browser sessions. Create one, log into your accounts in the browser, and reuse it.
```bash
# List profiles
curl "https://api.browser-use.com/api/v2/profiles" -H "X-Browser-Use-API-Key: $API_KEY"
# Create profile
curl -X POST "https://api.browser-use.com/api/v2/profiles" \
-H "X-Browser-Use-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "My Profile"}'
# Delete profile
curl -X DELETE "https://api.browser-use.com/api/v2/profiles/<profile-id>" \
-H "X-Browser-Use-API-Key: $API_KEY"
```
**Tip:** You can also sync cookies from your local Chrome using the Browser Use Chrome extension.
---
## 3. Tasks (Subagent)
Run autonomous browser tasks - like a subagent that handles browser interactions for you. Give it a prompt and it completes the task.
**Always use `browser-use-llm`** - optimized for browser tasks, 3-5x faster than other models.
```bash
curl -X POST "https://api.browser-use.com/api/v2/tasks" \
-H "X-Browser-Use-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"task": "Go to amazon.com and find the price of the MacBook Air M3",
"llm": "browser-use-llm"
}'
```
### Poll for completion
```bash
curl "https://api.browser-use.com/api/v2/tasks/<task-id>" -H "X-Browser-Use-API-Key: $API_KEY"
```
**Response:**
```json
{
"status": "finished",
"output": "The MacBook Air M3 is priced at $1,099",
"isSuccess": true,
"cost": "0.02"
}
```
Status values: `pending`, `running`, `finished`, `failed`, `stopped`
### Task options
| Option | Description |
|--------|-------------|
| `task` | Your prompt (required) |
| `llm` | Always use `browser-use-llm` |
| `startUrl` | Starting page |
| `maxSteps` | Max actions (default 100) |
| `sessionId` | Reuse existing session |
| `profileId` | Use a profile for auth |
| `flashMode` | Even faster execution |
| `vision` | Visual understanding |
---
## Full API Reference
See [references/api.md](references/api.md) for all endpoints including Sessions, Files, Skills, and Skills Marketplace.