iso-bot/pkg/engine/capture/backends/file.go

375 lines
No EOL
8.8 KiB
Go

// 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
}
}