// File-based capture for testing and development. package backends import ( "fmt" "image" "image/gif" "image/jpeg" "image/png" "os" "path/filepath" "strings" "time" "git.cloonar.com/openclawd/iso-bot/pkg/engine/capture" ) // FileConfig holds configuration for file-based capture. type FileConfig struct { // Path is the file or directory path to read from. Path string `yaml:"path"` // Type specifies the input type: "image", "video", "sequence", "directory". Type string `yaml:"type"` // FrameRate for video playback or image sequence (frames per second). FrameRate float64 `yaml:"frame_rate"` // Loop enables looping for video or image sequences. Loop bool `yaml:"loop"` // Pattern is the filename pattern for image sequences (e.g., "frame_%04d.png"). Pattern string `yaml:"pattern"` // StartFrame for image sequences or video (0-based). StartFrame int `yaml:"start_frame"` // EndFrame for image sequences or video (-1 for end of sequence). EndFrame int `yaml:"end_frame"` } // FileSource provides capture from static images, image sequences, or video files. type FileSource struct { config FileConfig currentFrame int totalFrames int frameData []image.Image lastCapture time.Time frameInterval time.Duration width int height int } // NewFileSource creates a file-based capture source. func NewFileSource(configMap map[string]interface{}) (capture.Source, error) { var config FileConfig // Extract config from map if path, ok := configMap["path"].(string); ok { config.Path = path } else { return nil, fmt.Errorf("file path is required") } if fileType, ok := configMap["type"].(string); ok { config.Type = fileType } else { config.Type = "auto" // Auto-detect based on path } if frameRate, ok := configMap["frame_rate"].(float64); ok { config.FrameRate = frameRate } else { config.FrameRate = 30.0 // Default 30 FPS } if loop, ok := configMap["loop"].(bool); ok { config.Loop = loop } else { config.Loop = true // Default to looping } if pattern, ok := configMap["pattern"].(string); ok { config.Pattern = pattern } if startFrame, ok := configMap["start_frame"].(int); ok { config.StartFrame = startFrame } if endFrame, ok := configMap["end_frame"].(int); ok { config.EndFrame = endFrame } else { config.EndFrame = -1 // End of sequence } source := &FileSource{ config: config, currentFrame: config.StartFrame, frameInterval: time.Duration(float64(time.Second) / config.FrameRate), } // Load file(s) and initialize if err := source.initialize(); err != nil { return nil, fmt.Errorf("failed to initialize file source: %w", err) } return source, nil } // Name returns a description of this capture source. func (f *FileSource) Name() string { return fmt.Sprintf("File: %s", filepath.Base(f.config.Path)) } // Capture grabs a single frame from the file source. func (f *FileSource) Capture() (image.Image, error) { if len(f.frameData) == 0 { return nil, fmt.Errorf("no frames loaded") } // Respect frame rate timing if !f.lastCapture.IsZero() { elapsed := time.Since(f.lastCapture) if elapsed < f.frameInterval { time.Sleep(f.frameInterval - elapsed) } } f.lastCapture = time.Now() // Get current frame if f.currentFrame >= len(f.frameData) { if f.config.Loop { f.currentFrame = f.config.StartFrame } else { return nil, fmt.Errorf("end of sequence reached") } } frame := f.frameData[f.currentFrame] f.currentFrame++ // Check end frame limit if f.config.EndFrame > 0 && f.currentFrame > f.config.EndFrame { if f.config.Loop { f.currentFrame = f.config.StartFrame } else { return frame, fmt.Errorf("end frame reached") } } return frame, nil } // CaptureRegion grabs a sub-region of the current frame. func (f *FileSource) CaptureRegion(r capture.Region) (image.Image, error) { fullFrame, err := f.Capture() if err != nil { return nil, err } // Crop the image to the specified region bounds := image.Rect(r.X, r.Y, r.X+r.Width, r.Y+r.Height) // Check if the region is within bounds imgBounds := fullFrame.Bounds() if !bounds.In(imgBounds) { bounds = bounds.Intersect(imgBounds) if bounds.Empty() { return nil, fmt.Errorf("region is outside image bounds") } } return fullFrame.(interface{ SubImage(r image.Rectangle) image.Image }).SubImage(bounds), nil } // Size returns the frame dimensions. func (f *FileSource) Size() (width, height int) { return f.width, f.height } // Close releases file resources. func (f *FileSource) Close() error { // Clear frame data to free memory f.frameData = nil return nil } // initialize loads the file(s) and prepares frame data. func (f *FileSource) initialize() error { // Auto-detect type if not specified if f.config.Type == "auto" { f.config.Type = f.detectType() } switch f.config.Type { case "image": return f.loadSingleImage() case "sequence": return f.loadImageSequence() case "directory": return f.loadDirectory() case "video": return f.loadVideo() default: return fmt.Errorf("unsupported file type: %s", f.config.Type) } } // detectType automatically determines the file type. func (f *FileSource) detectType() string { info, err := os.Stat(f.config.Path) if err != nil { return "image" // Default fallback } if info.IsDir() { return "directory" } ext := strings.ToLower(filepath.Ext(f.config.Path)) switch ext { case ".png", ".jpg", ".jpeg", ".gif", ".bmp": return "image" case ".mp4", ".avi", ".mov", ".mkv", ".webm": return "video" default: // Check if it looks like a sequence pattern if strings.Contains(f.config.Path, "%") || strings.Contains(f.config.Path, "*") { return "sequence" } return "image" } } // loadSingleImage loads a single image file. func (f *FileSource) loadSingleImage() error { img, err := f.loadImage(f.config.Path) if err != nil { return err } f.frameData = []image.Image{img} f.totalFrames = 1 f.width = img.Bounds().Dx() f.height = img.Bounds().Dy() return nil } // loadImageSequence loads a numbered sequence of images. func (f *FileSource) loadImageSequence() error { var frames []image.Image // Generate filenames based on pattern for i := f.config.StartFrame; f.config.EndFrame < 0 || i <= f.config.EndFrame; i++ { filename := fmt.Sprintf(f.config.Pattern, i) if !filepath.IsAbs(filename) { filename = filepath.Join(filepath.Dir(f.config.Path), filename) } // Check if file exists if _, err := os.Stat(filename); os.IsNotExist(err) { if len(frames) == 0 { return fmt.Errorf("no images found matching pattern: %s", f.config.Pattern) } break // End of sequence } img, err := f.loadImage(filename) if err != nil { return fmt.Errorf("failed to load %s: %w", filename, err) } frames = append(frames, img) } if len(frames) == 0 { return fmt.Errorf("no frames loaded from sequence") } f.frameData = frames f.totalFrames = len(frames) f.width = frames[0].Bounds().Dx() f.height = frames[0].Bounds().Dy() return nil } // loadDirectory loads all images from a directory. func (f *FileSource) loadDirectory() error { entries, err := os.ReadDir(f.config.Path) if err != nil { return err } var frames []image.Image for _, entry := range entries { if entry.IsDir() { continue } filename := entry.Name() ext := strings.ToLower(filepath.Ext(filename)) // Check if it's a supported image format if !isImageFile(ext) { continue } fullPath := filepath.Join(f.config.Path, filename) img, err := f.loadImage(fullPath) if err != nil { continue // Skip files that can't be loaded } frames = append(frames, img) } if len(frames) == 0 { return fmt.Errorf("no image files found in directory: %s", f.config.Path) } f.frameData = frames f.totalFrames = len(frames) f.width = frames[0].Bounds().Dx() f.height = frames[0].Bounds().Dy() return nil } // loadVideo loads frames from a video file. func (f *FileSource) loadVideo() error { // TODO: Implement video decoding // This would require a video decoding library like FFmpeg // For now, return an error return fmt.Errorf("video capture not implemented yet - use image sequences instead") } // loadImage loads a single image file. func (f *FileSource) loadImage(path string) (image.Image, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() // Determine format based on file extension ext := strings.ToLower(filepath.Ext(path)) switch ext { case ".png": return png.Decode(file) case ".jpg", ".jpeg": return jpeg.Decode(file) case ".gif": return gif.Decode(file) default: // Try to decode generically img, _, err := image.Decode(file) return img, err } } // isImageFile checks if the file extension indicates an image file. func isImageFile(ext string) bool { switch ext { case ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp": return true default: return false } }