Add PageIndex-style memory system + Genre Universe documentation
This commit is contained in:
parent
4c8c82f6ae
commit
a4f4e08ee4
@ -8,6 +8,14 @@
|
||||
"frontend-design": {
|
||||
"version": "1.0.0",
|
||||
"installedAt": 1769325240066
|
||||
},
|
||||
"browser-use": {
|
||||
"version": "1.0.0",
|
||||
"installedAt": 1769422454571
|
||||
},
|
||||
"agent-browser": {
|
||||
"version": "0.2.0",
|
||||
"installedAt": 1769423250734
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -26,3 +26,4 @@ npm-debug.log*
|
||||
package-lock.json
|
||||
reonomy-scraper.log
|
||||
Thumbs.db
|
||||
pageindex-framework/
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@ -39,4 +39,12 @@ This keeps identity, memory, and progress backed up. Consider making it private
|
||||
- Add your preferred style, rules, and "memory" here.
|
||||
|
||||
## 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
12
USER.md
@ -28,6 +28,7 @@
|
||||
- **LSAT edtech company ("The Burton Method")**
|
||||
- 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).
|
||||
- **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")**
|
||||
- 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.
|
||||
- **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 you’ve shared)
|
||||
|
||||
- **Jake** — a builder/operator who runs multiple tracks at once: edtech, real estate/CRM tooling, CFO-style business strategy, and creative projects.
|
||||
|
||||
176
genre-viz/analyzer/analyze_album.py
Normal file
176
genre-viz/analyzer/analyze_album.py
Normal 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))
|
||||
157
genre-viz/analyzer/analyze_track.py
Normal file
157
genre-viz/analyzer/analyze_track.py
Normal 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
597
genre-viz/index.html
Normal 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>
|
||||
5
genre-viz/start-genre-universe.sh
Executable file
5
genre-viz/start-genre-universe.sh
Executable 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
10
genre-viz/vercel.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 2,
|
||||
"name": "genre-universe",
|
||||
"builds": [
|
||||
{ "src": "index.html", "use": "@vercel/static" }
|
||||
],
|
||||
"routes": [
|
||||
{ "src": "/(.*)", "dest": "/index.html" }
|
||||
]
|
||||
}
|
||||
90
memory/2026-01-26-genre-universe.md
Normal file
90
memory/2026-01-26-genre-universe.md
Normal 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
79
memory/2026-01-26.md
Normal 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
185
memory/INDEX.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
90
memory/burton-method-research-intel.md
Normal file
90
memory/burton-method-research-intel.md
Normal 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
193
reonomy-auth.json
Normal file
File diff suppressed because one or more lines are too long
322
reonomy-demo-video/STORYBOARD.md
Normal file
322
reonomy-demo-video/STORYBOARD.md
Normal 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:** 0–120 (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:** 110–260 (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:** 250–400 (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:** 390–530 (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:** 520–660 (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:** 650–830 (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:** 820–1100 (9.3 seconds)
|
||||
**Source:** Generated (React components, not screenshot)
|
||||
|
||||
### Visual Content
|
||||
|
||||
#### Phase 1: Title (frames 820–870)
|
||||
- "🎉 Contacts Extracted" title
|
||||
- Spring animation entrance
|
||||
|
||||
#### Phase 2: Contact Lists (frames 870–980)
|
||||
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 980–1100)
|
||||
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 (2–6):
|
||||
```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
|
||||
20
reonomy-demo-video/package.json
Normal file
20
reonomy-demo-video/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
4
reonomy-demo-video/remotion.config.ts
Normal file
4
reonomy-demo-video/remotion.config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("jpeg");
|
||||
Config.setOverwriteOutput(true);
|
||||
576
reonomy-demo-video/src/ReonomyCanvasDemo.tsx
Normal file
576
reonomy-demo-video/src/ReonomyCanvasDemo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
635
reonomy-demo-video/src/ReonomyDemo.tsx
Normal file
635
reonomy-demo-video/src/ReonomyDemo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
819
reonomy-demo-video/src/ReonomyDemoExciting.tsx
Normal file
819
reonomy-demo-video/src/ReonomyDemoExciting.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
35
reonomy-demo-video/src/Root.tsx
Normal file
35
reonomy-demo-video/src/Root.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
4
reonomy-demo-video/src/index.ts
Normal file
4
reonomy-demo-video/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
17
reonomy-demo-video/tsconfig.json
Normal file
17
reonomy-demo-video/tsconfig.json
Normal 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
124
reonomy-demo.sh
Executable 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
6
reonomy-leads-v13.json
Normal 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
125
reonomy-quick-demo.js
Executable 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
442
reonomy-scraper-v13.js
Executable 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);
|
||||
});
|
||||
7
skills/agent-browser/.clawdhub/origin.json
Normal file
7
skills/agent-browser/.clawdhub/origin.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"registry": "https://clawdhub.com",
|
||||
"slug": "agent-browser",
|
||||
"installedVersion": "0.2.0",
|
||||
"installedAt": 1769423250734
|
||||
}
|
||||
63
skills/agent-browser/CONTRIBUTING.md
Normal file
63
skills/agent-browser/CONTRIBUTING.md
Normal 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
|
||||
328
skills/agent-browser/SKILL.md
Normal file
328
skills/agent-browser/SKILL.md
Normal 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
|
||||
7
skills/browser-use/.clawdhub/origin.json
Normal file
7
skills/browser-use/.clawdhub/origin.json
Normal 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
162
skills/browser-use/SKILL.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user