diff --git a/help.txt b/help.txt index b9d2cbc..d031337 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 and save (.gif default, .mp4 needs ffmpeg) + Tabs: rodney pages List all pages/tabs rodney page Switch to page by index diff --git a/main.go b/main.go index 325a486..082ce37 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,8 +19,10 @@ import ( "os/exec" "os/signal" "path/filepath" + "sort" "strconv" "strings" + "sync" "syscall" "time" @@ -30,15 +38,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 +125,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 +203,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 +248,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 +279,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 +390,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 +420,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 +461,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 { @@ -717,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))) } @@ -826,6 +883,428 @@ 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 + 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. +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} + + // Assemble output if we have frames + if frameCount > 0 && outputFile != "" { + if strings.HasSuffix(strings.ToLower(outputFile), ".gif") { + if gifResult, err := assembleGIF(framesDir, outputFile); err == nil { + result.OutputFile = gifResult.OutputFile + result.UniqueFrames = gifResult.UniqueFrames + } + } else { + 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 + } + } + } + } + + // 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", ".gif") + } + + result, err := stopVideo(outputFile) + if err != nil { + fatal("%v", err) + } + + 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 { + fmt.Printf("Saved %s (%d frames)\n", result.OutputFile, result.FrameCount) + } + } else if result.FrameCount > 0 { + fmt.Printf("Captured %d frames but assembly failed\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 +} + +// 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. +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..2c92909 100644 --- a/main_test.go +++ b/main_test.go @@ -2,9 +2,13 @@ package main import ( "encoding/json" + "fmt" + "image/gif" "net/http" "net/http/httptest" "os" + "os/exec" + "path/filepath" "strings" "testing" "time" @@ -42,6 +46,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 +103,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 +430,588 @@ 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") + } +} + +// ===================== +// 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)) + } + // 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() + 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 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") + + 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") + + 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)) + } +}