Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions V2-ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,24 +100,34 @@ vibe.quit();

---

## Video Recording
## Video Recording βœ…

**What:** Built-in screen recording of browser sessions.
**Status:** Implemented

**Why deferred:** Adds FFmpeg dependency complexity. Screenshots may be sufficient for debugging.
Record browser sessions as MP4 or WebM video files. Requires FFmpeg to be installed.

**Implementation:**
- Capture screenshots at interval (e.g., 10fps)
- Encode to MP4/WebM via FFmpeg
- Start/stop via `vibium.recording.start` / `vibium.recording.stop` BiDi commands
- JS API: `vibe.startRecording()`, `vibe.stopRecording()`
**CLI:**
```bash
clicker record https://example.com -o recording.mp4 --duration 10 --fps 10
```

**When to build:** When users need video artifacts for:
- Test failure debugging
- Demo generation
- Compliance/audit trails
**JavaScript API:**
```typescript
await vibe.startRecording({ fps: 10, format: 'mp4' });
// ... perform actions ...
const videoPath = await vibe.stopRecording();
```

**Estimated effort:** 1 week
**Python API:**
```python
await vibe.start_recording(fps=10, format='mp4')
# ... perform actions ...
video_path = await vibe.stop_recording()
```

**BiDi Extension Commands:**
- `vibium:startRecording` - Start recording with options (fps, format, outputPath)
- `vibium:stopRecording` - Stop recording and return video file path

---

Expand Down Expand Up @@ -222,7 +232,7 @@ const el = await vibe.find("the blue submit button");
Based on likely user demand:

1. ~~**Python client**~~ βœ… shipped
2. **Video recording** β€” Debugging value, moderate effort
2. ~~**Video recording**~~ βœ… shipped
3. **Network tracing** β€” DevTools parity
4. **Cortex** β€” If agents need persistent memory
5. **Retina** β€” If recording human sessions matters
Expand Down
117 changes: 117 additions & 0 deletions clicker/cmd/clicker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/vibium/clicker/internal/paths"
"github.com/vibium/clicker/internal/process"
"github.com/vibium/clicker/internal/proxy"
"github.com/vibium/clicker/internal/recording"
)

var version = "0.1.0"
Expand Down Expand Up @@ -768,6 +769,122 @@ The server provides browser automation tools:
mcpCmd.Flags().String("screenshot-dir", "", "Directory for saving screenshots (default: ~/Pictures/Vibium, use \"\" to disable)")
rootCmd.AddCommand(mcpCmd)

recordCmd := &cobra.Command{
Use: "record [url]",
Short: "Navigate to a URL and record the browser session as a video",
Long: `Record a browser session as a video file.

This command navigates to a URL, waits for a specified duration while
capturing screenshots, then encodes them into a video using FFmpeg.

Requires FFmpeg to be installed and available in PATH.`,
Example: ` clicker record https://example.com -o recording.mp4
# Records the page for 5 seconds (default)

clicker record https://example.com -o recording.mp4 --duration 10
# Records for 10 seconds

clicker record https://example.com -o recording.webm --format webm --fps 15
# Records as WebM at 15 FPS`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
process.WithCleanup(func() {
url := args[0]
output, _ := cmd.Flags().GetString("output")
duration, _ := cmd.Flags().GetInt("duration")
fps, _ := cmd.Flags().GetInt("fps")
format, _ := cmd.Flags().GetString("format")

// Check FFmpeg availability
if !recording.IsFFmpegAvailable() {
fmt.Fprintf(os.Stderr, "Error: FFmpeg is not installed or not in PATH\n")
fmt.Fprintf(os.Stderr, "Please install FFmpeg: https://ffmpeg.org/download.html\n")
os.Exit(1)
}

fmt.Println("Launching browser...")
launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless})
if err != nil {
fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err)
os.Exit(1)
}
defer waitAndClose(launchResult)

fmt.Println("Connecting to BiDi...")
conn, err := bidi.Connect(launchResult.WebSocketURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Error connecting: %v\n", err)
os.Exit(1)
}
defer conn.Close()

client := bidi.NewClient(conn)

fmt.Printf("Navigating to %s...\n", url)
_, err = client.Navigate("", url)
if err != nil {
fmt.Fprintf(os.Stderr, "Error navigating: %v\n", err)
os.Exit(1)
}

doWaitOpen()

// Create screenshot function
screenshotFn := func() (string, error) {
// Simple fix: Focus + scroll to top before screenshot
_, err := client.Evaluate("", `
window.focus();
window.scrollTo(0, 0);
document.body.style.overflow = 'visible';
`)
if err != nil {
return "", err
}

data, err := client.CaptureScreenshot("")
if err != nil {
return "", err
}

fmt.Printf("[screenshot] Captured %d bytes\n", len(data))
return data, nil
}



// Create recorder
recorder := recording.New(screenshotFn, recording.Options{
FPS: fps,
Format: format,
OutputPath: output,
})

fmt.Printf("Starting recording (duration: %ds, fps: %d, format: %s)...\n", duration, fps, format)
if err := recorder.Start(); err != nil {
fmt.Fprintf(os.Stderr, "Error starting recording: %v\n", err)
os.Exit(1)
}

// Record for specified duration
time.Sleep(time.Duration(duration) * time.Second)

fmt.Println("Stopping recording and encoding video...")
outputPath, err := recorder.Stop()
if err != nil {
fmt.Fprintf(os.Stderr, "Error stopping recording: %v\n", err)
os.Exit(1)
}

fmt.Printf("Recording saved to: %s\n", outputPath)
})
},
}
recordCmd.Flags().StringP("output", "o", "recording.mp4", "Output file path")
recordCmd.Flags().IntP("duration", "d", 5, "Recording duration in seconds")
recordCmd.Flags().Int("fps", 10, "Frames per second")
recordCmd.Flags().String("format", "mp4", "Output format: mp4 or webm")
rootCmd.AddCommand(recordCmd)

rootCmd.Version = version
rootCmd.SetVersionTemplate("Clicker v{{.Version}}\n")

Expand Down
127 changes: 127 additions & 0 deletions clicker/internal/proxy/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/vibium/clicker/internal/bidi"
"github.com/vibium/clicker/internal/browser"
"github.com/vibium/clicker/internal/recording"
)

// Default timeout for actionability checks
Expand All @@ -27,6 +28,9 @@ type BrowserSession struct {
internalCmds map[int]chan json.RawMessage // id -> response channel
internalCmdsMu sync.Mutex
nextInternalID int

// Video recording
recorder *recording.Recorder
}

// BiDi command structure for parsing incoming messages
Expand Down Expand Up @@ -150,6 +154,12 @@ func (r *Router) OnClientMessage(client *ClientConn, msg string) {
case "vibium:find":
r.handleVibiumFind(session, cmd)
return
case "vibium:startRecording":
r.handleVibiumStartRecording(session, cmd)
return
case "vibium:stopRecording":
r.handleVibiumStopRecording(session, cmd)
return
}

// Forward standard BiDi commands to browser
Expand Down Expand Up @@ -558,6 +568,114 @@ func (r *Router) sendInternalCommand(session *BrowserSession, method string, par
}
}

// handleVibiumStartRecording handles the vibium:startRecording command.
func (r *Router) handleVibiumStartRecording(session *BrowserSession, cmd bidiCommand) {
// Check if FFmpeg is available
if !recording.IsFFmpegAvailable() {
r.sendError(session, cmd.ID, fmt.Errorf("FFmpeg is not installed or not in PATH"))
return
}

session.mu.Lock()
if session.recorder != nil {
session.mu.Unlock()
r.sendError(session, cmd.ID, fmt.Errorf("recording already in progress"))
return
}
session.mu.Unlock()

// Parse options
fps := 10
if fpsVal, ok := cmd.Params["fps"].(float64); ok && fpsVal > 0 {
fps = int(fpsVal)
}

format := "mp4"
if formatVal, ok := cmd.Params["format"].(string); ok && (formatVal == "mp4" || formatVal == "webm") {
format = formatVal
}

outputPath, _ := cmd.Params["outputPath"].(string)

// Create screenshot function that captures from the browser
screenshotFn := func() (string, error) {
ctx, err := r.getContext(session)
if err != nil {
return "", err
}

params := map[string]interface{}{
"context": ctx,
}

resp, err := r.sendInternalCommand(session, "browsingContext.captureScreenshot", params)
if err != nil {
return "", err
}

var result struct {
Result struct {
Data string `json:"data"`
} `json:"result"`
}
if err := json.Unmarshal(resp, &result); err != nil {
return "", fmt.Errorf("failed to parse screenshot response: %w", err)
}

return result.Result.Data, nil
}

// Create and start recorder
recorder := recording.New(screenshotFn, recording.Options{
FPS: fps,
OutputPath: outputPath,
Format: format,
})

if err := recorder.Start(); err != nil {
r.sendError(session, cmd.ID, err)
return
}

session.mu.Lock()
session.recorder = recorder
session.mu.Unlock()

fmt.Printf("[router] Started recording for client %d (fps=%d, format=%s)\n", session.Client.ID, fps, format)

r.sendSuccess(session, cmd.ID, map[string]interface{}{
"started": true,
"fps": fps,
"format": format,
})
}

// handleVibiumStopRecording handles the vibium:stopRecording command.
func (r *Router) handleVibiumStopRecording(session *BrowserSession, cmd bidiCommand) {
session.mu.Lock()
recorder := session.recorder
session.recorder = nil
session.mu.Unlock()

if recorder == nil {
r.sendError(session, cmd.ID, fmt.Errorf("no recording in progress"))
return
}

outputPath, err := recorder.Stop()
if err != nil {
r.sendError(session, cmd.ID, err)
return
}

fmt.Printf("[router] Stopped recording for client %d, saved to: %s\n", session.Client.ID, outputPath)

r.sendSuccess(session, cmd.ID, map[string]interface{}{
"stopped": true,
"outputPath": outputPath,
})
}

// closeSession closes a browser session and cleans up resources.
func (r *Router) closeSession(session *BrowserSession) {
session.mu.Lock()
Expand All @@ -566,10 +684,19 @@ func (r *Router) closeSession(session *BrowserSession) {
return
}
session.closed = true
recorder := session.recorder
session.recorder = nil
session.mu.Unlock()

fmt.Printf("[router] Closing browser session for client %d\n", session.Client.ID)

// Stop any active recording
if recorder != nil {
if outputPath, err := recorder.Stop(); err == nil {
fmt.Printf("[router] Recording saved to: %s\n", outputPath)
}
}

// Signal the routing goroutine to stop
close(session.stopChan)

Expand Down
Loading