375 lines
No EOL
8.8 KiB
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
|
|
}
|
|
} |