From d0fe46980c3b76d578fb40540ab41a8a90b71012 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 05:38:23 +0000 Subject: [PATCH 1/4] Add video recording commands (start-video, stop-video) Records video of Chrome sessions by capturing CDP screencast frames during each rodney command execution. No background process needed - frames are captured automatically while commands run and accumulated across invocations. - start-video: enables recording flag in state, creates frames dir - stop-video: assembles frames into MP4 via ffmpeg (with graceful fallback), cleans up frames dir - Screencast capture hooks into withPage() and cmdOpen() transparently - Variable framerate video assembly using frame timestamps - 9 new tests covering start/stop/capture/accumulation/assembly https://claude.ai/code/session_018GfJUXfG9azF393kRTQPss --- help.txt | 4 + main.go | 320 +++++++++++++++++++++++++++++++++++++++++++++- main_test.go | 351 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 669 insertions(+), 6 deletions(-) diff --git a/help.txt b/help.txt index b9d2cbc..d438c8b 100644 --- a/help.txt +++ b/help.txt @@ -40,6 +40,10 @@ Screenshots: rodney screenshot [-w N] [-h N] [file] Take page screenshot rodney screenshot-el [f] Screenshot an element +Video recording: + rodney start-video Start recording video + rodney stop-video [file] Stop recording and save MP4 + Tabs: rodney pages List all pages/tabs rodney page Switch to page by index diff --git a/main.go b/main.go index 325a486..6f1f3fa 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "syscall" "time" @@ -30,15 +31,23 @@ var version = "dev" // State persisted between CLI invocations type State struct { - DebugURL string `json:"debug_url"` - ChromePID int `json:"chrome_pid"` - ActivePage int `json:"active_page"` // index into pages list - DataDir string `json:"data_dir"` - ProxyPID int `json:"proxy_pid,omitempty"` // PID of auth proxy helper - ProxyPort int `json:"proxy_port,omitempty"` // local port of auth proxy + DebugURL string `json:"debug_url"` + ChromePID int `json:"chrome_pid"` + ActivePage int `json:"active_page"` // index into pages list + DataDir string `json:"data_dir"` + ProxyPID int `json:"proxy_pid,omitempty"` // PID of auth proxy helper + ProxyPort int `json:"proxy_port,omitempty"` // local port of auth proxy + VideoRecording bool `json:"video_recording,omitempty"` + VideoDir string `json:"video_dir,omitempty"` } +// stateDirOverride allows tests to redirect state to a temp dir +var stateDirOverride string + func stateDir() string { + if stateDirOverride != "" { + return stateDirOverride + } home, _ := os.UserHomeDir() return filepath.Join(home, ".rodney") } @@ -109,6 +118,12 @@ func fatal(format string, args ...interface{}) { } func main() { + defer func() { + if videoCleanup != nil { + videoCleanup() + } + }() + if len(os.Args) < 2 { printUsage() os.Exit(1) @@ -181,6 +196,10 @@ func main() { cmdScreenshot(args) case "screenshot-el": cmdScreenshotEl(args) + case "start-video": + cmdStartVideo(args) + case "stop-video": + cmdStopVideo(args) case "pages": cmdPages(args) case "page": @@ -222,6 +241,20 @@ func init() { } } +// videoCleanup is called at process exit to flush any in-progress video capture. +var videoCleanup func() + +// maybeStartVideoCapture checks state and starts screencast if recording is active. +// Returns a cleanup function (always safe to call, even if recording is off). +func maybeStartVideoCapture(page *rod.Page) func() { + s, err := loadState() + if err != nil || !s.VideoRecording || s.VideoDir == "" { + return func() {} + } + stop := startVideoCapture(page, s.VideoDir) + return func() { stop() } +} + // withPage loads state, connects, and returns the active page. // Caller should NOT close the browser (we just disconnect). func withPage() (*State, *rod.Browser, *rod.Page) { @@ -239,6 +272,8 @@ func withPage() (*State, *rod.Browser, *rod.Page) { } // Apply default timeout so element queries don't hang forever page = page.Timeout(defaultTimeout) + // Start video capture if recording is active + videoCleanup = maybeStartVideoCapture(page) return s, browser, page } @@ -348,6 +383,10 @@ func cmdStop(args []string) { proc.Signal(syscall.SIGTERM) } } + // Clean up any active video recording + if s.VideoRecording && s.VideoDir != "" { + os.RemoveAll(s.VideoDir) + } removeState() fmt.Println("Chrome stopped") } @@ -374,6 +413,10 @@ func cmdStatus(args []string) { fmt.Printf("Current: %s - %s\n", info.Title, info.URL) } } + if s.VideoRecording { + frames := countFrames(s.VideoDir) + fmt.Printf("Recording video (%d frames captured)\n", frames) + } } func cmdOpen(args []string) { @@ -411,6 +454,8 @@ func cmdOpen(args []string) { fatal("navigation failed: %v", err) } } + // Start video capture if recording is active + videoCleanup = maybeStartVideoCapture(page) page.MustWaitLoad() info, _ := page.Info() if info != nil { @@ -826,6 +871,269 @@ func cmdScreenshotEl(args []string) { fmt.Printf("Saved %s (%d bytes)\n", file, len(data)) } +// --- Video recording --- + +// startVideo enables video recording: sets state flag and creates frames dir. +func startVideo() error { + s, err := loadState() + if err != nil { + return err + } + if s.VideoRecording { + return fmt.Errorf("video recording already in progress") + } + s.VideoDir = filepath.Join(stateDir(), "video-frames") + if err := os.MkdirAll(s.VideoDir, 0755); err != nil { + return fmt.Errorf("failed to create video dir: %w", err) + } + s.VideoRecording = true + return saveState(s) +} + +func cmdStartVideo(args []string) { + if err := startVideo(); err != nil { + fatal("%v", err) + } + fmt.Println("Video recording started") +} + +// VideoResult holds the result of stop-video. +type VideoResult struct { + FrameCount int + OutputFile string // empty if ffmpeg not available +} + +// stopVideo stops recording, optionally assembles video, clears state. +func stopVideo(outputFile string) (*VideoResult, error) { + s, err := loadState() + if err != nil { + return nil, err + } + if !s.VideoRecording { + return nil, fmt.Errorf("video recording is not active (run 'rodney start-video' first)") + } + + framesDir := s.VideoDir + frameCount := countFrames(framesDir) + + result := &VideoResult{FrameCount: frameCount} + + // Try to assemble video with ffmpeg if we have frames + if frameCount > 0 && outputFile != "" { + if assembled, err := assembleVideo(framesDir, outputFile); err == nil { + result.OutputFile = assembled + } + // Non-fatal if ffmpeg fails + } + + // Clean up: remove frames dir + os.RemoveAll(framesDir) + + // Clear state + s.VideoRecording = false + s.VideoDir = "" + saveState(s) + + return result, nil +} + +func cmdStopVideo(args []string) { + outputFile := "" + if len(args) > 0 { + outputFile = args[0] + } else { + outputFile = nextAvailableFile("recording", ".mp4") + } + + result, err := stopVideo(outputFile) + if err != nil { + fatal("%v", err) + } + + if result.OutputFile != "" { + fmt.Printf("Saved %s (%d frames)\n", result.OutputFile, result.FrameCount) + } else if result.FrameCount > 0 { + fmt.Printf("Captured %d frames (ffmpeg not available for MP4 assembly)\n", result.FrameCount) + } else { + fmt.Println("No frames captured") + } +} + +// assembleVideo uses ffmpeg to combine frames into an MP4 video. +// Returns the output file path on success. +func assembleVideo(framesDir, outputFile string) (string, error) { + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + return "", fmt.Errorf("ffmpeg not found: %w", err) + } + + // Read metadata for variable frame timing + metaPath := filepath.Join(framesDir, "meta.jsonl") + metaData, err := os.ReadFile(metaPath) + if err != nil { + // Fallback: use constant framerate + return assembleConstantFPS(ffmpeg, framesDir, outputFile) + } + + return assembleVariableFPS(ffmpeg, framesDir, outputFile, metaData) +} + +// assembleConstantFPS assembles frames at a fixed 10fps. +func assembleConstantFPS(ffmpeg, framesDir, outputFile string) (string, error) { + cmd := exec.Command(ffmpeg, "-y", + "-framerate", "10", + "-i", filepath.Join(framesDir, "frame_%06d.jpeg"), + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-preset", "fast", + outputFile, + ) + if output, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("ffmpeg failed: %v: %s", err, output) + } + return outputFile, nil +} + +// assembleVariableFPS uses ffmpeg concat demuxer with per-frame durations from metadata. +func assembleVariableFPS(ffmpeg, framesDir, outputFile string, metaData []byte) (string, error) { + type frameMeta struct { + Idx int `json:"idx"` + Ts float64 `json:"ts"` + } + + lines := strings.Split(strings.TrimSpace(string(metaData)), "\n") + var frames []frameMeta + for _, line := range lines { + var fm frameMeta + if err := json.Unmarshal([]byte(line), &fm); err == nil { + frames = append(frames, fm) + } + } + + if len(frames) < 2 { + return assembleConstantFPS(ffmpeg, framesDir, outputFile) + } + + // Write concat demuxer file + concatPath := filepath.Join(framesDir, "concat.txt") + f, err := os.Create(concatPath) + if err != nil { + return assembleConstantFPS(ffmpeg, framesDir, outputFile) + } + for i, fm := range frames { + framePath := filepath.Join(framesDir, fmt.Sprintf("frame_%06d.jpeg", fm.Idx)) + fmt.Fprintf(f, "file '%s'\n", framePath) + if i < len(frames)-1 { + dur := frames[i+1].Ts - fm.Ts + if dur <= 0 { + dur = 0.033 + } + fmt.Fprintf(f, "duration %.6f\n", dur) + } else { + fmt.Fprintf(f, "duration 0.033\n") + } + } + f.Close() + + cmd := exec.Command(ffmpeg, "-y", + "-f", "concat", "-safe", "0", + "-i", concatPath, + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-preset", "fast", + outputFile, + ) + if output, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("ffmpeg failed: %v: %s", err, output) + } + return outputFile, nil +} + +// startVideoCapture begins CDP screencast on the given page, writing JPEG frames +// and metadata to framesDir. It returns a stop function that stops the screencast +// and returns the number of frames captured in this session. +func startVideoCapture(page *rod.Page, framesDir string) (stop func() int) { + os.MkdirAll(framesDir, 0755) + + // Count existing frames to continue numbering + startIdx := countFrames(framesDir) + + var mu sync.Mutex + captured := 0 + + // Open metadata file for appending + metaPath := filepath.Join(framesDir, "meta.jsonl") + metaFile, err := os.OpenFile(metaPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + // Non-fatal: we can still capture frames without metadata + metaFile = nil + } + + done := make(chan struct{}) + + go page.EachEvent(func(e *proto.PageScreencastFrame) bool { + select { + case <-done: + return true + default: + } + + mu.Lock() + idx := startIdx + captured + captured++ + mu.Unlock() + + framePath := filepath.Join(framesDir, fmt.Sprintf("frame_%06d.jpeg", idx)) + os.WriteFile(framePath, e.Data, 0644) + + if metaFile != nil && e.Metadata != nil { + line := fmt.Sprintf(`{"idx":%d,"ts":%.6f}`+"\n", idx, float64(e.Metadata.Timestamp)) + mu.Lock() + metaFile.WriteString(line) + mu.Unlock() + } + + proto.PageScreencastFrameAck{SessionID: e.SessionID}.Call(page) + return false + })() + + quality := 80 + everyNth := 1 + proto.PageStartScreencast{ + Format: proto.PageStartScreencastFormatJpeg, + Quality: &quality, + EveryNthFrame: &everyNth, + }.Call(page) + + return func() int { + proto.PageStopScreencast{}.Call(page) + close(done) + // Give in-flight frames a moment to flush + time.Sleep(50 * time.Millisecond) + if metaFile != nil { + metaFile.Close() + } + mu.Lock() + defer mu.Unlock() + return captured + } +} + +// countFrames counts existing frame_NNNNNN.jpeg files in a directory. +func countFrames(dir string) int { + entries, err := os.ReadDir(dir) + if err != nil { + return 0 + } + n := 0 + for _, e := range entries { + if strings.HasPrefix(e.Name(), "frame_") && strings.HasSuffix(e.Name(), ".jpeg") { + n++ + } + } + return n +} + func cmdPages(args []string) { s, err := loadState() if err != nil { diff --git a/main_test.go b/main_test.go index b80b5c2..e835794 100644 --- a/main_test.go +++ b/main_test.go @@ -2,9 +2,12 @@ package main import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" + "os/exec" + "path/filepath" "strings" "testing" "time" @@ -42,6 +45,7 @@ func TestMain(m *testing.M) { mux := http.NewServeMux() mux.HandleFunc("/", handleIndex) mux.HandleFunc("/form", handleForm) + mux.HandleFunc("/animated", handleAnimated) mux.HandleFunc("/empty", handleEmpty) server := httptest.NewServer(mux) @@ -98,6 +102,19 @@ func handleForm(w http.ResponseWriter, r *http.Request) { `)) } +func handleAnimated(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(` + +
+
0
+ +`)) +} + func handleEmpty(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(` @@ -412,3 +429,337 @@ func TestAXNode_SelectorNotFound(t *testing.T) { t.Error("expected error for nonexistent selector, got nil") } } + +// ===================== +// Video recording tests +// ===================== + +// testStateDir overrides the state dir for test isolation +func withTestStateDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + origStateDir := stateDirOverride + stateDirOverride = dir + t.Cleanup(func() { stateDirOverride = origStateDir }) + return dir +} + +func TestStartVideo_SetsStateFlag(t *testing.T) { + dir := withTestStateDir(t) + + // Write a fake state file (simulating a running browser) + s := &State{DebugURL: "ws://fake", ChromePID: 99999} + if err := saveState(s); err != nil { + t.Fatal(err) + } + + // Call startVideo + if err := startVideo(); err != nil { + t.Fatalf("startVideo failed: %v", err) + } + + // State should now have VideoRecording=true and a VideoDir + s2, err := loadState() + if err != nil { + t.Fatal(err) + } + if !s2.VideoRecording { + t.Error("expected VideoRecording=true") + } + if s2.VideoDir == "" { + t.Error("expected VideoDir to be set") + } + + // VideoDir should exist on disk + info, err := os.Stat(s2.VideoDir) + if err != nil { + t.Fatalf("VideoDir does not exist: %v", err) + } + if !info.IsDir() { + t.Error("VideoDir is not a directory") + } + + // VideoDir should be under our state dir + if !strings.HasPrefix(s2.VideoDir, dir) { + t.Errorf("VideoDir %q should be under state dir %q", s2.VideoDir, dir) + } +} + +func TestStartVideo_ErrorsIfAlreadyRecording(t *testing.T) { + withTestStateDir(t) + + s := &State{DebugURL: "ws://fake", ChromePID: 99999, VideoRecording: true, VideoDir: "/tmp/fake"} + saveState(s) + + err := startVideo() + if err == nil { + t.Error("expected error when already recording") + } +} + +func TestVideoCapture_RecordsFramesDuringPageUse(t *testing.T) { + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + + // Navigate to a page with animation (generates continuous frames) + page := navigateTo(t, "/animated") + + // Start video capture on this page + stop := startVideoCapture(page, framesDir) + + // Give screencast time to emit some frames + time.Sleep(1 * time.Second) + + // Stop capture and get frame count + n := stop() + + if n == 0 { + t.Fatal("expected at least 1 frame captured, got 0") + } + + // Check frames exist on disk + entries, err := os.ReadDir(framesDir) + if err != nil { + t.Fatal(err) + } + + jpegCount := 0 + for _, e := range entries { + if filepath.Ext(e.Name()) == ".jpeg" { + jpegCount++ + } + } + if jpegCount == 0 { + t.Fatal("no JPEG files found in frames dir") + } + if jpegCount != n { + t.Errorf("frame count mismatch: stop() returned %d but found %d files", n, jpegCount) + } + + // Check metadata file exists + metaPath := filepath.Join(framesDir, "meta.jsonl") + metaData, err := os.ReadFile(metaPath) + if err != nil { + t.Fatalf("meta.jsonl not found: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(metaData)), "\n") + if len(lines) != n { + t.Errorf("meta.jsonl has %d lines, expected %d", len(lines), n) + } + + // First frame should be valid JPEG + firstFrame, err := os.ReadFile(filepath.Join(framesDir, "frame_000000.jpeg")) + if err != nil { + t.Fatalf("could not read first frame: %v", err) + } + if len(firstFrame) < 3 || firstFrame[0] != 0xFF || firstFrame[1] != 0xD8 { + t.Error("first frame is not a valid JPEG (missing magic bytes)") + } +} + +func TestVideoCapture_AccumulatesAcrossCalls(t *testing.T) { + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + + page := navigateTo(t, "/animated") + + // First capture session + stop1 := startVideoCapture(page, framesDir) + time.Sleep(500 * time.Millisecond) + n1 := stop1() + + // Second capture session (should continue numbering) + stop2 := startVideoCapture(page, framesDir) + time.Sleep(500 * time.Millisecond) + n2 := stop2() + + if n1 == 0 || n2 == 0 { + t.Fatalf("expected frames from both sessions, got %d and %d", n1, n2) + } + + // Total files should be n1 + n2 + entries, _ := os.ReadDir(framesDir) + jpegCount := 0 + for _, e := range entries { + if filepath.Ext(e.Name()) == ".jpeg" { + jpegCount++ + } + } + if jpegCount != n1+n2 { + t.Errorf("expected %d total frames, got %d", n1+n2, jpegCount) + } +} + +func TestStopVideo_ClearsStateAndReturnsFrameCount(t *testing.T) { + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + os.MkdirAll(framesDir, 0755) + + // Simulate some captured frames + metadata + for i := 0; i < 5; i++ { + // Write minimal JPEG files (just magic bytes for test) + os.WriteFile(filepath.Join(framesDir, fmt.Sprintf("frame_%06d.jpeg", i)), []byte{0xFF, 0xD8, 0xFF}, 0644) + } + metaLines := "" + for i := 0; i < 5; i++ { + metaLines += fmt.Sprintf(`{"idx":%d,"ts":%f}`+"\n", i, float64(1000+i)*0.016) + } + os.WriteFile(filepath.Join(framesDir, "meta.jsonl"), []byte(metaLines), 0644) + + s := &State{DebugURL: "ws://fake", ChromePID: 99999, VideoRecording: true, VideoDir: framesDir} + saveState(s) + + result, err := stopVideo("") + if err != nil { + t.Fatalf("stopVideo failed: %v", err) + } + + if result.FrameCount != 5 { + t.Errorf("expected 5 frames, got %d", result.FrameCount) + } + + // State should be cleared + s2, err := loadState() + if err != nil { + t.Fatal(err) + } + if s2.VideoRecording { + t.Error("expected VideoRecording=false after stop") + } + if s2.VideoDir != "" { + t.Error("expected VideoDir to be cleared after stop") + } +} + +func TestStopVideo_ErrorsIfNotRecording(t *testing.T) { + withTestStateDir(t) + + s := &State{DebugURL: "ws://fake", ChromePID: 99999} + saveState(s) + + _, err := stopVideo("") + if err == nil { + t.Error("expected error when not recording") + } +} + +func TestAssembleVideo_ProducesMP4(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not available") + } + + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + + // Capture real frames from an animated page + page := navigateTo(t, "/animated") + stop := startVideoCapture(page, framesDir) + time.Sleep(1 * time.Second) + n := stop() + + if n < 2 { + t.Fatalf("need at least 2 frames for video, got %d", n) + } + + outputFile := filepath.Join(dir, "test-output.mp4") + result, err := assembleVideo(framesDir, outputFile) + if err != nil { + t.Fatalf("assembleVideo failed: %v", err) + } + + info, err := os.Stat(result) + if err != nil { + t.Fatalf("output file not created: %v", err) + } + if info.Size() == 0 { + t.Error("output file is empty") + } + + // Verify it's a real MP4 (starts with ftyp box or moov) + header := make([]byte, 12) + f, _ := os.Open(result) + f.Read(header) + f.Close() + // MP4 files have "ftyp" at offset 4 + if string(header[4:8]) != "ftyp" { + t.Errorf("output doesn't look like MP4, header: %x", header[:12]) + } +} + +func TestVideoCapture_WithPageIntegration(t *testing.T) { + // Test that withPageVideoCapture starts/stops screencast when recording is on + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + os.MkdirAll(framesDir, 0755) + + page := navigateTo(t, "/animated") + + // Simulate: recording is active + s := &State{ + DebugURL: "ws://fake", + ChromePID: 99999, + VideoRecording: true, + VideoDir: framesDir, + } + saveState(s) + + // Call the integration hook — same thing withPage() calls + cleanup := maybeStartVideoCapture(page) + + time.Sleep(1 * time.Second) + + // Call cleanup (same as what runs via defer in main) + cleanup() + + // Frames should have been captured + frameCount := countFrames(framesDir) + if frameCount == 0 { + t.Fatal("expected frames to be captured via maybeStartVideoCapture") + } +} + +func TestStopVideo_ProducesMP4WhenFfmpegAvailable(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not available") + } + + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + + // Capture real frames from animated page + page := navigateTo(t, "/animated") + stop := startVideoCapture(page, framesDir) + time.Sleep(1 * time.Second) + stop() + + // Set up state as if start-video had run + s := &State{DebugURL: "ws://fake", ChromePID: 99999, VideoRecording: true, VideoDir: framesDir} + saveState(s) + + outputFile := filepath.Join(dir, "result.mp4") + result, err := stopVideo(outputFile) + if err != nil { + t.Fatalf("stopVideo failed: %v", err) + } + + if result.OutputFile == "" { + t.Error("expected OutputFile to be set") + } + if result.FrameCount == 0 { + t.Error("expected non-zero frame count") + } + + // MP4 file should exist + info, err := os.Stat(result.OutputFile) + if err != nil { + t.Fatalf("MP4 file not created: %v", err) + } + if info.Size() == 0 { + t.Error("MP4 file is empty") + } + + // Frames dir should be cleaned up + if _, err := os.Stat(framesDir); !os.IsNotExist(err) { + t.Error("expected frames dir to be removed after stop-video") + } +} From ca222e71360c714c737d6ae170c47f3762092822 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 06:01:39 +0000 Subject: [PATCH 2/4] Add animated GIF output with frame deduplication GIF is now the default output format for stop-video (no ffmpeg needed). Uses Go stdlib image/gif with Plan9 palette and Floyd-Steinberg dithering. Frame deduplication: consecutive identical frames (after quantization to 256 colors) are merged into a single GIF frame with extended duration. This significantly reduces file size for sessions with static page moments between commands. Format is auto-detected from file extension: - .gif: pure Go encoding with deduplication (default) - .mp4: ffmpeg assembly with variable framerate (requires ffmpeg) 4 new tests covering GIF encoding, dedup verification, stdlib decode validation, and extension-based format detection. https://claude.ai/code/session_018GfJUXfG9azF393kRTQPss --- help.txt | 2 +- main.go | 172 ++++++++++++++++++++++++++++++++++++++++++++++--- main_test.go | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 341 insertions(+), 10 deletions(-) diff --git a/help.txt b/help.txt index d438c8b..d031337 100644 --- a/help.txt +++ b/help.txt @@ -42,7 +42,7 @@ Screenshots: Video recording: rodney start-video Start recording video - rodney stop-video [file] Stop recording and save MP4 + rodney stop-video [file] Stop and save (.gif default, .mp4 needs ffmpeg) Tabs: rodney pages List all pages/tabs diff --git a/main.go b/main.go index 6f1f3fa..8ca355d 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,16 @@ package main import ( + "bytes" _ "embed" "encoding/base64" "encoding/json" "fmt" + "image" + "image/color/palette" + "image/draw" + "image/gif" + _ "image/jpeg" "io" "net" "net/http" @@ -13,6 +19,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -899,8 +906,9 @@ func cmdStartVideo(args []string) { // VideoResult holds the result of stop-video. type VideoResult struct { - FrameCount int - OutputFile string // empty if ffmpeg not available + FrameCount int + UniqueFrames int // for GIF: frames after deduplication + OutputFile string // empty if assembly failed } // stopVideo stops recording, optionally assembles video, clears state. @@ -918,12 +926,19 @@ func stopVideo(outputFile string) (*VideoResult, error) { result := &VideoResult{FrameCount: frameCount} - // Try to assemble video with ffmpeg if we have frames + // Assemble output if we have frames if frameCount > 0 && outputFile != "" { - if assembled, err := assembleVideo(framesDir, outputFile); err == nil { - result.OutputFile = assembled + if strings.HasSuffix(strings.ToLower(outputFile), ".gif") { + if gifResult, err := assembleGIF(framesDir, outputFile); err == nil { + result.OutputFile = gifResult.OutputFile + result.UniqueFrames = gifResult.UniqueFrames + } + } else { + if assembled, err := assembleVideo(framesDir, outputFile); err == nil { + result.OutputFile = assembled + } + // Non-fatal if ffmpeg fails } - // Non-fatal if ffmpeg fails } // Clean up: remove frames dir @@ -942,7 +957,7 @@ func cmdStopVideo(args []string) { if len(args) > 0 { outputFile = args[0] } else { - outputFile = nextAvailableFile("recording", ".mp4") + outputFile = nextAvailableFile("recording", ".gif") } result, err := stopVideo(outputFile) @@ -951,9 +966,13 @@ func cmdStopVideo(args []string) { } if result.OutputFile != "" { - fmt.Printf("Saved %s (%d frames)\n", result.OutputFile, result.FrameCount) + if result.UniqueFrames > 0 && result.UniqueFrames < result.FrameCount { + fmt.Printf("Saved %s (%d frames, %d unique)\n", result.OutputFile, result.FrameCount, result.UniqueFrames) + } else { + fmt.Printf("Saved %s (%d frames)\n", result.OutputFile, result.FrameCount) + } } else if result.FrameCount > 0 { - fmt.Printf("Captured %d frames (ffmpeg not available for MP4 assembly)\n", result.FrameCount) + fmt.Printf("Captured %d frames but assembly failed\n", result.FrameCount) } else { fmt.Println("No frames captured") } @@ -1049,6 +1068,141 @@ func assembleVariableFPS(ffmpeg, framesDir, outputFile string, metaData []byte) return outputFile, nil } +// GIFResult holds stats from GIF assembly. +type GIFResult struct { + OutputFile string + InputFrames int + UniqueFrames int +} + +// assembleGIF creates an animated GIF from JPEG frames with frame deduplication. +// Identical consecutive frames are merged into a single frame with extended duration. +func assembleGIF(framesDir, outputFile string) (*GIFResult, error) { + // Read metadata for frame timing + metaPath := filepath.Join(framesDir, "meta.jsonl") + metaData, _ := os.ReadFile(metaPath) + + type frameMeta struct { + Idx int `json:"idx"` + Ts float64 `json:"ts"` + } + var metas []frameMeta + if len(metaData) > 0 { + for _, line := range strings.Split(strings.TrimSpace(string(metaData)), "\n") { + var fm frameMeta + if json.Unmarshal([]byte(line), &fm) == nil { + metas = append(metas, fm) + } + } + } + + // List frame files in order + entries, err := os.ReadDir(framesDir) + if err != nil { + return nil, fmt.Errorf("failed to read frames dir: %w", err) + } + var frameFiles []string + for _, e := range entries { + if strings.HasPrefix(e.Name(), "frame_") && strings.HasSuffix(e.Name(), ".jpeg") { + frameFiles = append(frameFiles, filepath.Join(framesDir, e.Name())) + } + } + sort.Strings(frameFiles) + + if len(frameFiles) == 0 { + return nil, fmt.Errorf("no frames to assemble") + } + + // Build timing lookup: index -> duration in centiseconds (1/100 sec) + frameDurations := make(map[int]int) // frame index -> delay in centiseconds + for i := 0; i < len(metas)-1; i++ { + dur := metas[i+1].Ts - metas[i].Ts + if dur <= 0 { + dur = 0.033 + } + cs := int(dur*100 + 0.5) // convert to centiseconds, rounded + if cs < 2 { + cs = 2 // GIF minimum delay is 2cs (20ms) in most viewers + } + frameDurations[metas[i].Idx] = cs + } + + // Process frames: decode JPEG, quantize to paletted, deduplicate + pal := palette.Plan9 + var gifImages []*image.Paletted + var gifDelays []int + var prevPix []byte + inputCount := len(frameFiles) + + for i, path := range frameFiles { + data, err := os.ReadFile(path) + if err != nil { + continue + } + + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + continue + } + + // Quantize to 256-color paletted image + bounds := img.Bounds() + paletted := image.NewPaletted(bounds, pal) + draw.FloydSteinberg.Draw(paletted, bounds, img, image.Point{}) + + // Determine this frame's duration + delay := 3 // default 30ms + // Extract index from filename for metadata lookup + base := filepath.Base(path) + if idx, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(base, "frame_"), ".jpeg")); err == nil { + if d, ok := frameDurations[idx]; ok { + delay = d + } + } + // Last frame gets default delay if not in metadata + if i == len(frameFiles)-1 && delay == 3 { + delay = 10 // 100ms for last frame + } + + // Deduplicate: compare paletted pixels + if prevPix != nil && bytes.Equal(paletted.Pix, prevPix) { + // Same as previous frame — extend its delay + gifDelays[len(gifDelays)-1] += delay + } else { + gifImages = append(gifImages, paletted) + gifDelays = append(gifDelays, delay) + prevPix = make([]byte, len(paletted.Pix)) + copy(prevPix, paletted.Pix) + } + } + + if len(gifImages) == 0 { + return nil, fmt.Errorf("no valid frames decoded") + } + + // Write GIF + f, err := os.Create(outputFile) + if err != nil { + return nil, fmt.Errorf("failed to create output file: %w", err) + } + defer f.Close() + + err = gif.EncodeAll(f, &gif.GIF{ + Image: gifImages, + Delay: gifDelays, + LoopCount: 0, // loop forever + }) + if err != nil { + return nil, fmt.Errorf("GIF encoding failed: %w", err) + } + + return &GIFResult{ + OutputFile: outputFile, + InputFrames: inputCount, + UniqueFrames: len(gifImages), + }, nil +} + // startVideoCapture begins CDP screencast on the given page, writing JPEG frames // and metadata to framesDir. It returns a stop function that stops the screencast // and returns the number of frames captured in this session. diff --git a/main_test.go b/main_test.go index e835794..af4b775 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "image/gif" "net/http" "net/http/httptest" "os" @@ -763,3 +764,179 @@ func TestStopVideo_ProducesMP4WhenFfmpegAvailable(t *testing.T) { t.Error("expected frames dir to be removed after stop-video") } } + +// ===================== +// GIF recording tests +// ===================== + +func TestAssembleGIF_ProducesValidGIF(t *testing.T) { + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + + // Capture real frames from animated page + page := navigateTo(t, "/animated") + stop := startVideoCapture(page, framesDir) + time.Sleep(1 * time.Second) + n := stop() + + if n < 2 { + t.Fatalf("need at least 2 frames, got %d", n) + } + + outputFile := filepath.Join(dir, "test-output.gif") + result, err := assembleGIF(framesDir, outputFile) + if err != nil { + t.Fatalf("assembleGIF failed: %v", err) + } + + // File should exist and be non-empty + info, err := os.Stat(result.OutputFile) + if err != nil { + t.Fatalf("output file not created: %v", err) + } + if info.Size() == 0 { + t.Error("output file is empty") + } + + // Should be a valid GIF (starts with GIF89a or GIF87a) + header := make([]byte, 6) + f, _ := os.Open(result.OutputFile) + f.Read(header) + f.Close() + if string(header[:3]) != "GIF" { + t.Errorf("not a GIF file, header: %q", string(header)) + } + + // Should have captured some frames + if result.InputFrames == 0 { + t.Error("expected non-zero InputFrames") + } + if result.UniqueFrames == 0 { + t.Error("expected non-zero UniqueFrames") + } +} + +func TestAssembleGIF_DeduplicatesIdenticalFrames(t *testing.T) { + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + os.MkdirAll(framesDir, 0755) + + // Write identical JPEG frames to simulate duplicate screencast output + // Use a real JPEG from a page capture for realistic data + page := navigateTo(t, "/") + stop := startVideoCapture(page, framesDir) + time.Sleep(200 * time.Millisecond) + stop() + + // Read whatever frame we got and duplicate it + firstFrame, err := os.ReadFile(filepath.Join(framesDir, "frame_000000.jpeg")) + if err != nil { + t.Fatalf("no frame captured: %v", err) + } + + // Clear and write 10 identical frames + metadata + os.RemoveAll(framesDir) + os.MkdirAll(framesDir, 0755) + metaFile, _ := os.Create(filepath.Join(framesDir, "meta.jsonl")) + for i := 0; i < 10; i++ { + os.WriteFile(filepath.Join(framesDir, fmt.Sprintf("frame_%06d.jpeg", i)), firstFrame, 0644) + fmt.Fprintf(metaFile, `{"idx":%d,"ts":%.6f}`+"\n", i, float64(1000)+float64(i)*0.033) + } + metaFile.Close() + + outputFile := filepath.Join(dir, "dedup-test.gif") + result, err := assembleGIF(framesDir, outputFile) + if err != nil { + t.Fatalf("assembleGIF failed: %v", err) + } + + t.Logf("Input: %d frames, Unique: %d frames", result.InputFrames, result.UniqueFrames) + + // All 10 frames are identical, so should deduplicate to 1 unique frame + if result.UniqueFrames != 1 { + t.Errorf("expected 1 unique frame from 10 identical inputs, got %d", result.UniqueFrames) + } + if result.InputFrames != 10 { + t.Errorf("expected 10 input frames, got %d", result.InputFrames) + } +} + +func TestAssembleGIF_DecodableWithStdlib(t *testing.T) { + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + + page := navigateTo(t, "/animated") + stop := startVideoCapture(page, framesDir) + time.Sleep(1 * time.Second) + stop() + + outputFile := filepath.Join(dir, "decode-test.gif") + _, err := assembleGIF(framesDir, outputFile) + if err != nil { + t.Fatalf("assembleGIF failed: %v", err) + } + + // Decode the GIF with stdlib to verify it's valid + f, err := os.Open(outputFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + g, err := gif.DecodeAll(f) + if err != nil { + t.Fatalf("gif.DecodeAll failed: %v", err) + } + + if len(g.Image) == 0 { + t.Error("GIF has no frames") + } + if len(g.Image) != len(g.Delay) { + t.Errorf("frame count (%d) != delay count (%d)", len(g.Image), len(g.Delay)) + } + + // Check dimensions match our viewport + bounds := g.Image[0].Bounds() + if bounds.Dx() == 0 || bounds.Dy() == 0 { + t.Errorf("first frame has zero dimensions: %v", bounds) + } + + // All delays should be positive + for i, d := range g.Delay { + if d <= 0 { + t.Errorf("frame %d has non-positive delay: %d", i, d) + } + } +} + +func TestStopVideo_DetectsGIFExtension(t *testing.T) { + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + + page := navigateTo(t, "/animated") + stop := startVideoCapture(page, framesDir) + time.Sleep(1 * time.Second) + stop() + + s := &State{DebugURL: "ws://fake", ChromePID: 99999, VideoRecording: true, VideoDir: framesDir} + saveState(s) + + outputFile := filepath.Join(dir, "result.gif") + result, err := stopVideo(outputFile) + if err != nil { + t.Fatalf("stopVideo failed: %v", err) + } + + if result.OutputFile == "" { + t.Fatal("expected OutputFile to be set") + } + + // Should be a valid GIF + header := make([]byte, 6) + f, _ := os.Open(result.OutputFile) + f.Read(header) + f.Close() + if string(header[:3]) != "GIF" { + t.Errorf("expected GIF file for .gif extension, got header: %q", string(header)) + } +} From 20db95de0b1c047276aaa4bcc5ba4439eb265446 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 06:15:38 +0000 Subject: [PATCH 3/4] Fall back to GIF when ffmpeg is unavailable for MP4 output When stop-video is called with a .mp4 extension but ffmpeg isn't installed, automatically fall back to producing a .gif instead of silently failing. Prints a message to stderr informing the user. https://claude.ai/code/session_018GfJUXfG9azF393kRTQPss --- main.go | 22 +++++++++++++++++----- main_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index 8ca355d..3e3827a 100644 --- a/main.go +++ b/main.go @@ -906,9 +906,10 @@ func cmdStartVideo(args []string) { // VideoResult holds the result of stop-video. type VideoResult struct { - FrameCount int - UniqueFrames int // for GIF: frames after deduplication - OutputFile string // empty if assembly failed + FrameCount int + UniqueFrames int // for GIF: frames after deduplication + OutputFile string // empty if assembly failed + FallbackFormat bool // true if fell back to GIF because ffmpeg was unavailable } // stopVideo stops recording, optionally assembles video, clears state. @@ -934,10 +935,18 @@ func stopVideo(outputFile string) (*VideoResult, error) { result.UniqueFrames = gifResult.UniqueFrames } } else { - if assembled, err := assembleVideo(framesDir, outputFile); err == nil { + assembled, err := assembleVideo(framesDir, outputFile) + if err == nil { result.OutputFile = assembled + } else { + // ffmpeg not available — fall back to GIF + gifFile := strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + ".gif" + if gifResult, gifErr := assembleGIF(framesDir, gifFile); gifErr == nil { + result.OutputFile = gifResult.OutputFile + result.UniqueFrames = gifResult.UniqueFrames + result.FallbackFormat = true + } } - // Non-fatal if ffmpeg fails } } @@ -966,6 +975,9 @@ func cmdStopVideo(args []string) { } if result.OutputFile != "" { + if result.FallbackFormat { + fmt.Fprintf(os.Stderr, "ffmpeg not found, saving as GIF instead\n") + } if result.UniqueFrames > 0 && result.UniqueFrames < result.FrameCount { fmt.Printf("Saved %s (%d frames, %d unique)\n", result.OutputFile, result.FrameCount, result.UniqueFrames) } else { diff --git a/main_test.go b/main_test.go index af4b775..cac9a7e 100644 --- a/main_test.go +++ b/main_test.go @@ -909,6 +909,47 @@ func TestAssembleGIF_DecodableWithStdlib(t *testing.T) { } } +func TestStopVideo_MP4FallsBackToGIFWithoutFfmpeg(t *testing.T) { + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + + page := navigateTo(t, "/animated") + stop := startVideoCapture(page, framesDir) + time.Sleep(1 * time.Second) + stop() + + s := &State{DebugURL: "ws://fake", ChromePID: 99999, VideoRecording: true, VideoDir: framesDir} + saveState(s) + + // Request .mp4 but with a bogus PATH so ffmpeg won't be found + origPath := os.Getenv("PATH") + os.Setenv("PATH", "/nonexistent") + defer os.Setenv("PATH", origPath) + + outputFile := filepath.Join(dir, "result.mp4") + result, err := stopVideo(outputFile) + if err != nil { + t.Fatalf("stopVideo failed: %v", err) + } + + // Should have fallen back to GIF + if result.OutputFile == "" { + t.Fatal("expected OutputFile to be set (GIF fallback)") + } + if !strings.HasSuffix(result.OutputFile, ".gif") { + t.Errorf("expected .gif fallback, got %q", result.OutputFile) + } + + // Verify it's actually a valid GIF + header := make([]byte, 6) + f, _ := os.Open(result.OutputFile) + f.Read(header) + f.Close() + if string(header[:3]) != "GIF" { + t.Errorf("fallback file is not a GIF, header: %q", string(header)) + } +} + func TestStopVideo_DetectsGIFExtension(t *testing.T) { dir := withTestStateDir(t) framesDir := filepath.Join(dir, "video-frames") From 06151ac7ebd8614fc5d9b01307e376140b97f73f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 14:04:27 +0000 Subject: [PATCH 4/4] Capture video frames during sleep when recording is active cmdSleep now connects to the active page when video recording is on, so screencast frames are captured during the sleep period. Also adds a LoopCount assertion to the GIF stdlib decode test. https://claude.ai/code/session_018GfJUXfG9azF393kRTQPss --- main.go | 5 +++++ main_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/main.go b/main.go index 3e3827a..082ce37 100644 --- a/main.go +++ b/main.go @@ -769,6 +769,11 @@ func cmdSleep(args []string) { if err != nil { fatal("invalid seconds: %v", err) } + // If video recording is active, connect to the page so screencast + // captures frames during the sleep + if s, err := loadState(); err == nil && s.VideoRecording { + withPage() + } time.Sleep(time.Duration(secs * float64(time.Second))) } diff --git a/main_test.go b/main_test.go index cac9a7e..2c92909 100644 --- a/main_test.go +++ b/main_test.go @@ -894,6 +894,10 @@ func TestAssembleGIF_DecodableWithStdlib(t *testing.T) { if len(g.Image) != len(g.Delay) { t.Errorf("frame count (%d) != delay count (%d)", len(g.Image), len(g.Delay)) } + // LoopCount 0 means loop forever + if g.LoopCount != 0 { + t.Errorf("expected LoopCount 0 (loop forever), got %d", g.LoopCount) + } // Check dimensions match our viewport bounds := g.Image[0].Bounds() @@ -909,6 +913,36 @@ func TestAssembleGIF_DecodableWithStdlib(t *testing.T) { } } +func TestSleep_CapturesVideoFramesWhenRecording(t *testing.T) { + dir := withTestStateDir(t) + framesDir := filepath.Join(dir, "video-frames") + os.MkdirAll(framesDir, 0755) + + // Navigate to an animated page (simulates a page being open) + page := navigateTo(t, "/animated") + + // Set up state: recording is active + s := &State{ + DebugURL: "ws://fake", + ChromePID: 99999, + VideoRecording: true, + VideoDir: framesDir, + } + saveState(s) + + // maybeStartVideoCapture should start screencast when recording is on + cleanup := maybeStartVideoCapture(page) + time.Sleep(2 * time.Second) + cleanup() + + // Frames should have been captured during the sleep + frameCount := countFrames(framesDir) + if frameCount == 0 { + t.Fatal("expected frames to be captured during sleep while recording, got 0") + } + t.Logf("captured %d frames during 2s sleep", frameCount) +} + func TestStopVideo_MP4FallsBackToGIFWithoutFfmpeg(t *testing.T) { dir := withTestStateDir(t) framesDir := filepath.Join(dir, "video-frames")