Add modular capture backends, resolution profiles, Wayland support
This commit is contained in:
parent
3b363192f2
commit
80ba9b1b90
16 changed files with 2266 additions and 45 deletions
375
pkg/engine/capture/backends/file.go
Normal file
375
pkg/engine/capture/backends/file.go
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
// 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue