3461 lines
117 KiB
Go
3461 lines
117 KiB
Go
package runner
|
||
|
||
import (
|
||
_ "embed"
|
||
"archive/tar"
|
||
"bufio"
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"jiggablend/pkg/scripts"
|
||
"jiggablend/pkg/types"
|
||
|
||
"github.com/gorilla/websocket"
|
||
)
|
||
|
||
// Client represents a runner client
|
||
type Client struct {
|
||
managerURL string
|
||
name string
|
||
hostname string
|
||
ipAddress string
|
||
httpClient *http.Client
|
||
runnerID int64
|
||
runnerSecret string
|
||
managerSecret string
|
||
wsConn *websocket.Conn
|
||
wsConnMu sync.RWMutex
|
||
wsWriteMu sync.Mutex // Protects concurrent writes to WebSocket (WebSocket is not thread-safe)
|
||
stopChan chan struct{}
|
||
stepStartTimes map[string]time.Time // key: "taskID:stepName"
|
||
stepTimesMu sync.RWMutex
|
||
workspaceDir string // Persistent workspace directory for this runner
|
||
runningProcs sync.Map // map[int64]*exec.Cmd - tracks running processes by task ID
|
||
capabilities map[string]interface{} // Cached capabilities from initial probe (includes bools and numbers)
|
||
capabilitiesMu sync.RWMutex // Protects capabilities
|
||
hwAccelCache map[string]bool // Cached hardware acceleration detection results
|
||
hwAccelCacheMu sync.RWMutex // Protects hwAccelCache
|
||
vaapiDevices []string // Cached VAAPI device paths (all available devices)
|
||
vaapiDevicesMu sync.RWMutex // Protects vaapiDevices
|
||
allocatedDevices map[int64]string // map[taskID]device - tracks which device is allocated to which task
|
||
allocatedDevicesMu sync.RWMutex // Protects allocatedDevices
|
||
longRunningClient *http.Client // HTTP client for long-running operations (no timeout)
|
||
}
|
||
|
||
// NewClient creates a new runner client
|
||
func NewClient(managerURL, name, hostname, ipAddress string) *Client {
|
||
return &Client{
|
||
managerURL: managerURL,
|
||
name: name,
|
||
hostname: hostname,
|
||
ipAddress: ipAddress,
|
||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||
longRunningClient: &http.Client{Timeout: 0}, // No timeout for long-running operations (context downloads, file uploads/downloads)
|
||
stopChan: make(chan struct{}),
|
||
stepStartTimes: make(map[string]time.Time),
|
||
}
|
||
}
|
||
|
||
// SetSecrets sets the runner and manager secrets
|
||
func (c *Client) SetSecrets(runnerID int64, runnerSecret, managerSecret string) {
|
||
c.runnerID = runnerID
|
||
c.runnerSecret = runnerSecret
|
||
c.managerSecret = managerSecret
|
||
|
||
// Initialize runner workspace directory if not already initialized
|
||
if c.workspaceDir == "" {
|
||
c.initWorkspace()
|
||
}
|
||
}
|
||
|
||
// initWorkspace creates the persistent workspace directory for this runner
|
||
func (c *Client) initWorkspace() {
|
||
// Use runner name if available, otherwise use runner ID
|
||
workspaceName := c.name
|
||
if workspaceName == "" {
|
||
workspaceName = fmt.Sprintf("runner-%d", c.runnerID)
|
||
}
|
||
// Sanitize workspace name (remove invalid characters)
|
||
workspaceName = strings.ReplaceAll(workspaceName, " ", "_")
|
||
workspaceName = strings.ReplaceAll(workspaceName, "/", "_")
|
||
workspaceName = strings.ReplaceAll(workspaceName, "\\", "_")
|
||
workspaceName = strings.ReplaceAll(workspaceName, ":", "_")
|
||
|
||
// Create workspace in a jiggablend directory under temp or current directory
|
||
baseDir := os.TempDir()
|
||
if cwd, err := os.Getwd(); err == nil {
|
||
// Prefer current directory if writable
|
||
baseDir = cwd
|
||
}
|
||
|
||
c.workspaceDir = filepath.Join(baseDir, "jiggablend-workspaces", workspaceName)
|
||
if err := os.MkdirAll(c.workspaceDir, 0755); err != nil {
|
||
log.Printf("Warning: Failed to create workspace directory %s: %v", c.workspaceDir, err)
|
||
// Fallback to temp directory
|
||
c.workspaceDir = filepath.Join(os.TempDir(), "jiggablend-workspaces", workspaceName)
|
||
if err := os.MkdirAll(c.workspaceDir, 0755); err != nil {
|
||
log.Printf("Error: Failed to create fallback workspace directory: %v", err)
|
||
// Last resort: use temp directory with runner ID
|
||
c.workspaceDir = filepath.Join(os.TempDir(), fmt.Sprintf("jiggablend-runner-%d", c.runnerID))
|
||
os.MkdirAll(c.workspaceDir, 0755)
|
||
}
|
||
}
|
||
log.Printf("Runner workspace initialized at: %s", c.workspaceDir)
|
||
}
|
||
|
||
// getWorkspaceDir returns the workspace directory, initializing it if needed
|
||
func (c *Client) getWorkspaceDir() string {
|
||
if c.workspaceDir == "" {
|
||
c.initWorkspace()
|
||
}
|
||
return c.workspaceDir
|
||
}
|
||
|
||
// probeCapabilities checks what capabilities the runner has by probing for blender and ffmpeg
|
||
// Returns a map that includes both boolean capabilities and numeric values (like GPU count)
|
||
func (c *Client) probeCapabilities() map[string]interface{} {
|
||
capabilities := make(map[string]interface{})
|
||
|
||
// Check for blender
|
||
blenderCmd := exec.Command("blender", "--version")
|
||
if err := blenderCmd.Run(); err == nil {
|
||
capabilities["blender"] = true
|
||
} else {
|
||
capabilities["blender"] = false
|
||
}
|
||
|
||
// Check for ffmpeg
|
||
ffmpegCmd := exec.Command("ffmpeg", "-version")
|
||
if err := ffmpegCmd.Run(); err == nil {
|
||
capabilities["ffmpeg"] = true
|
||
|
||
// Immediately probe GPU capabilities when ffmpeg is detected
|
||
log.Printf("FFmpeg detected, probing GPU hardware acceleration capabilities...")
|
||
c.probeGPUCapabilities(capabilities)
|
||
} else {
|
||
capabilities["ffmpeg"] = false
|
||
// Set defaults when ffmpeg is not available
|
||
capabilities["vaapi"] = false
|
||
capabilities["vaapi_gpu_count"] = 0
|
||
capabilities["nvenc"] = false
|
||
capabilities["nvenc_gpu_count"] = 0
|
||
capabilities["video_gpu_count"] = 0
|
||
}
|
||
|
||
return capabilities
|
||
}
|
||
|
||
// probeGPUCapabilities probes GPU hardware acceleration capabilities for ffmpeg
|
||
// This is called immediately after detecting ffmpeg during initial capability probe
|
||
func (c *Client) probeGPUCapabilities(capabilities map[string]interface{}) {
|
||
// First, probe all available hardware acceleration methods
|
||
log.Printf("Probing all hardware acceleration methods...")
|
||
hwaccels := c.probeAllHardwareAccelerators()
|
||
if len(hwaccels) > 0 {
|
||
log.Printf("Available hardware acceleration methods: %v", getKeys(hwaccels))
|
||
} else {
|
||
log.Printf("No hardware acceleration methods found")
|
||
}
|
||
|
||
// Probe all hardware encoders
|
||
log.Printf("Probing all hardware encoders...")
|
||
hwEncoders := c.probeAllHardwareEncoders()
|
||
if len(hwEncoders) > 0 {
|
||
log.Printf("Available hardware encoders: %v", getKeys(hwEncoders))
|
||
}
|
||
|
||
// Check for VAAPI devices and count them
|
||
log.Printf("Checking for VAAPI hardware acceleration...")
|
||
|
||
// First check if encoder is listed (more reliable than testing)
|
||
cmd := exec.Command("ffmpeg", "-hide_banner", "-encoders")
|
||
output, err := cmd.CombinedOutput()
|
||
hasVAAPIEncoder := false
|
||
if err == nil {
|
||
encoderOutput := string(output)
|
||
if strings.Contains(encoderOutput, "h264_vaapi") {
|
||
hasVAAPIEncoder = true
|
||
log.Printf("VAAPI encoder (h264_vaapi) found in ffmpeg encoders list")
|
||
}
|
||
}
|
||
|
||
if hasVAAPIEncoder {
|
||
// Try to find and test devices
|
||
vaapiDevices := c.findVAAPIDevices()
|
||
capabilities["vaapi_gpu_count"] = len(vaapiDevices)
|
||
if len(vaapiDevices) > 0 {
|
||
capabilities["vaapi"] = true
|
||
log.Printf("VAAPI detected: %d GPU device(s) available: %v", len(vaapiDevices), vaapiDevices)
|
||
} else {
|
||
capabilities["vaapi"] = false
|
||
log.Printf("VAAPI encoder available but no working devices found")
|
||
log.Printf(" This might indicate:")
|
||
log.Printf(" - Missing or incorrect GPU drivers")
|
||
log.Printf(" - Missing libva or mesa-va-drivers packages")
|
||
log.Printf(" - Permission issues accessing /dev/dri devices")
|
||
log.Printf(" - GPU not properly initialized")
|
||
}
|
||
} else {
|
||
capabilities["vaapi"] = false
|
||
capabilities["vaapi_gpu_count"] = 0
|
||
log.Printf("VAAPI encoder not available in ffmpeg")
|
||
log.Printf(" This might indicate:")
|
||
log.Printf(" - FFmpeg was not compiled with VAAPI support")
|
||
log.Printf(" - Missing libva development libraries during FFmpeg compilation")
|
||
}
|
||
|
||
// Check for NVENC (NVIDIA) - try to detect multiple GPUs
|
||
log.Printf("Checking for NVENC hardware acceleration...")
|
||
if c.checkEncoderAvailable("h264_nvenc") {
|
||
capabilities["nvenc"] = true
|
||
// Try to detect actual GPU count using nvidia-smi if available
|
||
nvencCount := c.detectNVENCCount()
|
||
capabilities["nvenc_gpu_count"] = nvencCount
|
||
log.Printf("NVENC detected: %d GPU(s)", nvencCount)
|
||
} else {
|
||
capabilities["nvenc"] = false
|
||
capabilities["nvenc_gpu_count"] = 0
|
||
log.Printf("NVENC encoder not available")
|
||
}
|
||
|
||
// Check for other hardware encoders (for completeness)
|
||
log.Printf("Checking for other hardware encoders...")
|
||
if c.checkEncoderAvailable("h264_qsv") {
|
||
capabilities["qsv"] = true
|
||
capabilities["qsv_gpu_count"] = 1
|
||
log.Printf("Intel Quick Sync (QSV) detected")
|
||
} else {
|
||
capabilities["qsv"] = false
|
||
capabilities["qsv_gpu_count"] = 0
|
||
}
|
||
|
||
if c.checkEncoderAvailable("h264_videotoolbox") {
|
||
capabilities["videotoolbox"] = true
|
||
capabilities["videotoolbox_gpu_count"] = 1
|
||
log.Printf("VideoToolbox (macOS) detected")
|
||
} else {
|
||
capabilities["videotoolbox"] = false
|
||
capabilities["videotoolbox_gpu_count"] = 0
|
||
}
|
||
|
||
if c.checkEncoderAvailable("h264_amf") {
|
||
capabilities["amf"] = true
|
||
capabilities["amf_gpu_count"] = 1
|
||
log.Printf("AMD AMF detected")
|
||
} else {
|
||
capabilities["amf"] = false
|
||
capabilities["amf_gpu_count"] = 0
|
||
}
|
||
|
||
// Check for V4L2M2M (Video4Linux2)
|
||
if c.checkEncoderAvailable("h264_v4l2m2m") {
|
||
capabilities["v4l2m2m"] = true
|
||
capabilities["v4l2m2m_gpu_count"] = 1
|
||
log.Printf("V4L2 M2M detected")
|
||
} else {
|
||
capabilities["v4l2m2m"] = false
|
||
capabilities["v4l2m2m_gpu_count"] = 0
|
||
}
|
||
|
||
// Check for OpenMAX (Raspberry Pi)
|
||
if c.checkEncoderAvailable("h264_omx") {
|
||
capabilities["omx"] = true
|
||
capabilities["omx_gpu_count"] = 1
|
||
log.Printf("OpenMAX detected")
|
||
} else {
|
||
capabilities["omx"] = false
|
||
capabilities["omx_gpu_count"] = 0
|
||
}
|
||
|
||
// Check for MediaCodec (Android)
|
||
if c.checkEncoderAvailable("h264_mediacodec") {
|
||
capabilities["mediacodec"] = true
|
||
capabilities["mediacodec_gpu_count"] = 1
|
||
log.Printf("MediaCodec detected")
|
||
} else {
|
||
capabilities["mediacodec"] = false
|
||
capabilities["mediacodec_gpu_count"] = 0
|
||
}
|
||
|
||
// Calculate total GPU count for video encoding
|
||
// Priority: VAAPI > NVENC > QSV > VideoToolbox > AMF > others
|
||
vaapiCount := 0
|
||
if count, ok := capabilities["vaapi_gpu_count"].(int); ok {
|
||
vaapiCount = count
|
||
}
|
||
nvencCount := 0
|
||
if count, ok := capabilities["nvenc_gpu_count"].(int); ok {
|
||
nvencCount = count
|
||
}
|
||
qsvCount := 0
|
||
if count, ok := capabilities["qsv_gpu_count"].(int); ok {
|
||
qsvCount = count
|
||
}
|
||
videotoolboxCount := 0
|
||
if count, ok := capabilities["videotoolbox_gpu_count"].(int); ok {
|
||
videotoolboxCount = count
|
||
}
|
||
amfCount := 0
|
||
if count, ok := capabilities["amf_gpu_count"].(int); ok {
|
||
amfCount = count
|
||
}
|
||
|
||
// Total GPU count - use the best available (they can't be used simultaneously)
|
||
totalGPUs := vaapiCount
|
||
if totalGPUs == 0 {
|
||
totalGPUs = nvencCount
|
||
}
|
||
if totalGPUs == 0 {
|
||
totalGPUs = qsvCount
|
||
}
|
||
if totalGPUs == 0 {
|
||
totalGPUs = videotoolboxCount
|
||
}
|
||
if totalGPUs == 0 {
|
||
totalGPUs = amfCount
|
||
}
|
||
capabilities["video_gpu_count"] = totalGPUs
|
||
|
||
if totalGPUs > 0 {
|
||
log.Printf("Total video GPU count: %d", totalGPUs)
|
||
} else {
|
||
log.Printf("No hardware-accelerated video encoding GPUs detected (will use software encoding)")
|
||
}
|
||
}
|
||
|
||
// detectNVENCCount tries to detect the actual number of NVIDIA GPUs using nvidia-smi
|
||
func (c *Client) detectNVENCCount() int {
|
||
// Try to use nvidia-smi to count GPUs
|
||
cmd := exec.Command("nvidia-smi", "--list-gpus")
|
||
output, err := cmd.CombinedOutput()
|
||
if err == nil {
|
||
// Count lines that contain "GPU" (each GPU is listed on a separate line)
|
||
lines := strings.Split(string(output), "\n")
|
||
count := 0
|
||
for _, line := range lines {
|
||
if strings.Contains(line, "GPU") {
|
||
count++
|
||
}
|
||
}
|
||
if count > 0 {
|
||
return count
|
||
}
|
||
}
|
||
// Fallback to 1 if nvidia-smi is not available
|
||
return 1
|
||
}
|
||
|
||
// getKeys returns all keys from a map as a slice (helper function)
|
||
func getKeys(m map[string]bool) []string {
|
||
keys := make([]string, 0, len(m))
|
||
for k := range m {
|
||
keys = append(keys, k)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// ProbeCapabilities probes and caches capabilities (should be called once at startup)
|
||
func (c *Client) ProbeCapabilities() {
|
||
capabilities := c.probeCapabilities()
|
||
c.capabilitiesMu.Lock()
|
||
c.capabilities = capabilities
|
||
c.capabilitiesMu.Unlock()
|
||
}
|
||
|
||
// GetCapabilities returns the cached capabilities
|
||
func (c *Client) GetCapabilities() map[string]interface{} {
|
||
c.capabilitiesMu.RLock()
|
||
defer c.capabilitiesMu.RUnlock()
|
||
// Return a copy to prevent external modification
|
||
result := make(map[string]interface{})
|
||
for k, v := range c.capabilities {
|
||
result[k] = v
|
||
}
|
||
return result
|
||
}
|
||
|
||
// Register registers the runner with the manager using a registration token
|
||
func (c *Client) Register(registrationToken string) (int64, string, string, error) {
|
||
// Use cached capabilities (should have been probed once at startup)
|
||
c.capabilitiesMu.RLock()
|
||
capabilities := c.capabilities
|
||
c.capabilitiesMu.RUnlock()
|
||
|
||
// If capabilities weren't probed yet, probe them now (fallback)
|
||
if capabilities == nil {
|
||
capabilities = c.probeCapabilities()
|
||
c.capabilitiesMu.Lock()
|
||
c.capabilities = capabilities
|
||
c.capabilitiesMu.Unlock()
|
||
}
|
||
|
||
capabilitiesJSON, err := json.Marshal(capabilities)
|
||
if err != nil {
|
||
return 0, "", "", fmt.Errorf("failed to marshal capabilities: %w", err)
|
||
}
|
||
|
||
req := map[string]interface{}{
|
||
"name": c.name,
|
||
"hostname": c.hostname,
|
||
"ip_address": c.ipAddress,
|
||
"capabilities": string(capabilitiesJSON),
|
||
"registration_token": registrationToken,
|
||
}
|
||
|
||
body, _ := json.Marshal(req)
|
||
resp, err := c.httpClient.Post(
|
||
fmt.Sprintf("%s/api/runner/register", c.managerURL),
|
||
"application/json",
|
||
bytes.NewReader(body),
|
||
)
|
||
if err != nil {
|
||
// Network/connection error - should retry
|
||
return 0, "", "", fmt.Errorf("connection error: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusCreated {
|
||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||
errorBody := string(bodyBytes)
|
||
|
||
// Check if it's a token-related error (should not retry)
|
||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest {
|
||
// Check error message for token-related issues
|
||
errorLower := strings.ToLower(errorBody)
|
||
if strings.Contains(errorLower, "invalid") ||
|
||
strings.Contains(errorLower, "expired") ||
|
||
strings.Contains(errorLower, "already used") ||
|
||
strings.Contains(errorLower, "token") {
|
||
return 0, "", "", fmt.Errorf("token error: %s", errorBody)
|
||
}
|
||
}
|
||
|
||
// Other errors (like 500) might be retryable
|
||
return 0, "", "", fmt.Errorf("registration failed (status %d): %s", resp.StatusCode, errorBody)
|
||
}
|
||
|
||
var result struct {
|
||
ID int64 `json:"id"`
|
||
RunnerSecret string `json:"runner_secret"`
|
||
ManagerSecret string `json:"manager_secret"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return 0, "", "", fmt.Errorf("failed to decode response: %w", err)
|
||
}
|
||
|
||
c.runnerID = result.ID
|
||
c.runnerSecret = result.RunnerSecret
|
||
c.managerSecret = result.ManagerSecret
|
||
|
||
return result.ID, result.RunnerSecret, result.ManagerSecret, nil
|
||
}
|
||
|
||
// doSignedRequest performs an authenticated HTTP request using shared secret
|
||
// queryParams is optional and will be appended to the URL
|
||
func (c *Client) doSignedRequest(method, path string, body []byte, queryParams ...string) (*http.Response, error) {
|
||
return c.doSignedRequestWithClient(method, path, body, c.httpClient, queryParams...)
|
||
}
|
||
|
||
// doSignedRequestLong performs an authenticated HTTP request using the long-running client (no timeout)
|
||
// Use this for context downloads, file uploads/downloads, and other operations that may take a long time
|
||
func (c *Client) doSignedRequestLong(method, path string, body []byte, queryParams ...string) (*http.Response, error) {
|
||
return c.doSignedRequestWithClient(method, path, body, c.longRunningClient, queryParams...)
|
||
}
|
||
|
||
// doSignedRequestWithClient performs an authenticated HTTP request using the specified client
|
||
func (c *Client) doSignedRequestWithClient(method, path string, body []byte, client *http.Client, queryParams ...string) (*http.Response, error) {
|
||
if c.runnerSecret == "" {
|
||
return nil, fmt.Errorf("runner not authenticated")
|
||
}
|
||
|
||
// Build URL with query params if provided
|
||
url := fmt.Sprintf("%s%s", c.managerURL, path)
|
||
if len(queryParams) > 0 {
|
||
url += "?" + strings.Join(queryParams, "&")
|
||
}
|
||
|
||
req, err := http.NewRequest(method, url, bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("X-Runner-Secret", c.runnerSecret)
|
||
|
||
return client.Do(req)
|
||
}
|
||
|
||
// ConnectWebSocket establishes a WebSocket connection to the manager
|
||
func (c *Client) ConnectWebSocket() error {
|
||
if c.runnerID == 0 || c.runnerSecret == "" {
|
||
return fmt.Errorf("runner not authenticated")
|
||
}
|
||
|
||
// Build WebSocket URL with authentication
|
||
path := "/api/runner/ws"
|
||
|
||
// Convert HTTP URL to WebSocket URL
|
||
wsURL := strings.Replace(c.managerURL, "http://", "ws://", 1)
|
||
wsURL = strings.Replace(wsURL, "https://", "wss://", 1)
|
||
wsURL = fmt.Sprintf("%s%s?runner_id=%d&secret=%s",
|
||
wsURL, path, c.runnerID, url.QueryEscape(c.runnerSecret))
|
||
|
||
// Parse URL
|
||
u, err := url.Parse(wsURL)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid WebSocket URL: %w", err)
|
||
}
|
||
|
||
// Connect
|
||
dialer := websocket.Dialer{
|
||
HandshakeTimeout: 10 * time.Second,
|
||
}
|
||
conn, _, err := dialer.Dial(u.String(), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to connect WebSocket: %w", err)
|
||
}
|
||
|
||
c.wsConnMu.Lock()
|
||
if c.wsConn != nil {
|
||
c.wsConn.Close()
|
||
}
|
||
c.wsConn = conn
|
||
c.wsConnMu.Unlock()
|
||
|
||
log.Printf("WebSocket connected to manager")
|
||
return nil
|
||
}
|
||
|
||
// ConnectWebSocketWithReconnect connects with automatic reconnection
|
||
func (c *Client) ConnectWebSocketWithReconnect() {
|
||
backoff := 1 * time.Second
|
||
maxBackoff := 60 * time.Second
|
||
|
||
for {
|
||
err := c.ConnectWebSocket()
|
||
if err == nil {
|
||
backoff = 1 * time.Second // Reset on success
|
||
c.HandleWebSocketMessages()
|
||
} else {
|
||
log.Printf("WebSocket connection failed: %v, retrying in %v", err, backoff)
|
||
time.Sleep(backoff)
|
||
backoff *= 2
|
||
if backoff > maxBackoff {
|
||
backoff = maxBackoff
|
||
}
|
||
}
|
||
|
||
// Check if we should stop
|
||
select {
|
||
case <-c.stopChan:
|
||
return
|
||
default:
|
||
}
|
||
}
|
||
}
|
||
|
||
// HandleWebSocketMessages handles incoming WebSocket messages
|
||
func (c *Client) HandleWebSocketMessages() {
|
||
c.wsConnMu.Lock()
|
||
conn := c.wsConn
|
||
c.wsConnMu.Unlock()
|
||
|
||
if conn == nil {
|
||
return
|
||
}
|
||
|
||
// Set pong handler to respond to ping messages
|
||
// Also reset read deadline to keep connection alive
|
||
conn.SetPongHandler(func(string) error {
|
||
conn.SetReadDeadline(time.Now().Add(90 * time.Second)) // Increased to 90 seconds
|
||
return nil
|
||
})
|
||
|
||
// Set ping handler to respond with pong
|
||
// Also reset read deadline to keep connection alive
|
||
conn.SetPingHandler(func(string) error {
|
||
conn.SetReadDeadline(time.Now().Add(90 * time.Second)) // Increased to 90 seconds
|
||
// Respond to ping with pong - protect with write mutex
|
||
c.wsWriteMu.Lock()
|
||
defer c.wsWriteMu.Unlock()
|
||
return conn.WriteControl(websocket.PongMessage, []byte{}, time.Now().Add(10*time.Second))
|
||
})
|
||
|
||
// Set read deadline to ensure we process control frames
|
||
conn.SetReadDeadline(time.Now().Add(90 * time.Second)) // Increased to 90 seconds
|
||
|
||
// Handle messages
|
||
for {
|
||
// Reset read deadline for each message to allow ping/pong processing
|
||
conn.SetReadDeadline(time.Now().Add(90 * time.Second)) // Increased to 90 seconds
|
||
|
||
var msg map[string]interface{}
|
||
err := conn.ReadJSON(&msg)
|
||
if err != nil {
|
||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||
log.Printf("WebSocket error: %v", err)
|
||
}
|
||
c.wsConnMu.Lock()
|
||
c.wsConn = nil
|
||
c.wsConnMu.Unlock()
|
||
return
|
||
}
|
||
|
||
// Reset read deadline after successfully reading a message
|
||
// This ensures the connection stays alive as long as we're receiving messages
|
||
conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||
|
||
msgType, _ := msg["type"].(string)
|
||
switch msgType {
|
||
case "task_assignment":
|
||
c.handleTaskAssignment(msg)
|
||
case "ping":
|
||
// Respond to ping with pong (automatic)
|
||
}
|
||
}
|
||
}
|
||
|
||
// handleTaskAssignment handles a task assignment message
|
||
func (c *Client) handleTaskAssignment(msg map[string]interface{}) {
|
||
data, ok := msg["data"].(map[string]interface{})
|
||
if !ok {
|
||
log.Printf("Invalid task assignment message")
|
||
return
|
||
}
|
||
|
||
taskID, _ := data["task_id"].(float64)
|
||
jobID, _ := data["job_id"].(float64)
|
||
jobName, _ := data["job_name"].(string)
|
||
outputFormat, _ := data["output_format"].(string)
|
||
frameStart, _ := data["frame_start"].(float64)
|
||
frameEnd, _ := data["frame_end"].(float64)
|
||
taskType, _ := data["task_type"].(string)
|
||
inputFilesRaw, _ := data["input_files"].([]interface{})
|
||
|
||
// Log that task assignment was received
|
||
taskIDInt := int64(taskID)
|
||
c.sendLog(taskIDInt, types.LogLevelInfo, fmt.Sprintf("Task assignment received from manager (job: %d, type: %s, frames: %d-%d)", int64(jobID), taskType, int(frameStart), int(frameEnd)), "")
|
||
|
||
// Convert to task map format
|
||
taskMap := map[string]interface{}{
|
||
"id": taskID,
|
||
"job_id": jobID,
|
||
"frame_start": frameStart,
|
||
"frame_end": frameEnd,
|
||
}
|
||
|
||
// Process the task based on type
|
||
go func() {
|
||
var err error
|
||
switch taskType {
|
||
case "metadata":
|
||
if len(inputFilesRaw) == 0 {
|
||
log.Printf("No input files for metadata task %v", taskID)
|
||
c.sendTaskComplete(int64(taskID), "", false, "No input files")
|
||
return
|
||
}
|
||
err = c.processMetadataTask(taskMap, int64(jobID), inputFilesRaw)
|
||
case "video_generation":
|
||
err = c.processVideoGenerationTask(taskMap, int64(jobID))
|
||
default:
|
||
if len(inputFilesRaw) == 0 {
|
||
errMsg := fmt.Sprintf("No input files provided for task %d (job %d). Task assignment data: job_name=%s, output_format=%s, task_type=%s",
|
||
int64(taskID), int64(jobID), jobName, outputFormat, taskType)
|
||
log.Printf("ERROR: %s", errMsg)
|
||
c.sendLog(int64(taskID), types.LogLevelError, errMsg, "")
|
||
c.sendTaskComplete(int64(taskID), "", false, "No input files provided")
|
||
return
|
||
}
|
||
log.Printf("Processing render task %d with %d input files: %v", int64(taskID), len(inputFilesRaw), inputFilesRaw)
|
||
err = c.processTask(taskMap, jobName, outputFormat, inputFilesRaw)
|
||
}
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("Task %d failed: %v", int64(taskID), err)
|
||
log.Printf("ERROR: %s", errMsg)
|
||
c.sendLog(int64(taskID), types.LogLevelError, errMsg, "")
|
||
c.sendTaskComplete(int64(taskID), "", false, err.Error())
|
||
}
|
||
}()
|
||
}
|
||
|
||
// HeartbeatLoop sends periodic heartbeats via WebSocket
|
||
func (c *Client) HeartbeatLoop() {
|
||
ticker := time.NewTicker(30 * time.Second)
|
||
defer ticker.Stop()
|
||
|
||
for range ticker.C {
|
||
c.wsConnMu.RLock()
|
||
conn := c.wsConn
|
||
c.wsConnMu.RUnlock()
|
||
|
||
if conn != nil {
|
||
// Send heartbeat via WebSocket - protect with write mutex
|
||
c.wsWriteMu.Lock()
|
||
msg := map[string]interface{}{
|
||
"type": "heartbeat",
|
||
"timestamp": time.Now().Unix(),
|
||
}
|
||
err := conn.WriteJSON(msg)
|
||
c.wsWriteMu.Unlock()
|
||
|
||
if err != nil {
|
||
log.Printf("Failed to send heartbeat: %v", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// shouldFilterBlenderLog checks if a Blender log line should be filtered or downgraded
|
||
// Returns (shouldFilter, logLevel) - if shouldFilter is true, the log should be skipped
|
||
func shouldFilterBlenderLog(line string) (bool, types.LogLevel) {
|
||
// Filter out common Blender dependency graph noise
|
||
trimmed := strings.TrimSpace(line)
|
||
|
||
// Filter out empty lines
|
||
if trimmed == "" {
|
||
return true, types.LogLevelInfo
|
||
}
|
||
|
||
// Filter out separator lines (check both original and trimmed)
|
||
if trimmed == "--------------------------------------------------------------------" ||
|
||
strings.HasPrefix(trimmed, "-----") && strings.Contains(trimmed, "----") {
|
||
return true, types.LogLevelInfo
|
||
}
|
||
|
||
// Filter out trace headers (check both original and trimmed, case-insensitive)
|
||
upperLine := strings.ToUpper(trimmed)
|
||
upperOriginal := strings.ToUpper(line)
|
||
|
||
// Check for "Depth Type Name" - match even if words are separated by different spacing
|
||
if trimmed == "Trace:" ||
|
||
trimmed == "Depth Type Name" ||
|
||
trimmed == "----- ---- ----" ||
|
||
line == "Depth Type Name" ||
|
||
line == "----- ---- ----" ||
|
||
(strings.Contains(upperLine, "DEPTH") && strings.Contains(upperLine, "TYPE") && strings.Contains(upperLine, "NAME")) ||
|
||
(strings.Contains(upperOriginal, "DEPTH") && strings.Contains(upperOriginal, "TYPE") && strings.Contains(upperOriginal, "NAME")) ||
|
||
strings.Contains(line, "Depth Type Name") ||
|
||
strings.Contains(line, "----- ---- ----") ||
|
||
strings.HasPrefix(trimmed, "-----") ||
|
||
regexp.MustCompile(`^[-]+\s+[-]+\s+[-]+$`).MatchString(trimmed) {
|
||
return true, types.LogLevelInfo
|
||
}
|
||
|
||
// Completely filter out dependency graph messages (they're just noise)
|
||
dependencyGraphPatterns := []string{
|
||
"Failed to add relation",
|
||
"Could not find op_from",
|
||
"OperationKey",
|
||
"find_node_operation: Failed for",
|
||
"BONE_DONE",
|
||
"component name:",
|
||
"operation code:",
|
||
"rope_ctrl_rot_",
|
||
}
|
||
|
||
for _, pattern := range dependencyGraphPatterns {
|
||
if strings.Contains(line, pattern) {
|
||
return true, types.LogLevelInfo // Completely filter out
|
||
}
|
||
}
|
||
|
||
// Filter out animation system warnings (invalid drivers are common and harmless)
|
||
animationSystemPatterns := []string{
|
||
"BKE_animsys_eval_driver: invalid driver",
|
||
"bke.anim_sys",
|
||
"rotation_quaternion[",
|
||
"constraints[",
|
||
".influence[0]",
|
||
"pose.bones[",
|
||
}
|
||
|
||
for _, pattern := range animationSystemPatterns {
|
||
if strings.Contains(line, pattern) {
|
||
return true, types.LogLevelInfo // Completely filter out
|
||
}
|
||
}
|
||
|
||
// Filter out modifier warnings (common when vertices change)
|
||
modifierPatterns := []string{
|
||
"BKE_modifier_set_error",
|
||
"bke.modifier",
|
||
"Vertices changed from",
|
||
"Modifier:",
|
||
}
|
||
|
||
for _, pattern := range modifierPatterns {
|
||
if strings.Contains(line, pattern) {
|
||
return true, types.LogLevelInfo // Completely filter out
|
||
}
|
||
}
|
||
|
||
// Filter out lines that are just numbers or trace depth indicators
|
||
// Pattern: number, word, word (e.g., "1 Object timer_box_franck")
|
||
if matched, _ := regexp.MatchString(`^\d+\s+\w+\s+\w+`, trimmed); matched {
|
||
return true, types.LogLevelInfo
|
||
}
|
||
|
||
return false, types.LogLevelInfo
|
||
}
|
||
|
||
// sendLog sends a log entry to the manager via WebSocket
|
||
func (c *Client) sendLog(taskID int64, logLevel types.LogLevel, message, stepName string) {
|
||
c.wsConnMu.RLock()
|
||
conn := c.wsConn
|
||
c.wsConnMu.RUnlock()
|
||
|
||
if conn != nil {
|
||
// Serialize all WebSocket writes to prevent concurrent write panics
|
||
c.wsWriteMu.Lock()
|
||
defer c.wsWriteMu.Unlock()
|
||
|
||
msg := map[string]interface{}{
|
||
"type": "log_entry",
|
||
"data": map[string]interface{}{
|
||
"task_id": taskID,
|
||
"log_level": string(logLevel),
|
||
"message": message,
|
||
"step_name": stepName,
|
||
},
|
||
"timestamp": time.Now().Unix(),
|
||
}
|
||
if err := conn.WriteJSON(msg); err != nil {
|
||
log.Printf("Failed to send log: %v", err)
|
||
}
|
||
} else {
|
||
log.Printf("WebSocket not connected, cannot send log")
|
||
}
|
||
}
|
||
|
||
// KillAllProcesses kills all running processes tracked by this client
|
||
func (c *Client) KillAllProcesses() {
|
||
log.Printf("Killing all running processes...")
|
||
var killedCount int
|
||
c.runningProcs.Range(func(key, value interface{}) bool {
|
||
taskID := key.(int64)
|
||
cmd := value.(*exec.Cmd)
|
||
if cmd.Process != nil {
|
||
log.Printf("Killing process for task %d (PID: %d)", taskID, cmd.Process.Pid)
|
||
// Try graceful kill first (SIGTERM)
|
||
if err := cmd.Process.Signal(os.Interrupt); err != nil {
|
||
log.Printf("Failed to send SIGINT to process %d: %v", cmd.Process.Pid, err)
|
||
}
|
||
// Give it a moment to clean up
|
||
time.Sleep(100 * time.Millisecond)
|
||
// Force kill if still running
|
||
if err := cmd.Process.Kill(); err != nil {
|
||
log.Printf("Failed to kill process %d: %v", cmd.Process.Pid, err)
|
||
} else {
|
||
killedCount++
|
||
}
|
||
}
|
||
// Release any allocated device for this task
|
||
c.releaseVAAPIDevice(taskID)
|
||
return true
|
||
})
|
||
log.Printf("Killed %d process(es)", killedCount)
|
||
}
|
||
|
||
// sendStepUpdate sends a step start/complete event to the manager
|
||
func (c *Client) sendStepUpdate(taskID int64, stepName string, status types.StepStatus, errorMsg string) {
|
||
key := fmt.Sprintf("%d:%s", taskID, stepName)
|
||
var durationMs *int
|
||
|
||
// Track step start time
|
||
if status == types.StepStatusRunning {
|
||
c.stepTimesMu.Lock()
|
||
c.stepStartTimes[key] = time.Now()
|
||
c.stepTimesMu.Unlock()
|
||
}
|
||
|
||
// Calculate duration if step is completing
|
||
if status == types.StepStatusCompleted || status == types.StepStatusFailed {
|
||
c.stepTimesMu.RLock()
|
||
startTime, exists := c.stepStartTimes[key]
|
||
c.stepTimesMu.RUnlock()
|
||
if exists {
|
||
duration := int(time.Since(startTime).Milliseconds())
|
||
durationMs = &duration
|
||
c.stepTimesMu.Lock()
|
||
delete(c.stepStartTimes, key)
|
||
c.stepTimesMu.Unlock()
|
||
}
|
||
}
|
||
|
||
// Send step update via HTTP API
|
||
reqBody := map[string]interface{}{
|
||
"step_name": stepName,
|
||
"status": string(status),
|
||
}
|
||
if durationMs != nil {
|
||
reqBody["duration_ms"] = *durationMs
|
||
}
|
||
if errorMsg != "" {
|
||
reqBody["error_message"] = errorMsg
|
||
}
|
||
|
||
body, _ := json.Marshal(reqBody)
|
||
// Sign with path only (without query params) to match manager verification
|
||
path := fmt.Sprintf("/api/runner/tasks/%d/steps", taskID)
|
||
resp, err := c.doSignedRequest("POST", path, body, fmt.Sprintf("runner_id=%d", c.runnerID))
|
||
if err != nil {
|
||
log.Printf("Failed to send step update: %v", err)
|
||
// Fallback to log-based tracking
|
||
msg := fmt.Sprintf("Step %s: %s", stepName, status)
|
||
if errorMsg != "" {
|
||
msg += " - " + errorMsg
|
||
}
|
||
logLevel := types.LogLevelInfo
|
||
if status == types.StepStatusFailed {
|
||
logLevel = types.LogLevelError
|
||
}
|
||
c.sendLog(taskID, logLevel, msg, stepName)
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
log.Printf("Step update failed: %s", string(body))
|
||
// Fallback to log-based tracking
|
||
msg := fmt.Sprintf("Step %s: %s", stepName, status)
|
||
if errorMsg != "" {
|
||
msg += " - " + errorMsg
|
||
}
|
||
logLevel := types.LogLevelInfo
|
||
if status == types.StepStatusFailed {
|
||
logLevel = types.LogLevelError
|
||
}
|
||
c.sendLog(taskID, logLevel, msg, stepName)
|
||
return
|
||
}
|
||
|
||
// Also send log for debugging
|
||
msg := fmt.Sprintf("Step %s: %s", stepName, status)
|
||
if errorMsg != "" {
|
||
msg += " - " + errorMsg
|
||
}
|
||
logLevel := types.LogLevelInfo
|
||
if status == types.StepStatusFailed {
|
||
logLevel = types.LogLevelError
|
||
}
|
||
c.sendLog(taskID, logLevel, msg, stepName)
|
||
}
|
||
|
||
// processTask processes a single task
|
||
func (c *Client) processTask(task map[string]interface{}, jobName string, outputFormat string, inputFiles []interface{}) error {
|
||
_ = jobName
|
||
|
||
taskID := int64(task["id"].(float64))
|
||
jobID := int64(task["job_id"].(float64))
|
||
frameStart := int(task["frame_start"].(float64))
|
||
frameEnd := int(task["frame_end"].(float64))
|
||
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Starting task: job %d, frames %d-%d, format: %s", jobID, frameStart, frameEnd, outputFormat), "")
|
||
log.Printf("Processing task %d: job %d, frames %d-%d, format: %s (from task assignment)", taskID, jobID, frameStart, frameEnd, outputFormat)
|
||
|
||
// Create temporary job workspace within runner workspace
|
||
workDir := filepath.Join(c.getWorkspaceDir(), fmt.Sprintf("job-%d-task-%d", jobID, taskID))
|
||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||
return fmt.Errorf("failed to create work directory: %w", err)
|
||
}
|
||
defer os.RemoveAll(workDir)
|
||
|
||
// Step: download
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusRunning, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "Downloading job context...", "download")
|
||
|
||
// Clean up expired cache entries periodically
|
||
c.cleanupExpiredContextCache()
|
||
|
||
// Download context tar
|
||
contextPath := filepath.Join(workDir, "context.tar")
|
||
if err := c.downloadJobContext(jobID, contextPath); err != nil {
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusFailed, err.Error())
|
||
return fmt.Errorf("failed to download context: %w", err)
|
||
}
|
||
|
||
// Extract context tar
|
||
c.sendLog(taskID, types.LogLevelInfo, "Extracting context...", "download")
|
||
if err := c.extractTar(contextPath, workDir); err != nil {
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusFailed, err.Error())
|
||
return fmt.Errorf("failed to extract context: %w", err)
|
||
}
|
||
|
||
// Find .blend file in extracted contents
|
||
blendFile := ""
|
||
err := filepath.Walk(workDir, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".blend") {
|
||
// Check it's not a Blender save file (.blend1, .blend2, etc.)
|
||
lower := strings.ToLower(info.Name())
|
||
idx := strings.LastIndex(lower, ".blend")
|
||
if idx != -1 {
|
||
suffix := lower[idx+len(".blend"):]
|
||
// If there are digits after .blend, it's a save file
|
||
isSaveFile := false
|
||
if len(suffix) > 0 {
|
||
isSaveFile = true
|
||
for _, r := range suffix {
|
||
if r < '0' || r > '9' {
|
||
isSaveFile = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if !isSaveFile {
|
||
blendFile = path
|
||
return filepath.SkipAll // Stop walking once we find a blend file
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusFailed, err.Error())
|
||
return fmt.Errorf("failed to find blend file: %w", err)
|
||
}
|
||
|
||
if blendFile == "" {
|
||
err := fmt.Errorf("no .blend file found in context")
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusFailed, err.Error())
|
||
return err
|
||
}
|
||
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusCompleted, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "Context downloaded and extracted successfully", "download")
|
||
|
||
// Fetch job metadata to get render settings
|
||
var jobMetadata *types.BlendMetadata
|
||
metadata, err := c.getJobMetadata(jobID)
|
||
if err == nil && metadata != nil {
|
||
jobMetadata = metadata
|
||
c.sendLog(taskID, types.LogLevelInfo, "Loaded render settings from job metadata", "render_blender")
|
||
} else {
|
||
c.sendLog(taskID, types.LogLevelInfo, "No render settings found in job metadata, using blend file defaults", "render_blender")
|
||
}
|
||
|
||
// Render frames
|
||
outputDir := filepath.Join(workDir, "output")
|
||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||
return fmt.Errorf("failed to create output directory: %w", err)
|
||
}
|
||
|
||
// For EXR_264_MP4 and EXR_AV1_MP4, render as EXR (OpenEXR) first for highest fidelity, then combine into video
|
||
renderFormat := outputFormat
|
||
if outputFormat == "EXR_264_MP4" || outputFormat == "EXR_AV1_MP4" {
|
||
renderFormat = "EXR" // Use EXR for maximum quality (32-bit float, HDR)
|
||
}
|
||
|
||
// Blender uses # characters for frame number placeholders (not %04d)
|
||
// Use #### for 4-digit zero-padded frame numbers
|
||
outputPattern := filepath.Join(outputDir, fmt.Sprintf("frame_####.%s", strings.ToLower(renderFormat)))
|
||
|
||
// Step: render_blender
|
||
c.sendStepUpdate(taskID, "render_blender", types.StepStatusRunning, "")
|
||
if frameStart == frameEnd {
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Starting Blender render for frame %d...", frameStart), "render_blender")
|
||
} else {
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Starting Blender render for frames %d-%d...", frameStart, frameEnd), "render_blender")
|
||
}
|
||
|
||
// Execute Blender - use absolute path for output pattern
|
||
absOutputPattern, err := filepath.Abs(outputPattern)
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("failed to get absolute path for output: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "render_blender")
|
||
c.sendStepUpdate(taskID, "render_blender", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// Override output format and render settings from job submission
|
||
// For MP4, we render as EXR (handled above) for highest fidelity, so renderFormat is already EXR
|
||
// This script will override the blend file's settings based on job metadata
|
||
formatFilePath := filepath.Join(workDir, "output_format.txt")
|
||
renderSettingsFilePath := filepath.Join(workDir, "render_settings.json")
|
||
|
||
// Check if unhide_objects is enabled
|
||
unhideObjects := false
|
||
if jobMetadata != nil && jobMetadata.UnhideObjects != nil && *jobMetadata.UnhideObjects {
|
||
unhideObjects = true
|
||
}
|
||
|
||
// Build unhide code conditionally from embedded script
|
||
unhideCode := ""
|
||
if unhideObjects {
|
||
unhideCode = scripts.UnhideObjects
|
||
}
|
||
|
||
// Load template and replace placeholders
|
||
scriptContent := scripts.RenderBlenderTemplate
|
||
scriptContent = strings.ReplaceAll(scriptContent, "{{UNHIDE_CODE}}", unhideCode)
|
||
scriptContent = strings.ReplaceAll(scriptContent, "{{FORMAT_FILE_PATH}}", fmt.Sprintf("%q", formatFilePath))
|
||
scriptContent = strings.ReplaceAll(scriptContent, "{{RENDER_SETTINGS_FILE}}", fmt.Sprintf("%q", renderSettingsFilePath))
|
||
scriptPath := filepath.Join(workDir, "enable_gpu.py")
|
||
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
|
||
errMsg := fmt.Sprintf("failed to create GPU enable script: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "render_blender")
|
||
c.sendStepUpdate(taskID, "render_blender", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// Write output format to a temporary file for the script to read
|
||
// (Blender's argument parsing makes it tricky to pass custom args to Python scripts)
|
||
// IMPORTANT: Write the user's selected outputFormat, NOT renderFormat
|
||
// renderFormat might be "EXR" for video, but we want the user's actual selection (PNG, JPEG, etc.)
|
||
formatFile := filepath.Join(workDir, "output_format.txt")
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Writing output format '%s' to format file (user selected: '%s', render format: '%s')", outputFormat, outputFormat, renderFormat), "render_blender")
|
||
if err := os.WriteFile(formatFile, []byte(outputFormat), 0644); err != nil {
|
||
errMsg := fmt.Sprintf("failed to create format file: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "render_blender")
|
||
c.sendStepUpdate(taskID, "render_blender", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// Write render settings to a JSON file if we have metadata with render settings
|
||
renderSettingsFile := filepath.Join(workDir, "render_settings.json")
|
||
if jobMetadata != nil && jobMetadata.RenderSettings.EngineSettings != nil {
|
||
settingsJSON, err := json.Marshal(jobMetadata.RenderSettings)
|
||
if err == nil {
|
||
if err := os.WriteFile(renderSettingsFile, settingsJSON, 0644); err != nil {
|
||
c.sendLog(taskID, types.LogLevelWarn, fmt.Sprintf("Failed to write render settings file: %v", err), "render_blender")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check if execution should be enabled (defaults to false/off)
|
||
enableExecution := false
|
||
if jobMetadata != nil && jobMetadata.EnableExecution != nil && *jobMetadata.EnableExecution {
|
||
enableExecution = true
|
||
}
|
||
|
||
// Run Blender with GPU enabled via Python script
|
||
// Use -s (start) and -e (end) for frame ranges, or -f for single frame
|
||
var cmd *exec.Cmd
|
||
args := []string{"-b", blendFile, "--python", scriptPath}
|
||
if enableExecution {
|
||
args = append(args, "--enable-autoexec")
|
||
}
|
||
if frameStart == frameEnd {
|
||
// Single frame
|
||
args = append(args, "-o", absOutputPattern, "-f", fmt.Sprintf("%d", frameStart))
|
||
cmd = exec.Command("blender", args...)
|
||
} else {
|
||
// Frame range
|
||
args = append(args, "-o", absOutputPattern,
|
||
"-s", fmt.Sprintf("%d", frameStart),
|
||
"-e", fmt.Sprintf("%d", frameEnd),
|
||
"-a") // -a renders animation (all frames in range)
|
||
cmd = exec.Command("blender", args...)
|
||
}
|
||
cmd.Dir = workDir
|
||
|
||
// Set environment variables for headless rendering
|
||
// This helps ensure proper OpenGL context initialization, especially for EEVEE
|
||
cmd.Env = os.Environ()
|
||
// Blender will handle headless rendering automatically
|
||
// We preserve the environment to allow GPU access if available
|
||
|
||
// Capture stdout and stderr separately for line-by-line streaming
|
||
stdoutPipe, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("failed to create stdout pipe: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "render_blender")
|
||
c.sendStepUpdate(taskID, "render_blender", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
stderrPipe, err := cmd.StderrPipe()
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("failed to create stderr pipe: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "render_blender")
|
||
c.sendStepUpdate(taskID, "render_blender", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// Start the command
|
||
if err := cmd.Start(); err != nil {
|
||
errMsg := fmt.Sprintf("failed to start blender: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "render_blender")
|
||
c.sendStepUpdate(taskID, "render_blender", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// Register process for cleanup on shutdown
|
||
c.runningProcs.Store(taskID, cmd)
|
||
defer c.runningProcs.Delete(taskID)
|
||
|
||
// Stream stdout line by line
|
||
stdoutDone := make(chan bool)
|
||
go func() {
|
||
defer close(stdoutDone)
|
||
scanner := bufio.NewScanner(stdoutPipe)
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
if line != "" {
|
||
shouldFilter, logLevel := shouldFilterBlenderLog(line)
|
||
if !shouldFilter {
|
||
c.sendLog(taskID, logLevel, line, "render_blender")
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
|
||
// Stream stderr line by line
|
||
stderrDone := make(chan bool)
|
||
go func() {
|
||
defer close(stderrDone)
|
||
scanner := bufio.NewScanner(stderrPipe)
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
if line != "" {
|
||
shouldFilter, logLevel := shouldFilterBlenderLog(line)
|
||
if !shouldFilter {
|
||
// Use the filtered log level, but if it's still WARN, keep it as WARN
|
||
if logLevel == types.LogLevelInfo {
|
||
logLevel = types.LogLevelWarn
|
||
}
|
||
c.sendLog(taskID, logLevel, line, "render_blender")
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
|
||
// Wait for command to complete
|
||
err = cmd.Wait()
|
||
|
||
// Wait for streaming goroutines to finish
|
||
<-stdoutDone
|
||
<-stderrDone
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("blender failed: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "render_blender")
|
||
c.sendStepUpdate(taskID, "render_blender", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// Find rendered output file(s)
|
||
// For frame ranges, we'll find all frames in the upload step
|
||
// For single frames, we need to find the specific output file
|
||
outputFile := ""
|
||
|
||
// Only check for single output file if it's a single frame render
|
||
if frameStart == frameEnd {
|
||
// List all files in output directory to find what Blender actually created
|
||
entries, err := os.ReadDir(outputDir)
|
||
if err == nil {
|
||
c.sendLog(taskID, types.LogLevelInfo, "Checking output directory for files...", "render_blender")
|
||
|
||
// Try exact match first: frame_0155.png
|
||
expectedFile := filepath.Join(outputDir, fmt.Sprintf("frame_%04d.%s", frameStart, strings.ToLower(renderFormat)))
|
||
if _, err := os.Stat(expectedFile); err == nil {
|
||
outputFile = expectedFile
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Found output file: %s", filepath.Base(expectedFile)), "render_blender")
|
||
} else {
|
||
// Try without zero padding: frame_155.png
|
||
altFile := filepath.Join(outputDir, fmt.Sprintf("frame_%d.%s", frameStart, strings.ToLower(renderFormat)))
|
||
if _, err := os.Stat(altFile); err == nil {
|
||
outputFile = altFile
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Found output file: %s", filepath.Base(altFile)), "render_blender")
|
||
} else {
|
||
// Try just frame number: 0155.png or 155.png
|
||
altFile2 := filepath.Join(outputDir, fmt.Sprintf("%04d.%s", frameStart, strings.ToLower(renderFormat)))
|
||
if _, err := os.Stat(altFile2); err == nil {
|
||
outputFile = altFile2
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Found output file: %s", filepath.Base(altFile2)), "render_blender")
|
||
} else {
|
||
// Search through all files for one containing the frame number
|
||
for _, entry := range entries {
|
||
if !entry.IsDir() {
|
||
fileName := entry.Name()
|
||
// Skip files that contain the literal pattern string (Blender bug)
|
||
if strings.Contains(fileName, "%04d") || strings.Contains(fileName, "%d") {
|
||
c.sendLog(taskID, types.LogLevelWarn, fmt.Sprintf("Skipping file with literal pattern: %s", fileName), "render_blender")
|
||
continue
|
||
}
|
||
// Check if filename contains the frame number (with or without padding)
|
||
frameStr := fmt.Sprintf("%d", frameStart)
|
||
frameStrPadded := fmt.Sprintf("%04d", frameStart)
|
||
if strings.Contains(fileName, frameStrPadded) ||
|
||
(strings.Contains(fileName, frameStr) && strings.HasSuffix(strings.ToLower(fileName), strings.ToLower(renderFormat))) {
|
||
outputFile = filepath.Join(outputDir, fileName)
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Found output file: %s", fileName), "render_blender")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if outputFile == "" {
|
||
// List all files in output directory for debugging
|
||
entries, _ := os.ReadDir(outputDir)
|
||
fileList := []string{}
|
||
for _, entry := range entries {
|
||
if !entry.IsDir() {
|
||
fileList = append(fileList, entry.Name())
|
||
}
|
||
}
|
||
expectedFile := filepath.Join(outputDir, fmt.Sprintf("frame_%04d.%s", frameStart, strings.ToLower(renderFormat)))
|
||
errMsg := fmt.Sprintf("output file not found: %s\nFiles in output directory: %v",
|
||
expectedFile, fileList)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "render_blender")
|
||
c.sendStepUpdate(taskID, "render_blender", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Blender render completed for frame %d", frameStart), "render_blender")
|
||
} else {
|
||
// Frame range - Blender renders multiple frames, we'll find them all in the upload step
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Blender render completed for frames %d-%d", frameStart, frameEnd), "render_blender")
|
||
}
|
||
c.sendStepUpdate(taskID, "render_blender", types.StepStatusCompleted, "")
|
||
|
||
// Step: upload or upload_frames
|
||
uploadStepName := "upload"
|
||
if outputFormat == "EXR_264_MP4" || outputFormat == "EXR_AV1_MP4" {
|
||
uploadStepName = "upload_frames"
|
||
}
|
||
c.sendStepUpdate(taskID, uploadStepName, types.StepStatusRunning, "")
|
||
|
||
var outputPath string
|
||
// If we have a frame range, find and upload all frames
|
||
if frameStart != frameEnd {
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Uploading frames %d-%d...", frameStart, frameEnd), uploadStepName)
|
||
|
||
// Find all rendered frames in the output directory
|
||
var frameFiles []string
|
||
entries, err := os.ReadDir(outputDir)
|
||
if err == nil {
|
||
for frame := frameStart; frame <= frameEnd; frame++ {
|
||
// Try different naming patterns
|
||
patterns := []string{
|
||
fmt.Sprintf("frame_%04d.%s", frame, strings.ToLower(renderFormat)),
|
||
fmt.Sprintf("frame_%d.%s", frame, strings.ToLower(renderFormat)),
|
||
fmt.Sprintf("%04d.%s", frame, strings.ToLower(renderFormat)),
|
||
fmt.Sprintf("%d.%s", frame, strings.ToLower(renderFormat)),
|
||
}
|
||
|
||
found := false
|
||
for _, pattern := range patterns {
|
||
framePath := filepath.Join(outputDir, pattern)
|
||
if _, err := os.Stat(framePath); err == nil {
|
||
frameFiles = append(frameFiles, framePath)
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
|
||
// If not found with patterns, search through entries
|
||
if !found {
|
||
frameStr := fmt.Sprintf("%d", frame)
|
||
frameStrPadded := fmt.Sprintf("%04d", frame)
|
||
for _, entry := range entries {
|
||
if entry.IsDir() {
|
||
continue
|
||
}
|
||
fileName := entry.Name()
|
||
// Skip files with literal pattern strings
|
||
if strings.Contains(fileName, "%04d") || strings.Contains(fileName, "%d") {
|
||
continue
|
||
}
|
||
// Check if filename contains the frame number
|
||
fullPath := filepath.Join(outputDir, fileName)
|
||
alreadyAdded := false
|
||
for _, existing := range frameFiles {
|
||
if existing == fullPath {
|
||
alreadyAdded = true
|
||
break
|
||
}
|
||
}
|
||
if !alreadyAdded &&
|
||
(strings.Contains(fileName, frameStrPadded) ||
|
||
(strings.Contains(fileName, frameStr) && strings.HasSuffix(strings.ToLower(fileName), strings.ToLower(renderFormat)))) {
|
||
frameFiles = append(frameFiles, fullPath)
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(frameFiles) == 0 {
|
||
errMsg := fmt.Sprintf("no frame files found for range %d-%d", frameStart, frameEnd)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, uploadStepName)
|
||
c.sendStepUpdate(taskID, uploadStepName, types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// Upload all frames
|
||
uploadedCount := 0
|
||
uploadedFiles := []string{}
|
||
for i, frameFile := range frameFiles {
|
||
fileName := filepath.Base(frameFile)
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Uploading frame %d/%d: %s", i+1, len(frameFiles), fileName), uploadStepName)
|
||
uploadedPath, err := c.uploadFile(jobID, frameFile)
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("failed to upload frame %s: %v", fileName, err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, uploadStepName)
|
||
c.sendStepUpdate(taskID, uploadStepName, types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
uploadedCount++
|
||
uploadedFiles = append(uploadedFiles, fileName)
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Uploaded frame %d/%d: %s -> %s", i+1, len(frameFiles), fileName, uploadedPath), uploadStepName)
|
||
}
|
||
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Successfully uploaded %d frames: %v", uploadedCount, uploadedFiles), uploadStepName)
|
||
c.sendStepUpdate(taskID, uploadStepName, types.StepStatusCompleted, "")
|
||
outputPath = "" // Not used for frame ranges, frames are uploaded individually
|
||
} else {
|
||
// Single frame upload
|
||
fileName := filepath.Base(outputFile)
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Uploading output file: %s", fileName), uploadStepName)
|
||
outputPath, err = c.uploadFile(jobID, outputFile)
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("failed to upload output file %s: %v", fileName, err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, uploadStepName)
|
||
c.sendStepUpdate(taskID, uploadStepName, types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Output file uploaded successfully: %s -> %s", fileName, outputPath), uploadStepName)
|
||
c.sendStepUpdate(taskID, uploadStepName, types.StepStatusCompleted, "")
|
||
}
|
||
|
||
// Step: complete
|
||
c.sendStepUpdate(taskID, "complete", types.StepStatusRunning, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "Task completed successfully", "complete")
|
||
|
||
// Mark task as complete
|
||
if err := c.completeTask(taskID, outputPath, true, ""); err != nil {
|
||
c.sendStepUpdate(taskID, "complete", types.StepStatusFailed, err.Error())
|
||
return err
|
||
}
|
||
c.sendStepUpdate(taskID, "complete", types.StepStatusCompleted, "")
|
||
|
||
return nil
|
||
}
|
||
|
||
// processVideoGenerationTask processes a video generation task
|
||
func (c *Client) processVideoGenerationTask(task map[string]interface{}, jobID int64) error {
|
||
taskID := int64(task["id"].(float64))
|
||
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Starting video generation task: job %d", jobID), "")
|
||
log.Printf("Processing video generation task %d for job %d", taskID, jobID)
|
||
|
||
// Get job metadata to determine output format
|
||
jobMetadata, err := c.getJobMetadata(jobID)
|
||
var outputFormat string
|
||
if err == nil && jobMetadata != nil && jobMetadata.RenderSettings.OutputFormat != "" {
|
||
outputFormat = jobMetadata.RenderSettings.OutputFormat
|
||
} else {
|
||
// Fallback: try to get from task data or default to EXR_264_MP4
|
||
if format, ok := task["output_format"].(string); ok {
|
||
outputFormat = format
|
||
} else {
|
||
outputFormat = "EXR_264_MP4" // Default
|
||
}
|
||
}
|
||
|
||
// Get all output files for this job
|
||
files, err := c.getJobFiles(jobID)
|
||
if err != nil {
|
||
c.sendStepUpdate(taskID, "get_files", types.StepStatusFailed, err.Error())
|
||
return fmt.Errorf("failed to get job files: %w", err)
|
||
}
|
||
|
||
// Find all EXR frame files (MP4 is rendered as EXR for highest fidelity - 32-bit float HDR)
|
||
var exrFiles []map[string]interface{}
|
||
for _, file := range files {
|
||
fileType, _ := file["file_type"].(string)
|
||
fileName, _ := file["file_name"].(string)
|
||
// Check for both .exr and .EXR extensions
|
||
if fileType == "output" && (strings.HasSuffix(strings.ToLower(fileName), ".exr") || strings.HasSuffix(fileName, ".EXR")) {
|
||
exrFiles = append(exrFiles, file)
|
||
}
|
||
}
|
||
|
||
if len(exrFiles) == 0 {
|
||
err := fmt.Errorf("no EXR frame files found for MP4 generation")
|
||
c.sendStepUpdate(taskID, "get_files", types.StepStatusFailed, err.Error())
|
||
return err
|
||
}
|
||
|
||
c.sendStepUpdate(taskID, "get_files", types.StepStatusCompleted, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Found %d EXR frames for video generation (highest fidelity - 32-bit HDR)", len(exrFiles)), "get_files")
|
||
|
||
log.Printf("Generating MP4 for job %d from %d EXR frames", jobID, len(exrFiles))
|
||
|
||
// Step: download_frames
|
||
c.sendStepUpdate(taskID, "download_frames", types.StepStatusRunning, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "Downloading EXR frames...", "download_frames")
|
||
|
||
// Create temporary job workspace for video generation within runner workspace
|
||
workDir := filepath.Join(c.getWorkspaceDir(), fmt.Sprintf("job-%d-video", jobID))
|
||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||
c.sendStepUpdate(taskID, "download_frames", types.StepStatusFailed, err.Error())
|
||
return fmt.Errorf("failed to create work directory: %w", err)
|
||
}
|
||
defer os.RemoveAll(workDir)
|
||
|
||
// Download all EXR frames
|
||
var frameFiles []string
|
||
for _, file := range exrFiles {
|
||
fileName, _ := file["file_name"].(string)
|
||
framePath := filepath.Join(workDir, fileName)
|
||
if err := c.downloadFrameFile(jobID, fileName, framePath); err != nil {
|
||
log.Printf("Failed to download frame %s: %v", fileName, err)
|
||
continue
|
||
}
|
||
frameFiles = append(frameFiles, framePath)
|
||
}
|
||
|
||
if len(frameFiles) == 0 {
|
||
err := fmt.Errorf("failed to download any frame files")
|
||
c.sendStepUpdate(taskID, "download_frames", types.StepStatusFailed, err.Error())
|
||
return err
|
||
}
|
||
|
||
// Sort frame files by name to ensure correct order
|
||
sort.Strings(frameFiles)
|
||
c.sendStepUpdate(taskID, "download_frames", types.StepStatusCompleted, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Downloaded %d frames", len(frameFiles)), "download_frames")
|
||
|
||
// Step: generate_video
|
||
c.sendStepUpdate(taskID, "generate_video", types.StepStatusRunning, "")
|
||
|
||
// Determine codec and pixel format based on output format
|
||
var codec string
|
||
var pixFmt string
|
||
var useAlpha bool
|
||
|
||
if outputFormat == "EXR_AV1_MP4" {
|
||
codec = "libaom-av1"
|
||
pixFmt = "yuva420p" // AV1 with alpha channel
|
||
useAlpha = true
|
||
c.sendLog(taskID, types.LogLevelInfo, "Generating MP4 video with AV1 codec (with alpha channel)...", "generate_video")
|
||
} else {
|
||
// Default to H.264 for EXR_264_MP4
|
||
codec = "libx264"
|
||
pixFmt = "yuv420p" // H.264 without alpha
|
||
useAlpha = false
|
||
c.sendLog(taskID, types.LogLevelInfo, "Generating MP4 video with H.264 codec...", "generate_video")
|
||
}
|
||
|
||
// Generate MP4 using ffmpeg
|
||
outputMP4 := filepath.Join(workDir, fmt.Sprintf("output_%d.mp4", jobID))
|
||
|
||
// Use ffmpeg to combine EXR frames into MP4
|
||
// Method 1: Using image sequence input (more reliable)
|
||
firstFrame := frameFiles[0]
|
||
// Extract frame number pattern (e.g., frame_2470.exr -> frame_%04d.exr)
|
||
baseName := filepath.Base(firstFrame)
|
||
// Find the numeric part and replace it with %04d pattern
|
||
// Use regex to find digits after underscore and before extension
|
||
re := regexp.MustCompile(`_(\d+)\.`)
|
||
var pattern string
|
||
var startNumber int
|
||
frameNumStr := re.FindStringSubmatch(baseName)
|
||
if len(frameNumStr) > 1 {
|
||
// Replace the numeric part with %04d
|
||
pattern = re.ReplaceAllString(baseName, "_%04d.")
|
||
// Extract the starting frame number
|
||
fmt.Sscanf(frameNumStr[1], "%d", &startNumber)
|
||
} else {
|
||
// Fallback: try simple replacement
|
||
startNumber = extractFrameNumber(baseName)
|
||
pattern = strings.Replace(baseName, fmt.Sprintf("%d", startNumber), "%04d", 1)
|
||
}
|
||
patternPath := filepath.Join(workDir, pattern)
|
||
|
||
// Allocate a VAAPI device for this task (if available)
|
||
allocatedDevice := c.allocateVAAPIDevice(taskID)
|
||
defer c.releaseVAAPIDevice(taskID) // Always release the device when done
|
||
if allocatedDevice != "" {
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Using VAAPI device: %s", allocatedDevice), "generate_video")
|
||
} else {
|
||
c.sendLog(taskID, types.LogLevelInfo, "No VAAPI device available, will use software encoding or other hardware", "generate_video")
|
||
}
|
||
|
||
// Run ffmpeg to combine EXR frames into MP4 at 24 fps
|
||
// EXR is 32-bit float HDR format - FFmpeg will automatically tonemap to 8-bit/10-bit for video
|
||
// Use -start_number to tell ffmpeg the starting frame number
|
||
var cmd *exec.Cmd
|
||
var useHardware bool
|
||
|
||
if outputFormat == "EXR_AV1_MP4" {
|
||
// Try AV1 hardware acceleration
|
||
cmd, err = c.buildFFmpegCommandAV1(allocatedDevice, useAlpha, "-y", "-start_number", fmt.Sprintf("%d", startNumber),
|
||
"-framerate", "24", "-i", patternPath,
|
||
"-r", "24", outputMP4)
|
||
if err == nil {
|
||
useHardware = true
|
||
c.sendLog(taskID, types.LogLevelInfo, "Using AV1 hardware acceleration", "generate_video")
|
||
} else {
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("AV1 hardware acceleration not available, will use software: %v", err), "generate_video")
|
||
}
|
||
} else {
|
||
// Try H.264 hardware acceleration
|
||
if allocatedDevice != "" {
|
||
cmd, err = c.buildFFmpegCommand(allocatedDevice, "-y", "-start_number", fmt.Sprintf("%d", startNumber),
|
||
"-framerate", "24", "-i", patternPath,
|
||
"-r", "24", outputMP4)
|
||
if err == nil {
|
||
useHardware = true
|
||
} else {
|
||
allocatedDevice = "" // Fall back to software
|
||
}
|
||
}
|
||
}
|
||
|
||
if !useHardware {
|
||
// Software encoding with HDR tonemapping
|
||
// Build video filter for HDR to SDR conversion
|
||
var vf string
|
||
if useAlpha {
|
||
// For AV1 with alpha: preserve alpha channel during tonemapping
|
||
vf = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuva420p"
|
||
} else {
|
||
// For H.264 without alpha: standard tonemapping
|
||
vf = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
|
||
}
|
||
|
||
cmd = exec.Command("ffmpeg", "-y", "-start_number", fmt.Sprintf("%d", startNumber),
|
||
"-framerate", "24", "-i", patternPath,
|
||
"-vf", vf,
|
||
"-c:v", codec, "-pix_fmt", pixFmt, "-r", "24", outputMP4)
|
||
|
||
if outputFormat == "EXR_AV1_MP4" {
|
||
// AV1 encoding options for quality
|
||
cmd.Args = append(cmd.Args, "-cpu-used", "4", "-crf", "30", "-b:v", "0")
|
||
}
|
||
}
|
||
cmd.Dir = workDir
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
outputStr := string(output)
|
||
|
||
// Check for size-related errors and provide helpful messages
|
||
if sizeErr := c.checkFFmpegSizeError(outputStr); sizeErr != nil {
|
||
c.sendLog(taskID, types.LogLevelError, sizeErr.Error(), "generate_video")
|
||
c.sendStepUpdate(taskID, "generate_video", types.StepStatusFailed, sizeErr.Error())
|
||
return sizeErr
|
||
}
|
||
|
||
// Try alternative method with concat demuxer
|
||
log.Printf("First ffmpeg attempt failed, trying concat method: %s", outputStr)
|
||
err = c.generateMP4WithConcat(frameFiles, outputMP4, workDir, allocatedDevice, outputFormat, codec, pixFmt, useAlpha, useHardware)
|
||
if err != nil {
|
||
// Check for size errors in concat method too
|
||
if sizeErr := c.checkFFmpegSizeError(err.Error()); sizeErr != nil {
|
||
c.sendLog(taskID, types.LogLevelError, sizeErr.Error(), "generate_video")
|
||
c.sendStepUpdate(taskID, "generate_video", types.StepStatusFailed, sizeErr.Error())
|
||
return sizeErr
|
||
}
|
||
c.sendStepUpdate(taskID, "generate_video", types.StepStatusFailed, err.Error())
|
||
return err
|
||
}
|
||
}
|
||
|
||
// Check if MP4 was created
|
||
if _, err := os.Stat(outputMP4); os.IsNotExist(err) {
|
||
err := fmt.Errorf("MP4 file not created: %s", outputMP4)
|
||
c.sendStepUpdate(taskID, "generate_video", types.StepStatusFailed, err.Error())
|
||
return err
|
||
}
|
||
|
||
c.sendStepUpdate(taskID, "generate_video", types.StepStatusCompleted, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "MP4 video generated successfully", "generate_video")
|
||
|
||
// Step: upload_video
|
||
c.sendStepUpdate(taskID, "upload_video", types.StepStatusRunning, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "Uploading MP4 video...", "upload_video")
|
||
|
||
// Upload MP4 file
|
||
mp4Path, err := c.uploadFile(jobID, outputMP4)
|
||
if err != nil {
|
||
c.sendStepUpdate(taskID, "upload_video", types.StepStatusFailed, err.Error())
|
||
return fmt.Errorf("failed to upload MP4: %w", err)
|
||
}
|
||
|
||
c.sendStepUpdate(taskID, "upload_video", types.StepStatusCompleted, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Successfully uploaded MP4: %s", mp4Path), "upload_video")
|
||
|
||
// Mark task as complete
|
||
if err := c.completeTask(taskID, mp4Path, true, ""); err != nil {
|
||
return err
|
||
}
|
||
|
||
log.Printf("Successfully generated and uploaded MP4 for job %d: %s", jobID, mp4Path)
|
||
return nil
|
||
}
|
||
|
||
// buildFFmpegCommand builds an ffmpeg command with hardware acceleration if available
|
||
// If device is provided (non-empty), it will be used for VAAPI encoding
|
||
// Returns the command and any error encountered during detection
|
||
func (c *Client) buildFFmpegCommand(device string, args ...string) (*exec.Cmd, error) {
|
||
// Try hardware encoders in order of preference
|
||
// Priority: NVENC (NVIDIA) > VideoToolbox (macOS) > VAAPI (Intel/AMD Linux) > AMF (AMD Windows) > software fallback
|
||
|
||
// Check for NVIDIA NVENC
|
||
if c.checkEncoderAvailable("h264_nvenc") {
|
||
// Insert hardware encoding args before output file
|
||
outputIdx := len(args) - 1
|
||
hwArgs := []string{"-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "10M", "-maxrate", "12M", "-bufsize", "20M", "-pix_fmt", "yuv420p"}
|
||
newArgs := make([]string, 0, len(args)+len(hwArgs))
|
||
newArgs = append(newArgs, args[:outputIdx]...)
|
||
newArgs = append(newArgs, hwArgs...)
|
||
newArgs = append(newArgs, args[outputIdx:]...)
|
||
return exec.Command("ffmpeg", newArgs...), nil
|
||
}
|
||
|
||
// Check for VideoToolbox (macOS)
|
||
if c.checkEncoderAvailable("h264_videotoolbox") {
|
||
outputIdx := len(args) - 1
|
||
hwArgs := []string{"-c:v", "h264_videotoolbox", "-b:v", "10M", "-pix_fmt", "yuv420p"}
|
||
newArgs := make([]string, 0, len(args)+len(hwArgs))
|
||
newArgs = append(newArgs, args[:outputIdx]...)
|
||
newArgs = append(newArgs, hwArgs...)
|
||
newArgs = append(newArgs, args[outputIdx:]...)
|
||
return exec.Command("ffmpeg", newArgs...), nil
|
||
}
|
||
|
||
// Check for VAAPI (Intel/AMD on Linux)
|
||
if c.checkEncoderAvailable("h264_vaapi") {
|
||
// Use provided device if available, otherwise get the first available
|
||
vaapiDevice := device
|
||
if vaapiDevice == "" {
|
||
vaapiDevice = c.getVAAPIDevice()
|
||
}
|
||
|
||
if vaapiDevice != "" {
|
||
outputIdx := len(args) - 1
|
||
hwArgs := []string{"-vaapi_device", vaapiDevice, "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-b:v", "10M", "-pix_fmt", "yuv420p"}
|
||
newArgs := make([]string, 0, len(args)+len(hwArgs))
|
||
newArgs = append(newArgs, args[:outputIdx]...)
|
||
newArgs = append(newArgs, hwArgs...)
|
||
newArgs = append(newArgs, args[outputIdx:]...)
|
||
return exec.Command("ffmpeg", newArgs...), nil
|
||
}
|
||
}
|
||
|
||
// Check for AMF (AMD on Windows)
|
||
if c.checkEncoderAvailable("h264_amf") {
|
||
outputIdx := len(args) - 1
|
||
hwArgs := []string{"-c:v", "h264_amf", "-quality", "balanced", "-b:v", "10M", "-pix_fmt", "yuv420p"}
|
||
newArgs := make([]string, 0, len(args)+len(hwArgs))
|
||
newArgs = append(newArgs, args[:outputIdx]...)
|
||
newArgs = append(newArgs, hwArgs...)
|
||
newArgs = append(newArgs, args[outputIdx:]...)
|
||
return exec.Command("ffmpeg", newArgs...), nil
|
||
}
|
||
|
||
// Check for Intel Quick Sync (QSV)
|
||
if c.checkEncoderAvailable("h264_qsv") {
|
||
outputIdx := len(args) - 1
|
||
hwArgs := []string{"-c:v", "h264_qsv", "-preset", "medium", "-b:v", "10M", "-pix_fmt", "yuv420p"}
|
||
newArgs := make([]string, 0, len(args)+len(hwArgs))
|
||
newArgs = append(newArgs, args[:outputIdx]...)
|
||
newArgs = append(newArgs, hwArgs...)
|
||
newArgs = append(newArgs, args[outputIdx:]...)
|
||
return exec.Command("ffmpeg", newArgs...), nil
|
||
}
|
||
|
||
// No hardware acceleration available
|
||
return nil, fmt.Errorf("no hardware encoder available")
|
||
}
|
||
|
||
// buildFFmpegCommandAV1 builds an ffmpeg command with AV1 hardware acceleration if available
|
||
// If device is provided (non-empty), it will be used for VAAPI encoding
|
||
// useAlpha indicates if alpha channel should be preserved
|
||
// Returns the command and any error encountered during detection
|
||
func (c *Client) buildFFmpegCommandAV1(device string, useAlpha bool, args ...string) (*exec.Cmd, error) {
|
||
// Try AV1 hardware encoders in order of preference
|
||
// Priority: NVENC (NVIDIA) > QSV (Intel) > VAAPI (Intel/AMD Linux) > AMF (AMD Windows) > software fallback
|
||
// Note: Hardware AV1 encoders may not support alpha, so we may need to fall back to software
|
||
|
||
// Build HDR tonemapping filter for EXR input
|
||
// Hardware encoders need the input to be tonemapped first
|
||
var tonemapFilter string
|
||
if useAlpha {
|
||
tonemapFilter = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuva420p"
|
||
} else {
|
||
tonemapFilter = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
|
||
}
|
||
|
||
// Check for NVIDIA NVENC AV1 (RTX 40 series and newer)
|
||
if c.checkEncoderAvailable("av1_nvenc") {
|
||
outputIdx := len(args) - 1
|
||
// AV1 NVENC may support alpha, but let's use yuva420p only if useAlpha is true
|
||
pixFmt := "yuv420p"
|
||
if useAlpha {
|
||
// Check if av1_nvenc supports alpha (it should on newer drivers)
|
||
pixFmt = "yuva420p"
|
||
}
|
||
// Insert tonemapping filter and hardware encoding args before output file
|
||
hwArgs := []string{"-vf", tonemapFilter, "-c:v", "av1_nvenc", "-preset", "p4", "-b:v", "10M", "-maxrate", "12M", "-bufsize", "20M", "-pix_fmt", pixFmt}
|
||
newArgs := make([]string, 0, len(args)+len(hwArgs))
|
||
newArgs = append(newArgs, args[:outputIdx]...)
|
||
newArgs = append(newArgs, hwArgs...)
|
||
newArgs = append(newArgs, args[outputIdx:]...)
|
||
return exec.Command("ffmpeg", newArgs...), nil
|
||
}
|
||
|
||
// Check for Intel Quick Sync AV1 (Arc GPUs and newer)
|
||
if c.checkEncoderAvailable("av1_qsv") {
|
||
outputIdx := len(args) - 1
|
||
pixFmt := "yuv420p"
|
||
if useAlpha {
|
||
// QSV AV1 may support alpha on newer hardware
|
||
pixFmt = "yuva420p"
|
||
}
|
||
// Insert tonemapping filter and hardware encoding args
|
||
hwArgs := []string{"-vf", tonemapFilter, "-c:v", "av1_qsv", "-preset", "medium", "-b:v", "10M", "-pix_fmt", pixFmt}
|
||
newArgs := make([]string, 0, len(args)+len(hwArgs))
|
||
newArgs = append(newArgs, args[:outputIdx]...)
|
||
newArgs = append(newArgs, hwArgs...)
|
||
newArgs = append(newArgs, args[outputIdx:]...)
|
||
return exec.Command("ffmpeg", newArgs...), nil
|
||
}
|
||
|
||
// Check for VAAPI AV1 (Intel/AMD on Linux, newer hardware)
|
||
if c.checkEncoderAvailable("av1_vaapi") {
|
||
// Use provided device if available, otherwise get the first available
|
||
vaapiDevice := device
|
||
if vaapiDevice == "" {
|
||
vaapiDevice = c.getVAAPIDevice()
|
||
}
|
||
|
||
if vaapiDevice != "" {
|
||
outputIdx := len(args) - 1
|
||
pixFmt := "yuv420p"
|
||
vaapiFilter := tonemapFilter
|
||
if useAlpha {
|
||
// VAAPI AV1 may support alpha on newer hardware
|
||
// Note: VAAPI may need format conversion before hwupload
|
||
pixFmt = "yuva420p"
|
||
}
|
||
// For VAAPI, we need to tonemap first, then convert format and upload to hardware
|
||
vaapiFilter = vaapiFilter + ",format=nv12,hwupload"
|
||
hwArgs := []string{"-vaapi_device", vaapiDevice, "-vf", vaapiFilter, "-c:v", "av1_vaapi", "-b:v", "10M", "-pix_fmt", pixFmt}
|
||
newArgs := make([]string, 0, len(args)+len(hwArgs))
|
||
newArgs = append(newArgs, args[:outputIdx]...)
|
||
newArgs = append(newArgs, hwArgs...)
|
||
newArgs = append(newArgs, args[outputIdx:]...)
|
||
return exec.Command("ffmpeg", newArgs...), nil
|
||
}
|
||
}
|
||
|
||
// Check for AMD AMF AV1 (newer AMD GPUs)
|
||
if c.checkEncoderAvailable("av1_amf") {
|
||
outputIdx := len(args) - 1
|
||
pixFmt := "yuv420p"
|
||
if useAlpha {
|
||
// AMF AV1 may support alpha on newer hardware
|
||
pixFmt = "yuva420p"
|
||
}
|
||
// Insert tonemapping filter and hardware encoding args
|
||
hwArgs := []string{"-vf", tonemapFilter, "-c:v", "av1_amf", "-quality", "balanced", "-b:v", "10M", "-pix_fmt", pixFmt}
|
||
newArgs := make([]string, 0, len(args)+len(hwArgs))
|
||
newArgs = append(newArgs, args[:outputIdx]...)
|
||
newArgs = append(newArgs, hwArgs...)
|
||
newArgs = append(newArgs, args[outputIdx:]...)
|
||
return exec.Command("ffmpeg", newArgs...), nil
|
||
}
|
||
|
||
// No AV1 hardware acceleration available
|
||
return nil, fmt.Errorf("no AV1 hardware encoder available")
|
||
}
|
||
|
||
// probeAllHardwareAccelerators probes ffmpeg for all available hardware acceleration methods
|
||
// Returns a map of hwaccel method -> true/false
|
||
func (c *Client) probeAllHardwareAccelerators() map[string]bool {
|
||
hwaccels := make(map[string]bool)
|
||
|
||
cmd := exec.Command("ffmpeg", "-hide_banner", "-hwaccels")
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
log.Printf("Failed to probe hardware accelerators: %v", err)
|
||
return hwaccels
|
||
}
|
||
|
||
// Parse output - hwaccels are listed one per line after "Hardware acceleration methods:"
|
||
outputStr := string(output)
|
||
lines := strings.Split(outputStr, "\n")
|
||
inHwaccelsSection := false
|
||
|
||
for _, line := range lines {
|
||
line = strings.TrimSpace(line)
|
||
if strings.Contains(line, "Hardware acceleration methods:") {
|
||
inHwaccelsSection = true
|
||
continue
|
||
}
|
||
if inHwaccelsSection {
|
||
if line == "" {
|
||
break
|
||
}
|
||
// Each hwaccel is on its own line
|
||
hwaccel := strings.TrimSpace(line)
|
||
if hwaccel != "" {
|
||
hwaccels[hwaccel] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
return hwaccels
|
||
}
|
||
|
||
// probeAllHardwareEncoders probes ffmpeg for all available hardware encoders
|
||
// Returns a map of encoder name -> true/false
|
||
func (c *Client) probeAllHardwareEncoders() map[string]bool {
|
||
encoders := make(map[string]bool)
|
||
|
||
cmd := exec.Command("ffmpeg", "-hide_banner", "-encoders")
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
log.Printf("Failed to probe encoders: %v", err)
|
||
return encoders
|
||
}
|
||
|
||
// Parse output - encoders are listed with format: " V..... h264_nvenc"
|
||
outputStr := string(output)
|
||
lines := strings.Split(outputStr, "\n")
|
||
inEncodersSection := false
|
||
|
||
// Common hardware encoder patterns
|
||
hwPatterns := []string{
|
||
"_nvenc", "_vaapi", "_qsv", "_videotoolbox", "_amf", "_v4l2m2m", "_omx", "_mediacodec",
|
||
}
|
||
|
||
for _, line := range lines {
|
||
line = strings.TrimSpace(line)
|
||
if strings.Contains(line, "Encoders:") || strings.Contains(line, "Codecs:") {
|
||
inEncodersSection = true
|
||
continue
|
||
}
|
||
if inEncodersSection {
|
||
// Encoder lines typically look like: " V..... h264_nvenc H.264 / AVC / MPEG-4 AVC (NVIDIA NVENC)"
|
||
// Split by whitespace and check if any part matches hardware patterns
|
||
parts := strings.Fields(line)
|
||
for _, part := range parts {
|
||
for _, pattern := range hwPatterns {
|
||
if strings.Contains(part, pattern) {
|
||
encoders[part] = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return encoders
|
||
}
|
||
|
||
// checkEncoderAvailable checks if an ffmpeg encoder is available and actually usable
|
||
func (c *Client) checkEncoderAvailable(encoder string) bool {
|
||
// Check cache first
|
||
c.hwAccelCacheMu.RLock()
|
||
if cached, ok := c.hwAccelCache[encoder]; ok {
|
||
c.hwAccelCacheMu.RUnlock()
|
||
return cached
|
||
}
|
||
c.hwAccelCacheMu.RUnlock()
|
||
|
||
// Initialize cache if needed
|
||
c.hwAccelCacheMu.Lock()
|
||
if c.hwAccelCache == nil {
|
||
c.hwAccelCache = make(map[string]bool)
|
||
}
|
||
c.hwAccelCacheMu.Unlock()
|
||
|
||
// First check if encoder is listed in encoders output
|
||
cmd := exec.Command("ffmpeg", "-hide_banner", "-encoders")
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
c.hwAccelCacheMu.Lock()
|
||
c.hwAccelCache[encoder] = false
|
||
c.hwAccelCacheMu.Unlock()
|
||
return false
|
||
}
|
||
|
||
encoderOutput := string(output)
|
||
// Check for exact encoder name (more reliable than just contains)
|
||
encoderPattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(encoder) + `\b`)
|
||
if !encoderPattern.MatchString(encoderOutput) {
|
||
// Also try case-insensitive and without exact word boundary
|
||
if !strings.Contains(strings.ToLower(encoderOutput), strings.ToLower(encoder)) {
|
||
c.hwAccelCacheMu.Lock()
|
||
c.hwAccelCache[encoder] = false
|
||
c.hwAccelCacheMu.Unlock()
|
||
return false
|
||
}
|
||
}
|
||
|
||
// Check hardware acceleration methods that might be needed
|
||
hwaccelCmd := exec.Command("ffmpeg", "-hide_banner", "-hwaccels")
|
||
hwaccelOutput, err := hwaccelCmd.CombinedOutput()
|
||
hwaccelStr := ""
|
||
if err == nil {
|
||
hwaccelStr = string(hwaccelOutput)
|
||
}
|
||
|
||
// Encoder-specific detection and testing
|
||
var available bool
|
||
switch encoder {
|
||
case "h264_nvenc", "hevc_nvenc":
|
||
// NVENC - check for CUDA/NVENC support
|
||
hasCuda := strings.Contains(hwaccelStr, "cuda") || strings.Contains(hwaccelStr, "cuvid")
|
||
if hasCuda {
|
||
available = c.testNVENCEncoder()
|
||
} else {
|
||
// Some builds have NVENC without CUDA hwaccel, still test
|
||
available = c.testNVENCEncoder()
|
||
}
|
||
case "h264_vaapi", "hevc_vaapi":
|
||
// VAAPI needs device setup
|
||
// Check if encoder is listed first (more reliable than hwaccels check)
|
||
hasVAAPI := strings.Contains(hwaccelStr, "vaapi")
|
||
if hasVAAPI {
|
||
available = c.testVAAPIEncoder()
|
||
} else {
|
||
// Even if hwaccels doesn't show vaapi, the encoder might still work
|
||
// Try testing anyway (some builds have the encoder but not the hwaccel method)
|
||
log.Printf("VAAPI not in hwaccels list, but encoder found - testing anyway")
|
||
available = c.testVAAPIEncoder()
|
||
}
|
||
case "h264_qsv", "hevc_qsv":
|
||
// QSV needs specific setup
|
||
hasQSV := strings.Contains(hwaccelStr, "qsv")
|
||
if hasQSV {
|
||
available = c.testQSVEncoder()
|
||
} else {
|
||
available = false
|
||
}
|
||
case "h264_videotoolbox", "hevc_videotoolbox":
|
||
// VideoToolbox on macOS
|
||
hasVideoToolbox := strings.Contains(hwaccelStr, "videotoolbox")
|
||
if hasVideoToolbox {
|
||
available = c.testVideoToolboxEncoder()
|
||
} else {
|
||
available = false
|
||
}
|
||
case "h264_amf", "hevc_amf":
|
||
// AMF on Windows
|
||
hasAMF := strings.Contains(hwaccelStr, "d3d11va") || strings.Contains(hwaccelStr, "dxva2")
|
||
if hasAMF {
|
||
available = c.testAMFEncoder()
|
||
} else {
|
||
available = false
|
||
}
|
||
case "h264_v4l2m2m", "hevc_v4l2m2m":
|
||
// V4L2 M2M (Video4Linux2 Memory-to-Memory) on Linux
|
||
available = c.testV4L2M2MEncoder()
|
||
case "h264_omx", "hevc_omx":
|
||
// OpenMAX on Raspberry Pi
|
||
available = c.testOMXEncoder()
|
||
case "h264_mediacodec", "hevc_mediacodec":
|
||
// MediaCodec on Android
|
||
available = c.testMediaCodecEncoder()
|
||
default:
|
||
// Generic test for other encoders
|
||
available = c.testGenericEncoder(encoder)
|
||
}
|
||
|
||
// Cache the result
|
||
c.hwAccelCacheMu.Lock()
|
||
c.hwAccelCache[encoder] = available
|
||
c.hwAccelCacheMu.Unlock()
|
||
|
||
return available
|
||
}
|
||
|
||
// testNVENCEncoder tests NVIDIA NVENC encoder
|
||
func (c *Client) testNVENCEncoder() bool {
|
||
// Test with a simple encode
|
||
testCmd := exec.Command("ffmpeg",
|
||
"-f", "lavfi",
|
||
"-i", "color=c=black:s=64x64:d=0.1",
|
||
"-c:v", "h264_nvenc",
|
||
"-preset", "p1",
|
||
"-frames:v", "1",
|
||
"-f", "null",
|
||
"-",
|
||
)
|
||
testCmd.Stdout = nil
|
||
testCmd.Stderr = nil
|
||
err := testCmd.Run()
|
||
return err == nil
|
||
}
|
||
|
||
// testVAAPIEncoder tests VAAPI encoder and finds all available devices
|
||
func (c *Client) testVAAPIEncoder() bool {
|
||
// First, find all available VAAPI devices
|
||
devices := c.findVAAPIDevices()
|
||
if len(devices) == 0 {
|
||
log.Printf("VAAPI test failed: No devices found")
|
||
return false
|
||
}
|
||
|
||
// Test with each device until one works
|
||
for _, device := range devices {
|
||
log.Printf("Testing VAAPI device: %s", device)
|
||
|
||
// Try multiple test approaches with proper parameters
|
||
testCommands := [][]string{
|
||
// Standard test with proper size and bitrate
|
||
{"-vaapi_device", device, "-f", "lavfi", "-i", "color=c=black:s=1920x1080:d=0.1", "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-b:v", "1M", "-frames:v", "1", "-f", "null", "-"},
|
||
// Try with smaller but still reasonable size
|
||
{"-vaapi_device", device, "-f", "lavfi", "-i", "color=c=black:s=640x480:d=0.1", "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-b:v", "1M", "-frames:v", "1", "-f", "null", "-"},
|
||
// Try with minimum reasonable size
|
||
{"-vaapi_device", device, "-f", "lavfi", "-i", "color=c=black:s=64x64:d=0.1", "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-b:v", "1M", "-frames:v", "1", "-f", "null", "-"},
|
||
}
|
||
|
||
for i, testArgs := range testCommands {
|
||
testCmd := exec.Command("ffmpeg", testArgs...)
|
||
var stderr bytes.Buffer
|
||
testCmd.Stdout = nil
|
||
testCmd.Stderr = &stderr
|
||
err := testCmd.Run()
|
||
if err == nil {
|
||
log.Printf("VAAPI device %s works with test method %d", device, i+1)
|
||
return true
|
||
}
|
||
// Log error for debugging but continue trying
|
||
if i == 0 {
|
||
log.Printf("VAAPI device %s test failed (method %d): %v, stderr: %s", device, i+1, err, stderr.String())
|
||
}
|
||
}
|
||
}
|
||
|
||
log.Printf("VAAPI test failed: All devices failed all test methods")
|
||
return false
|
||
}
|
||
|
||
// findVAAPIDevices finds all available VAAPI render devices
|
||
func (c *Client) findVAAPIDevices() []string {
|
||
// Check cache first
|
||
c.vaapiDevicesMu.RLock()
|
||
if len(c.vaapiDevices) > 0 {
|
||
// Verify devices still exist
|
||
validDevices := make([]string, 0, len(c.vaapiDevices))
|
||
for _, device := range c.vaapiDevices {
|
||
if _, err := os.Stat(device); err == nil {
|
||
validDevices = append(validDevices, device)
|
||
}
|
||
}
|
||
if len(validDevices) > 0 {
|
||
c.vaapiDevicesMu.RUnlock()
|
||
// Update cache if some devices were removed
|
||
if len(validDevices) != len(c.vaapiDevices) {
|
||
c.vaapiDevicesMu.Lock()
|
||
c.vaapiDevices = validDevices
|
||
c.vaapiDevicesMu.Unlock()
|
||
}
|
||
return validDevices
|
||
}
|
||
}
|
||
c.vaapiDevicesMu.RUnlock()
|
||
|
||
log.Printf("Discovering VAAPI devices...")
|
||
|
||
// Build list of potential device paths
|
||
deviceCandidates := []string{}
|
||
|
||
// First, check /dev/dri for render nodes (preferred)
|
||
if entries, err := os.ReadDir("/dev/dri"); err == nil {
|
||
log.Printf("Found %d entries in /dev/dri", len(entries))
|
||
for _, entry := range entries {
|
||
if strings.HasPrefix(entry.Name(), "renderD") {
|
||
devPath := filepath.Join("/dev/dri", entry.Name())
|
||
deviceCandidates = append(deviceCandidates, devPath)
|
||
log.Printf("Found render node: %s", devPath)
|
||
} else if strings.HasPrefix(entry.Name(), "card") {
|
||
// Also try card devices as fallback
|
||
devPath := filepath.Join("/dev/dri", entry.Name())
|
||
deviceCandidates = append(deviceCandidates, devPath)
|
||
log.Printf("Found card device: %s", devPath)
|
||
}
|
||
}
|
||
} else {
|
||
log.Printf("Failed to read /dev/dri: %v", err)
|
||
}
|
||
|
||
// Also try common device paths as fallback
|
||
commonDevices := []string{
|
||
"/dev/dri/renderD128",
|
||
"/dev/dri/renderD129",
|
||
"/dev/dri/renderD130",
|
||
"/dev/dri/renderD131",
|
||
"/dev/dri/renderD132",
|
||
"/dev/dri/card0",
|
||
"/dev/dri/card1",
|
||
"/dev/dri/card2",
|
||
}
|
||
for _, dev := range commonDevices {
|
||
// Only add if not already in candidates
|
||
found := false
|
||
for _, candidate := range deviceCandidates {
|
||
if candidate == dev {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
deviceCandidates = append(deviceCandidates, dev)
|
||
}
|
||
}
|
||
|
||
log.Printf("Testing %d device candidates for VAAPI", len(deviceCandidates))
|
||
|
||
// Test each device and collect working ones
|
||
workingDevices := []string{}
|
||
for _, device := range deviceCandidates {
|
||
if _, err := os.Stat(device); err != nil {
|
||
log.Printf("Device %s does not exist, skipping", device)
|
||
continue
|
||
}
|
||
|
||
log.Printf("Testing VAAPI device: %s", device)
|
||
|
||
// Try multiple test methods with proper frame sizes and bitrate
|
||
// VAAPI encoders require minimum frame sizes and bitrate parameters
|
||
testMethods := [][]string{
|
||
// Standard test with proper size and bitrate
|
||
{"-vaapi_device", device, "-f", "lavfi", "-i", "color=c=black:s=1920x1080:d=0.1", "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-b:v", "1M", "-frames:v", "1", "-f", "null", "-"},
|
||
// Try with smaller but still reasonable size
|
||
{"-vaapi_device", device, "-f", "lavfi", "-i", "color=c=black:s=640x480:d=0.1", "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-b:v", "1M", "-frames:v", "1", "-f", "null", "-"},
|
||
// Try with minimum reasonable size
|
||
{"-vaapi_device", device, "-f", "lavfi", "-i", "color=c=black:s=64x64:d=0.1", "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-b:v", "1M", "-frames:v", "1", "-f", "null", "-"},
|
||
}
|
||
|
||
deviceWorks := false
|
||
for i, testArgs := range testMethods {
|
||
testCmd := exec.Command("ffmpeg", testArgs...)
|
||
var stderr bytes.Buffer
|
||
testCmd.Stdout = nil
|
||
testCmd.Stderr = &stderr
|
||
err := testCmd.Run()
|
||
if err == nil {
|
||
log.Printf("VAAPI device %s works (method %d)", device, i+1)
|
||
workingDevices = append(workingDevices, device)
|
||
deviceWorks = true
|
||
break
|
||
}
|
||
if i == 0 {
|
||
// Log first failure for debugging
|
||
log.Printf("VAAPI device %s test failed (method %d): %v", device, i+1, err)
|
||
if stderr.Len() > 0 {
|
||
log.Printf(" stderr: %s", strings.TrimSpace(stderr.String()))
|
||
}
|
||
}
|
||
}
|
||
|
||
if !deviceWorks {
|
||
log.Printf("VAAPI device %s failed all test methods", device)
|
||
}
|
||
}
|
||
|
||
log.Printf("Found %d working VAAPI device(s): %v", len(workingDevices), workingDevices)
|
||
|
||
// Cache all working devices
|
||
c.vaapiDevicesMu.Lock()
|
||
c.vaapiDevices = workingDevices
|
||
c.vaapiDevicesMu.Unlock()
|
||
|
||
return workingDevices
|
||
}
|
||
|
||
// getVAAPIDevice returns the first available VAAPI device, or empty string if none
|
||
func (c *Client) getVAAPIDevice() string {
|
||
devices := c.findVAAPIDevices()
|
||
if len(devices) > 0 {
|
||
return devices[0]
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// allocateVAAPIDevice allocates an available VAAPI device to a task
|
||
// Returns the device path, or empty string if no device is available
|
||
func (c *Client) allocateVAAPIDevice(taskID int64) string {
|
||
c.allocatedDevicesMu.Lock()
|
||
defer c.allocatedDevicesMu.Unlock()
|
||
|
||
// Initialize map if needed
|
||
if c.allocatedDevices == nil {
|
||
c.allocatedDevices = make(map[int64]string)
|
||
}
|
||
|
||
// Get all available devices
|
||
allDevices := c.findVAAPIDevices()
|
||
if len(allDevices) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// Find which devices are currently allocated
|
||
allocatedSet := make(map[string]bool)
|
||
for _, allocatedDevice := range c.allocatedDevices {
|
||
allocatedSet[allocatedDevice] = true
|
||
}
|
||
|
||
// Find the first available (not allocated) device
|
||
for _, device := range allDevices {
|
||
if !allocatedSet[device] {
|
||
c.allocatedDevices[taskID] = device
|
||
log.Printf("Allocated VAAPI device %s to task %d", device, taskID)
|
||
return device
|
||
}
|
||
}
|
||
|
||
// All devices are in use
|
||
log.Printf("No available VAAPI devices for task %d (all %d devices in use)", taskID, len(allDevices))
|
||
return ""
|
||
}
|
||
|
||
// releaseVAAPIDevice releases a VAAPI device allocated to a task
|
||
func (c *Client) releaseVAAPIDevice(taskID int64) {
|
||
c.allocatedDevicesMu.Lock()
|
||
defer c.allocatedDevicesMu.Unlock()
|
||
|
||
if c.allocatedDevices == nil {
|
||
return
|
||
}
|
||
|
||
if device, ok := c.allocatedDevices[taskID]; ok {
|
||
delete(c.allocatedDevices, taskID)
|
||
log.Printf("Released VAAPI device %s from task %d", device, taskID)
|
||
}
|
||
}
|
||
|
||
// testQSVEncoder tests Intel Quick Sync Video encoder
|
||
func (c *Client) testQSVEncoder() bool {
|
||
// QSV can work with different backends
|
||
testCmd := exec.Command("ffmpeg",
|
||
"-f", "lavfi",
|
||
"-i", "color=c=black:s=64x64:d=0.1",
|
||
"-c:v", "h264_qsv",
|
||
"-preset", "medium",
|
||
"-frames:v", "1",
|
||
"-f", "null",
|
||
"-",
|
||
)
|
||
testCmd.Stdout = nil
|
||
testCmd.Stderr = nil
|
||
err := testCmd.Run()
|
||
return err == nil
|
||
}
|
||
|
||
// testVideoToolboxEncoder tests macOS VideoToolbox encoder
|
||
func (c *Client) testVideoToolboxEncoder() bool {
|
||
testCmd := exec.Command("ffmpeg",
|
||
"-f", "lavfi",
|
||
"-i", "color=c=black:s=64x64:d=0.1",
|
||
"-c:v", "h264_videotoolbox",
|
||
"-frames:v", "1",
|
||
"-f", "null",
|
||
"-",
|
||
)
|
||
testCmd.Stdout = nil
|
||
testCmd.Stderr = nil
|
||
err := testCmd.Run()
|
||
return err == nil
|
||
}
|
||
|
||
// testAMFEncoder tests AMD AMF encoder
|
||
func (c *Client) testAMFEncoder() bool {
|
||
testCmd := exec.Command("ffmpeg",
|
||
"-f", "lavfi",
|
||
"-i", "color=c=black:s=64x64:d=0.1",
|
||
"-c:v", "h264_amf",
|
||
"-quality", "balanced",
|
||
"-frames:v", "1",
|
||
"-f", "null",
|
||
"-",
|
||
)
|
||
testCmd.Stdout = nil
|
||
testCmd.Stderr = nil
|
||
err := testCmd.Run()
|
||
return err == nil
|
||
}
|
||
|
||
// testV4L2M2MEncoder tests V4L2 M2M encoder (Video4Linux2 Memory-to-Memory)
|
||
func (c *Client) testV4L2M2MEncoder() bool {
|
||
testCmd := exec.Command("ffmpeg",
|
||
"-f", "lavfi",
|
||
"-i", "color=c=black:s=64x64:d=0.1",
|
||
"-c:v", "h264_v4l2m2m",
|
||
"-frames:v", "1",
|
||
"-f", "null",
|
||
"-",
|
||
)
|
||
testCmd.Stdout = nil
|
||
testCmd.Stderr = nil
|
||
err := testCmd.Run()
|
||
return err == nil
|
||
}
|
||
|
||
// testOMXEncoder tests OpenMAX encoder (Raspberry Pi)
|
||
func (c *Client) testOMXEncoder() bool {
|
||
testCmd := exec.Command("ffmpeg",
|
||
"-f", "lavfi",
|
||
"-i", "color=c=black:s=64x64:d=0.1",
|
||
"-c:v", "h264_omx",
|
||
"-frames:v", "1",
|
||
"-f", "null",
|
||
"-",
|
||
)
|
||
testCmd.Stdout = nil
|
||
testCmd.Stderr = nil
|
||
err := testCmd.Run()
|
||
return err == nil
|
||
}
|
||
|
||
// testMediaCodecEncoder tests MediaCodec encoder (Android)
|
||
func (c *Client) testMediaCodecEncoder() bool {
|
||
testCmd := exec.Command("ffmpeg",
|
||
"-f", "lavfi",
|
||
"-i", "color=c=black:s=64x64:d=0.1",
|
||
"-c:v", "h264_mediacodec",
|
||
"-frames:v", "1",
|
||
"-f", "null",
|
||
"-",
|
||
)
|
||
testCmd.Stdout = nil
|
||
testCmd.Stderr = nil
|
||
err := testCmd.Run()
|
||
return err == nil
|
||
}
|
||
|
||
// testGenericEncoder tests a generic encoder
|
||
func (c *Client) testGenericEncoder(encoder string) bool {
|
||
testCmd := exec.Command("ffmpeg",
|
||
"-f", "lavfi",
|
||
"-i", "color=c=black:s=64x64:d=0.1",
|
||
"-c:v", encoder,
|
||
"-frames:v", "1",
|
||
"-f", "null",
|
||
"-",
|
||
)
|
||
testCmd.Stdout = nil
|
||
testCmd.Stderr = nil
|
||
err := testCmd.Run()
|
||
return err == nil
|
||
}
|
||
|
||
// generateMP4WithConcat uses ffmpeg concat demuxer as fallback
|
||
// device parameter is optional - if provided, it will be used for VAAPI encoding
|
||
func (c *Client) generateMP4WithConcat(frameFiles []string, outputMP4, workDir string, device string, outputFormat string, codec string, pixFmt string, useAlpha bool, useHardware bool) error {
|
||
// Create file list for ffmpeg concat demuxer
|
||
listFile := filepath.Join(workDir, "frames.txt")
|
||
listFileHandle, err := os.Create(listFile)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create list file: %w", err)
|
||
}
|
||
|
||
for _, frameFile := range frameFiles {
|
||
absPath, _ := filepath.Abs(frameFile)
|
||
fmt.Fprintf(listFileHandle, "file '%s'\n", absPath)
|
||
}
|
||
listFileHandle.Close()
|
||
|
||
// Build video filter for HDR to SDR conversion
|
||
var vf string
|
||
if useAlpha {
|
||
// For AV1 with alpha: preserve alpha channel during tonemapping
|
||
vf = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuva420p"
|
||
} else {
|
||
// For H.264 without alpha: standard tonemapping
|
||
vf = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
|
||
}
|
||
|
||
// Run ffmpeg with concat demuxer
|
||
// EXR frames are 32-bit float HDR - FFmpeg will tonemap automatically
|
||
var cmd *exec.Cmd
|
||
|
||
if useHardware {
|
||
if outputFormat == "EXR_AV1_MP4" {
|
||
// Try AV1 hardware acceleration
|
||
cmd, err = c.buildFFmpegCommandAV1(device, useAlpha, "-f", "concat", "-safe", "0", "-i", listFile,
|
||
"-r", "24", "-y", outputMP4)
|
||
if err != nil {
|
||
useHardware = false // Fall back to software
|
||
}
|
||
} else {
|
||
// Try H.264 hardware acceleration
|
||
if device != "" {
|
||
cmd, err = c.buildFFmpegCommand(device, "-f", "concat", "-safe", "0", "-i", listFile,
|
||
"-r", "24", "-y", outputMP4)
|
||
if err != nil {
|
||
useHardware = false // Fall back to software
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !useHardware {
|
||
// Software encoding with HDR tonemapping
|
||
cmd = exec.Command("ffmpeg", "-f", "concat", "-safe", "0", "-i", listFile,
|
||
"-vf", vf,
|
||
"-c:v", codec, "-pix_fmt", pixFmt, "-r", "24", "-y", outputMP4)
|
||
|
||
if outputFormat == "EXR_AV1_MP4" {
|
||
// AV1 encoding options for quality
|
||
cmd.Args = append(cmd.Args, "-cpu-used", "4", "-crf", "30", "-b:v", "0")
|
||
}
|
||
}
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
outputStr := string(output)
|
||
// Check for size-related errors
|
||
if sizeErr := c.checkFFmpegSizeError(outputStr); sizeErr != nil {
|
||
return sizeErr
|
||
}
|
||
return fmt.Errorf("ffmpeg concat failed: %w\nOutput: %s", err, outputStr)
|
||
}
|
||
|
||
if _, err := os.Stat(outputMP4); os.IsNotExist(err) {
|
||
return fmt.Errorf("MP4 file not created: %s", outputMP4)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// checkFFmpegSizeError checks ffmpeg output for size-related errors and returns a helpful error message
|
||
func (c *Client) checkFFmpegSizeError(output string) error {
|
||
outputLower := strings.ToLower(output)
|
||
|
||
// Check for hardware encoding size constraints
|
||
if strings.Contains(outputLower, "hardware does not support encoding at size") {
|
||
// Extract size constraints if available
|
||
constraintsMatch := regexp.MustCompile(`constraints:\s*width\s+(\d+)-(\d+)\s+height\s+(\d+)-(\d+)`).FindStringSubmatch(output)
|
||
if len(constraintsMatch) == 5 {
|
||
return fmt.Errorf("video frame size is outside hardware encoder limits. Hardware requires: width %s-%s, height %s-%s. Please adjust your render resolution to fit within these constraints",
|
||
constraintsMatch[1], constraintsMatch[2], constraintsMatch[3], constraintsMatch[4])
|
||
}
|
||
return fmt.Errorf("video frame size is outside hardware encoder limits. Please adjust your render resolution")
|
||
}
|
||
|
||
// Check for invalid picture size
|
||
if strings.Contains(outputLower, "picture size") && strings.Contains(outputLower, "is invalid") {
|
||
sizeMatch := regexp.MustCompile(`picture size\s+(\d+)x(\d+)`).FindStringSubmatch(output)
|
||
if len(sizeMatch) == 3 {
|
||
return fmt.Errorf("invalid video frame size: %sx%s. Frame dimensions are too large or invalid", sizeMatch[1], sizeMatch[2])
|
||
}
|
||
return fmt.Errorf("invalid video frame size. Frame dimensions are too large or invalid")
|
||
}
|
||
|
||
// Check for encoder parameter errors mentioning width/height
|
||
if strings.Contains(outputLower, "error while opening encoder") &&
|
||
(strings.Contains(outputLower, "width") || strings.Contains(outputLower, "height") || strings.Contains(outputLower, "size")) {
|
||
// Try to extract the actual size if mentioned
|
||
sizeMatch := regexp.MustCompile(`at size\s+(\d+)x(\d+)`).FindStringSubmatch(output)
|
||
if len(sizeMatch) == 3 {
|
||
return fmt.Errorf("hardware encoder cannot encode frame size %sx%s. The frame dimensions may be too small, too large, or not supported by the hardware encoder", sizeMatch[1], sizeMatch[2])
|
||
}
|
||
return fmt.Errorf("hardware encoder error: frame size may be invalid. Common issues: frame too small (minimum usually 128x128) or too large (maximum varies by hardware)")
|
||
}
|
||
|
||
// Check for general size-related errors
|
||
if strings.Contains(outputLower, "invalid") &&
|
||
(strings.Contains(outputLower, "width") || strings.Contains(outputLower, "height") || strings.Contains(outputLower, "dimension")) {
|
||
return fmt.Errorf("invalid frame dimensions detected. Please check your render resolution settings")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// extractFrameNumber extracts frame number from filename like "frame_0001.exr" or "frame_0001.png"
|
||
func extractFrameNumber(filename string) int {
|
||
parts := strings.Split(filepath.Base(filename), "_")
|
||
if len(parts) < 2 {
|
||
return 0
|
||
}
|
||
framePart := strings.Split(parts[1], ".")[0]
|
||
var frameNum int
|
||
fmt.Sscanf(framePart, "%d", &frameNum)
|
||
return frameNum
|
||
}
|
||
|
||
// getJobFiles gets job files from manager
|
||
func (c *Client) getJobFiles(jobID int64) ([]map[string]interface{}, error) {
|
||
path := fmt.Sprintf("/api/runner/jobs/%d/files", jobID)
|
||
resp, err := c.doSignedRequest("GET", path, nil, fmt.Sprintf("runner_id=%d", c.runnerID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return nil, fmt.Errorf("failed to get job files: %s", string(body))
|
||
}
|
||
|
||
var files []map[string]interface{}
|
||
if err := json.NewDecoder(resp.Body).Decode(&files); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return files, nil
|
||
}
|
||
|
||
// getJobMetadata gets job metadata from manager
|
||
func (c *Client) getJobMetadata(jobID int64) (*types.BlendMetadata, error) {
|
||
path := fmt.Sprintf("/api/runner/jobs/%d/metadata", jobID)
|
||
resp, err := c.doSignedRequest("GET", path, nil, fmt.Sprintf("runner_id=%d", c.runnerID))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == http.StatusNotFound {
|
||
return nil, nil // No metadata found, not an error
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return nil, fmt.Errorf("failed to get job metadata: %s", string(body))
|
||
}
|
||
|
||
var metadata types.BlendMetadata
|
||
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &metadata, nil
|
||
}
|
||
|
||
// downloadFrameFile downloads a frame file for MP4 generation
|
||
func (c *Client) downloadFrameFile(jobID int64, fileName, destPath string) error {
|
||
// URL encode the fileName to handle special characters in filenames
|
||
encodedFileName := url.PathEscape(fileName)
|
||
path := fmt.Sprintf("/api/runner/files/%d/%s", jobID, encodedFileName)
|
||
// Use long-running client for file downloads (no timeout) - EXR files can be large
|
||
resp, err := c.doSignedRequestLong("GET", path, nil, fmt.Sprintf("runner_id=%d", c.runnerID))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("download failed: %s", string(body))
|
||
}
|
||
|
||
file, err := os.Create(destPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer file.Close()
|
||
|
||
_, err = io.Copy(file, resp.Body)
|
||
return err
|
||
}
|
||
|
||
// downloadFile downloads a file from the manager to a directory (preserves filename only)
|
||
func (c *Client) downloadFile(filePath, destDir string) error {
|
||
fileName := filepath.Base(filePath)
|
||
destPath := filepath.Join(destDir, fileName)
|
||
return c.downloadFileToPath(filePath, destPath)
|
||
}
|
||
|
||
// downloadFileToPath downloads a file from the manager to a specific path (preserves directory structure)
|
||
func (c *Client) downloadFileToPath(filePath, destPath string) error {
|
||
// Extract job ID and relative path from storage path
|
||
// Path format: storage/jobs/{jobID}/{relativePath}
|
||
parts := strings.Split(strings.TrimPrefix(filePath, "./"), "/")
|
||
if len(parts) < 3 {
|
||
return fmt.Errorf("invalid file path format: %s", filePath)
|
||
}
|
||
|
||
// Find job ID in path (look for "jobs" directory)
|
||
jobID := ""
|
||
var relPathParts []string
|
||
foundJobs := false
|
||
for i, part := range parts {
|
||
if part == "jobs" && i+1 < len(parts) {
|
||
jobID = parts[i+1]
|
||
foundJobs = true
|
||
if i+2 < len(parts) {
|
||
relPathParts = parts[i+2:]
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
if !foundJobs || jobID == "" {
|
||
return fmt.Errorf("could not extract job ID from path: %s", filePath)
|
||
}
|
||
|
||
// Build download path - preserve relative path structure
|
||
downloadPath := fmt.Sprintf("/api/runner/files/%s", jobID)
|
||
if len(relPathParts) > 0 {
|
||
// URL encode each path component
|
||
for _, part := range relPathParts {
|
||
downloadPath += "/" + part
|
||
}
|
||
} else {
|
||
// Fallback to filename only
|
||
downloadPath += "/" + filepath.Base(filePath)
|
||
}
|
||
|
||
// Use long-running client for file downloads (no timeout)
|
||
resp, err := c.doSignedRequestLong("GET", downloadPath, nil, fmt.Sprintf("runner_id=%d", c.runnerID))
|
||
if err != nil {
|
||
return fmt.Errorf("failed to download file: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("download failed: %s", string(body))
|
||
}
|
||
|
||
// Ensure destination directory exists
|
||
destDir := filepath.Dir(destPath)
|
||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||
}
|
||
|
||
file, err := os.Create(destPath)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create destination file: %w", err)
|
||
}
|
||
defer file.Close()
|
||
|
||
_, err = io.Copy(file, resp.Body)
|
||
return err
|
||
}
|
||
|
||
// uploadFile uploads a file to the manager
|
||
func (c *Client) uploadFile(jobID int64, filePath string) (string, error) {
|
||
file, err := os.Open(filePath)
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to open file: %w", err)
|
||
}
|
||
defer file.Close()
|
||
|
||
// Create multipart form
|
||
var buf bytes.Buffer
|
||
formWriter := multipart.NewWriter(&buf)
|
||
|
||
part, err := formWriter.CreateFormFile("file", filepath.Base(filePath))
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to create form file: %w", err)
|
||
}
|
||
|
||
_, err = io.Copy(part, file)
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to copy file data: %w", err)
|
||
}
|
||
|
||
formWriter.Close()
|
||
|
||
// Upload file with shared secret
|
||
path := fmt.Sprintf("/api/runner/files/%d/upload?runner_id=%d", jobID, c.runnerID)
|
||
url := fmt.Sprintf("%s%s", c.managerURL, path)
|
||
req, err := http.NewRequest("POST", url, &buf)
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to create request: %w", err)
|
||
}
|
||
|
||
req.Header.Set("Content-Type", formWriter.FormDataContentType())
|
||
req.Header.Set("X-Runner-Secret", c.runnerSecret)
|
||
|
||
// Use long-running client for file uploads (no timeout)
|
||
resp, err := c.longRunningClient.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to upload file: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusCreated {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return "", fmt.Errorf("upload failed: %s", string(body))
|
||
}
|
||
|
||
var result struct {
|
||
FilePath string `json:"file_path"`
|
||
FileName string `json:"file_name"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||
}
|
||
|
||
return result.FilePath, nil
|
||
}
|
||
|
||
// getContextCacheKey generates a cache key for a job's context
|
||
func (c *Client) getContextCacheKey(jobID int64) string {
|
||
// Use job ID as the cache key (context is regenerated when job files change)
|
||
return fmt.Sprintf("job_%d", jobID)
|
||
}
|
||
|
||
// getContextCachePath returns the path to a cached context file
|
||
func (c *Client) getContextCachePath(cacheKey string) string {
|
||
cacheDir := filepath.Join(c.getWorkspaceDir(), "cache", "contexts")
|
||
os.MkdirAll(cacheDir, 0755)
|
||
return filepath.Join(cacheDir, cacheKey+".tar")
|
||
}
|
||
|
||
// isContextCacheValid checks if a cached context file exists and is not expired (1 hour TTL)
|
||
func (c *Client) isContextCacheValid(cachePath string) bool {
|
||
info, err := os.Stat(cachePath)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
// Check if file is less than 1 hour old
|
||
return time.Since(info.ModTime()) < time.Hour
|
||
}
|
||
|
||
// downloadJobContext downloads the job context tar, using cache if available
|
||
func (c *Client) downloadJobContext(jobID int64, destPath string) error {
|
||
cacheKey := c.getContextCacheKey(jobID)
|
||
cachePath := c.getContextCachePath(cacheKey)
|
||
|
||
// Check cache first
|
||
if c.isContextCacheValid(cachePath) {
|
||
log.Printf("Using cached context for job %d", jobID)
|
||
// Copy from cache to destination
|
||
src, err := os.Open(cachePath)
|
||
if err != nil {
|
||
log.Printf("Failed to open cached context, will download: %v", err)
|
||
} else {
|
||
defer src.Close()
|
||
dst, err := os.Create(destPath)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create destination file: %w", err)
|
||
}
|
||
defer dst.Close()
|
||
_, err = io.Copy(dst, src)
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
log.Printf("Failed to copy cached context, will download: %v", err)
|
||
}
|
||
}
|
||
|
||
// Download from manager - use long-running client (no timeout) for large context files
|
||
path := fmt.Sprintf("/api/runner/jobs/%d/context.tar", jobID)
|
||
resp, err := c.doSignedRequestLong("GET", path, nil, fmt.Sprintf("runner_id=%d", c.runnerID))
|
||
if err != nil {
|
||
return fmt.Errorf("failed to download context: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("context download failed: %s", string(body))
|
||
}
|
||
|
||
// Create temporary file first
|
||
tmpPath := destPath + ".tmp"
|
||
tmpFile, err := os.Create(tmpPath)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create temporary file: %w", err)
|
||
}
|
||
defer tmpFile.Close()
|
||
defer os.Remove(tmpPath)
|
||
|
||
// Stream download to temporary file
|
||
_, err = io.Copy(tmpFile, resp.Body)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to download context: %w", err)
|
||
}
|
||
tmpFile.Close()
|
||
|
||
// Move to final destination
|
||
if err := os.Rename(tmpPath, destPath); err != nil {
|
||
return fmt.Errorf("failed to move context to destination: %w", err)
|
||
}
|
||
|
||
// Update cache
|
||
cacheDir := filepath.Dir(cachePath)
|
||
os.MkdirAll(cacheDir, 0755)
|
||
if err := os.Link(destPath, cachePath); err != nil {
|
||
// If link fails (e.g., cross-filesystem), copy instead
|
||
src, err := os.Open(destPath)
|
||
if err == nil {
|
||
defer src.Close()
|
||
dst, err := os.Create(cachePath)
|
||
if err == nil {
|
||
defer dst.Close()
|
||
io.Copy(dst, src)
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// extractTar extracts a tar file to the destination directory
|
||
func (c *Client) extractTar(tarPath, destDir string) error {
|
||
// Open the tar file
|
||
file, err := os.Open(tarPath)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to open tar file: %w", err)
|
||
}
|
||
defer file.Close()
|
||
|
||
// Create tar reader
|
||
tarReader := tar.NewReader(file)
|
||
|
||
// Extract files
|
||
for {
|
||
header, err := tarReader.Next()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("failed to read tar header: %w", err)
|
||
}
|
||
|
||
// Sanitize path to prevent directory traversal
|
||
targetPath := filepath.Join(destDir, header.Name)
|
||
if !strings.HasPrefix(filepath.Clean(targetPath), filepath.Clean(destDir)+string(os.PathSeparator)) {
|
||
return fmt.Errorf("invalid file path in tar: %s", header.Name)
|
||
}
|
||
|
||
// Handle directories
|
||
if header.Typeflag == tar.TypeDir {
|
||
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
|
||
return fmt.Errorf("failed to create directory: %w", err)
|
||
}
|
||
continue
|
||
}
|
||
|
||
// Handle regular files
|
||
if header.Typeflag == tar.TypeReg {
|
||
// Create parent directories
|
||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||
return fmt.Errorf("failed to create parent directory: %w", err)
|
||
}
|
||
|
||
// Create file
|
||
outFile, err := os.Create(targetPath)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create file: %w", err)
|
||
}
|
||
|
||
// Copy file contents
|
||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||
outFile.Close()
|
||
return fmt.Errorf("failed to extract file: %w", err)
|
||
}
|
||
|
||
// Set file permissions
|
||
if err := os.Chmod(targetPath, os.FileMode(header.Mode)); err != nil {
|
||
outFile.Close()
|
||
return fmt.Errorf("failed to set file permissions: %w", err)
|
||
}
|
||
|
||
outFile.Close()
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// cleanupExpiredContextCache removes context cache files older than 1 hour
|
||
func (c *Client) cleanupExpiredContextCache() {
|
||
cacheDir := filepath.Join(c.getWorkspaceDir(), "cache", "contexts")
|
||
entries, err := os.ReadDir(cacheDir)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
for _, entry := range entries {
|
||
if entry.IsDir() {
|
||
continue
|
||
}
|
||
info, err := entry.Info()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if now.Sub(info.ModTime()) > time.Hour {
|
||
cachePath := filepath.Join(cacheDir, entry.Name())
|
||
os.Remove(cachePath)
|
||
log.Printf("Removed expired context cache: %s", entry.Name())
|
||
}
|
||
}
|
||
}
|
||
|
||
// processMetadataTask processes a metadata extraction task
|
||
func (c *Client) processMetadataTask(task map[string]interface{}, jobID int64, inputFiles []interface{}) error {
|
||
taskID := int64(task["id"].(float64))
|
||
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Starting metadata extraction task: job %d", jobID), "")
|
||
log.Printf("Processing metadata extraction task %d for job %d", taskID, jobID)
|
||
|
||
// Create temporary job workspace for metadata extraction within runner workspace
|
||
workDir := filepath.Join(c.getWorkspaceDir(), fmt.Sprintf("job-%d-metadata-%d", jobID, taskID))
|
||
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||
return fmt.Errorf("failed to create work directory: %w", err)
|
||
}
|
||
defer os.RemoveAll(workDir)
|
||
|
||
// Step: download
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusRunning, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "Downloading job context...", "download")
|
||
|
||
// Download context tar
|
||
contextPath := filepath.Join(workDir, "context.tar")
|
||
if err := c.downloadJobContext(jobID, contextPath); err != nil {
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusFailed, err.Error())
|
||
return fmt.Errorf("failed to download context: %w", err)
|
||
}
|
||
|
||
// Extract context tar
|
||
c.sendLog(taskID, types.LogLevelInfo, "Extracting context...", "download")
|
||
if err := c.extractTar(contextPath, workDir); err != nil {
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusFailed, err.Error())
|
||
return fmt.Errorf("failed to extract context: %w", err)
|
||
}
|
||
|
||
// Find .blend file in extracted contents
|
||
blendFile := ""
|
||
err := filepath.Walk(workDir, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".blend") {
|
||
// Check it's not a Blender save file (.blend1, .blend2, etc.)
|
||
lower := strings.ToLower(info.Name())
|
||
idx := strings.LastIndex(lower, ".blend")
|
||
if idx != -1 {
|
||
suffix := lower[idx+len(".blend"):]
|
||
// If there are digits after .blend, it's a save file
|
||
isSaveFile := false
|
||
if len(suffix) > 0 {
|
||
isSaveFile = true
|
||
for _, r := range suffix {
|
||
if r < '0' || r > '9' {
|
||
isSaveFile = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if !isSaveFile {
|
||
blendFile = path
|
||
return filepath.SkipAll // Stop walking once we find a blend file
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusFailed, err.Error())
|
||
return fmt.Errorf("failed to find blend file: %w", err)
|
||
}
|
||
|
||
if blendFile == "" {
|
||
err := fmt.Errorf("no .blend file found in context")
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusFailed, err.Error())
|
||
return err
|
||
}
|
||
|
||
c.sendStepUpdate(taskID, "download", types.StepStatusCompleted, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "Context downloaded and extracted successfully", "download")
|
||
|
||
// Step: extract_metadata
|
||
c.sendStepUpdate(taskID, "extract_metadata", types.StepStatusRunning, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "Extracting metadata from blend file...", "extract_metadata")
|
||
|
||
// Create Python script to extract metadata
|
||
scriptPath := filepath.Join(workDir, "extract_metadata.py")
|
||
scriptContent := `import bpy
|
||
import json
|
||
import sys
|
||
|
||
# Make all file paths relative to the blend file location FIRST
|
||
# This must be done immediately after file load, before any other operations
|
||
# to prevent Blender from trying to access external files with absolute paths
|
||
try:
|
||
bpy.ops.file.make_paths_relative()
|
||
print("Made all file paths relative to blend file")
|
||
except Exception as e:
|
||
print(f"Warning: Could not make paths relative: {e}")
|
||
|
||
# Check for missing addons that the blend file requires
|
||
# Blender marks missing addons with "_missing" suffix in preferences
|
||
missing_files_info = {
|
||
"checked": False,
|
||
"has_missing": False,
|
||
"missing_files": [],
|
||
"missing_addons": []
|
||
}
|
||
|
||
try:
|
||
missing = []
|
||
for mod in bpy.context.preferences.addons:
|
||
if mod.module.endswith("_missing"):
|
||
missing.append(mod.module.rsplit("_", 1)[0])
|
||
|
||
missing_files_info["checked"] = True
|
||
if missing:
|
||
missing_files_info["has_missing"] = True
|
||
missing_files_info["missing_addons"] = missing
|
||
print("Missing add-ons required by this .blend:")
|
||
for name in missing:
|
||
print(" -", name)
|
||
else:
|
||
print("No missing add-ons detected – file is headless-safe")
|
||
except Exception as e:
|
||
print(f"Warning: Could not check for missing addons: {e}")
|
||
missing_files_info["error"] = str(e)
|
||
|
||
# Get scene
|
||
scene = bpy.context.scene
|
||
|
||
# Extract frame range from scene settings
|
||
frame_start = scene.frame_start
|
||
frame_end = scene.frame_end
|
||
|
||
# Also check for actual animation range (keyframes)
|
||
# Find the earliest and latest keyframes across all objects
|
||
animation_start = None
|
||
animation_end = None
|
||
|
||
for obj in scene.objects:
|
||
if obj.animation_data and obj.animation_data.action:
|
||
action = obj.animation_data.action
|
||
if action.fcurves:
|
||
for fcurve in action.fcurves:
|
||
if fcurve.keyframe_points:
|
||
for keyframe in fcurve.keyframe_points:
|
||
frame = int(keyframe.co[0])
|
||
if animation_start is None or frame < animation_start:
|
||
animation_start = frame
|
||
if animation_end is None or frame > animation_end:
|
||
animation_end = frame
|
||
|
||
# Use animation range if available, otherwise use scene frame range
|
||
# If scene range seems wrong (start == end), prefer animation range
|
||
if animation_start is not None and animation_end is not None:
|
||
if frame_start == frame_end or (animation_start < frame_start or animation_end > frame_end):
|
||
# Use animation range if scene range is invalid or animation extends beyond it
|
||
frame_start = animation_start
|
||
frame_end = animation_end
|
||
|
||
# Extract render settings
|
||
render = scene.render
|
||
resolution_x = render.resolution_x
|
||
resolution_y = render.resolution_y
|
||
engine = scene.render.engine.upper()
|
||
|
||
# Determine output format from file format
|
||
output_format = render.image_settings.file_format
|
||
|
||
# Extract engine-specific settings
|
||
engine_settings = {}
|
||
|
||
if engine == 'CYCLES':
|
||
cycles = scene.cycles
|
||
engine_settings = {
|
||
"samples": getattr(cycles, 'samples', 128),
|
||
"use_denoising": getattr(cycles, 'use_denoising', False),
|
||
"denoising_radius": getattr(cycles, 'denoising_radius', 0),
|
||
"denoising_strength": getattr(cycles, 'denoising_strength', 0.0),
|
||
"device": getattr(cycles, 'device', 'CPU'),
|
||
"use_adaptive_sampling": getattr(cycles, 'use_adaptive_sampling', False),
|
||
"adaptive_threshold": getattr(cycles, 'adaptive_threshold', 0.01) if getattr(cycles, 'use_adaptive_sampling', False) else 0.01,
|
||
"use_fast_gi": getattr(cycles, 'use_fast_gi', False),
|
||
"light_tree": getattr(cycles, 'use_light_tree', False),
|
||
"use_light_linking": getattr(cycles, 'use_light_linking', False),
|
||
"caustics_reflective": getattr(cycles, 'caustics_reflective', False),
|
||
"caustics_refractive": getattr(cycles, 'caustics_refractive', False),
|
||
"blur_glossy": getattr(cycles, 'blur_glossy', 0.0),
|
||
"max_bounces": getattr(cycles, 'max_bounces', 12),
|
||
"diffuse_bounces": getattr(cycles, 'diffuse_bounces', 4),
|
||
"glossy_bounces": getattr(cycles, 'glossy_bounces', 4),
|
||
"transmission_bounces": getattr(cycles, 'transmission_bounces', 12),
|
||
"volume_bounces": getattr(cycles, 'volume_bounces', 0),
|
||
"transparent_max_bounces": getattr(cycles, 'transparent_max_bounces', 8),
|
||
"film_transparent": getattr(cycles, 'film_transparent', False),
|
||
"use_layer_samples": getattr(cycles, 'use_layer_samples', False),
|
||
}
|
||
elif engine == 'EEVEE' or engine == 'EEVEE_NEXT':
|
||
eevee = scene.eevee
|
||
engine_settings = {
|
||
"taa_render_samples": getattr(eevee, 'taa_render_samples', 64),
|
||
"use_bloom": getattr(eevee, 'use_bloom', False),
|
||
"bloom_threshold": getattr(eevee, 'bloom_threshold', 0.8),
|
||
"bloom_intensity": getattr(eevee, 'bloom_intensity', 0.05),
|
||
"bloom_radius": getattr(eevee, 'bloom_radius', 6.5),
|
||
"use_ssr": getattr(eevee, 'use_ssr', True),
|
||
"use_ssr_refraction": getattr(eevee, 'use_ssr_refraction', False),
|
||
"ssr_quality": getattr(eevee, 'ssr_quality', 'MEDIUM'),
|
||
"use_ssao": getattr(eevee, 'use_ssao', True),
|
||
"ssao_quality": getattr(eevee, 'ssao_quality', 'MEDIUM'),
|
||
"ssao_distance": getattr(eevee, 'ssao_distance', 0.2),
|
||
"ssao_factor": getattr(eevee, 'ssao_factor', 1.0),
|
||
"use_soft_shadows": getattr(eevee, 'use_soft_shadows', True),
|
||
"use_shadow_high_bitdepth": getattr(eevee, 'use_shadow_high_bitdepth', True),
|
||
"use_volumetric": getattr(eevee, 'use_volumetric', False),
|
||
"volumetric_tile_size": getattr(eevee, 'volumetric_tile_size', '8'),
|
||
"volumetric_samples": getattr(eevee, 'volumetric_samples', 64),
|
||
"volumetric_start": getattr(eevee, 'volumetric_start', 0.0),
|
||
"volumetric_end": getattr(eevee, 'volumetric_end', 100.0),
|
||
"use_volumetric_lights": getattr(eevee, 'use_volumetric_lights', True),
|
||
"use_volumetric_shadows": getattr(eevee, 'use_volumetric_shadows', True),
|
||
"use_gtao": getattr(eevee, 'use_gtao', False),
|
||
"gtao_quality": getattr(eevee, 'gtao_quality', 'MEDIUM'),
|
||
"use_overscan": getattr(eevee, 'use_overscan', False),
|
||
}
|
||
else:
|
||
# For other engines, extract basic samples if available
|
||
engine_settings = {
|
||
"samples": getattr(scene, 'samples', 128) if hasattr(scene, 'samples') else 128
|
||
}
|
||
|
||
# Extract scene info
|
||
camera_count = len([obj for obj in scene.objects if obj.type == 'CAMERA'])
|
||
object_count = len(scene.objects)
|
||
material_count = len(bpy.data.materials)
|
||
|
||
# Build metadata dictionary
|
||
metadata = {
|
||
"frame_start": frame_start,
|
||
"frame_end": frame_end,
|
||
"render_settings": {
|
||
"resolution_x": resolution_x,
|
||
"resolution_y": resolution_y,
|
||
"output_format": output_format,
|
||
"engine": engine.lower(),
|
||
"engine_settings": engine_settings
|
||
},
|
||
"scene_info": {
|
||
"camera_count": camera_count,
|
||
"object_count": object_count,
|
||
"material_count": material_count
|
||
},
|
||
"missing_files_info": missing_files_info
|
||
}
|
||
|
||
# Output as JSON
|
||
print(json.dumps(metadata))
|
||
sys.stdout.flush()
|
||
`
|
||
|
||
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
|
||
c.sendStepUpdate(taskID, "extract_metadata", types.StepStatusFailed, err.Error())
|
||
return fmt.Errorf("failed to create extraction script: %w", err)
|
||
}
|
||
|
||
// Execute Blender with Python script
|
||
// Note: disable_execution flag is not applied to metadata extraction for safety
|
||
cmd := exec.Command("blender", "-b", blendFile, "--python", scriptPath)
|
||
cmd.Dir = workDir
|
||
|
||
// Capture stdout and stderr separately for line-by-line streaming
|
||
stdoutPipe, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("failed to create stdout pipe: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "extract_metadata")
|
||
c.sendStepUpdate(taskID, "extract_metadata", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
stderrPipe, err := cmd.StderrPipe()
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("failed to create stderr pipe: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "extract_metadata")
|
||
c.sendStepUpdate(taskID, "extract_metadata", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// Buffer to collect stdout for JSON parsing
|
||
var stdoutBuffer bytes.Buffer
|
||
|
||
// Start the command
|
||
if err := cmd.Start(); err != nil {
|
||
errMsg := fmt.Sprintf("failed to start blender: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "extract_metadata")
|
||
c.sendStepUpdate(taskID, "extract_metadata", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// Register process for cleanup on shutdown
|
||
c.runningProcs.Store(taskID, cmd)
|
||
defer c.runningProcs.Delete(taskID)
|
||
|
||
// Stream stdout line by line and collect for JSON parsing
|
||
stdoutDone := make(chan bool)
|
||
go func() {
|
||
defer close(stdoutDone)
|
||
scanner := bufio.NewScanner(stdoutPipe)
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
stdoutBuffer.WriteString(line)
|
||
stdoutBuffer.WriteString("\n")
|
||
if line != "" {
|
||
shouldFilter, logLevel := shouldFilterBlenderLog(line)
|
||
if !shouldFilter {
|
||
c.sendLog(taskID, logLevel, line, "extract_metadata")
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
|
||
// Stream stderr line by line
|
||
stderrDone := make(chan bool)
|
||
go func() {
|
||
defer close(stderrDone)
|
||
scanner := bufio.NewScanner(stderrPipe)
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
if line != "" {
|
||
shouldFilter, logLevel := shouldFilterBlenderLog(line)
|
||
if !shouldFilter {
|
||
// Use the filtered log level, but if it's still WARN, keep it as WARN
|
||
if logLevel == types.LogLevelInfo {
|
||
logLevel = types.LogLevelWarn
|
||
}
|
||
c.sendLog(taskID, logLevel, line, "extract_metadata")
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
|
||
// Wait for command to complete
|
||
err = cmd.Wait()
|
||
|
||
// Wait for streaming goroutines to finish
|
||
<-stdoutDone
|
||
<-stderrDone
|
||
if err != nil {
|
||
errMsg := fmt.Sprintf("blender metadata extraction failed: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "extract_metadata")
|
||
c.sendStepUpdate(taskID, "extract_metadata", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
// Parse output (metadata is printed to stdout)
|
||
metadataJSON := strings.TrimSpace(stdoutBuffer.String())
|
||
// Extract JSON from output (Blender may print other stuff)
|
||
jsonStart := strings.Index(metadataJSON, "{")
|
||
jsonEnd := strings.LastIndex(metadataJSON, "}")
|
||
if jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart {
|
||
errMsg := "failed to extract JSON from Blender output"
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "extract_metadata")
|
||
c.sendStepUpdate(taskID, "extract_metadata", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
metadataJSON = metadataJSON[jsonStart : jsonEnd+1]
|
||
|
||
var metadata types.BlendMetadata
|
||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||
errMsg := fmt.Sprintf("Failed to parse metadata JSON: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "extract_metadata")
|
||
c.sendStepUpdate(taskID, "extract_metadata", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
c.sendLog(taskID, types.LogLevelInfo, fmt.Sprintf("Metadata extracted: frames %d-%d, resolution %dx%d",
|
||
metadata.FrameStart, metadata.FrameEnd, metadata.RenderSettings.ResolutionX, metadata.RenderSettings.ResolutionY), "extract_metadata")
|
||
c.sendStepUpdate(taskID, "extract_metadata", types.StepStatusCompleted, "")
|
||
|
||
// Step: submit_metadata
|
||
c.sendStepUpdate(taskID, "submit_metadata", types.StepStatusRunning, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "Submitting metadata to manager...", "submit_metadata")
|
||
|
||
// Submit metadata to manager
|
||
if err := c.submitMetadata(jobID, metadata); err != nil {
|
||
errMsg := fmt.Sprintf("Failed to submit metadata: %v", err)
|
||
c.sendLog(taskID, types.LogLevelError, errMsg, "submit_metadata")
|
||
c.sendStepUpdate(taskID, "submit_metadata", types.StepStatusFailed, errMsg)
|
||
return errors.New(errMsg)
|
||
}
|
||
|
||
c.sendStepUpdate(taskID, "submit_metadata", types.StepStatusCompleted, "")
|
||
c.sendLog(taskID, types.LogLevelInfo, "Metadata extraction completed successfully", "")
|
||
|
||
// Mark task as complete
|
||
c.sendTaskComplete(taskID, "", true, "")
|
||
return nil
|
||
}
|
||
|
||
// submitMetadata submits extracted metadata to the manager
|
||
func (c *Client) submitMetadata(jobID int64, metadata types.BlendMetadata) error {
|
||
metadataJSON, err := json.Marshal(metadata)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||
}
|
||
|
||
path := fmt.Sprintf("/api/runner/jobs/%d/metadata?runner_id=%d", jobID, c.runnerID)
|
||
url := fmt.Sprintf("%s%s", c.managerURL, path)
|
||
req, err := http.NewRequest("POST", url, bytes.NewReader(metadataJSON))
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create request: %w", err)
|
||
}
|
||
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("X-Runner-Secret", c.runnerSecret)
|
||
|
||
resp, err := c.httpClient.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to submit metadata: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("metadata submission failed: %s", string(body))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// completeTask marks a task as complete via WebSocket (or HTTP fallback)
|
||
func (c *Client) completeTask(taskID int64, outputPath string, success bool, errorMsg string) error {
|
||
return c.sendTaskComplete(taskID, outputPath, success, errorMsg)
|
||
}
|
||
|
||
// sendTaskComplete sends task completion via WebSocket
|
||
func (c *Client) sendTaskComplete(taskID int64, outputPath string, success bool, errorMsg string) error {
|
||
c.wsConnMu.RLock()
|
||
conn := c.wsConn
|
||
c.wsConnMu.RUnlock()
|
||
|
||
if conn != nil {
|
||
// Serialize all WebSocket writes to prevent concurrent write panics
|
||
c.wsWriteMu.Lock()
|
||
defer c.wsWriteMu.Unlock()
|
||
|
||
msg := map[string]interface{}{
|
||
"type": "task_complete",
|
||
"data": map[string]interface{}{
|
||
"task_id": taskID,
|
||
"output_path": outputPath,
|
||
"success": success,
|
||
"error": errorMsg,
|
||
},
|
||
"timestamp": time.Now().Unix(),
|
||
}
|
||
if err := conn.WriteJSON(msg); err != nil {
|
||
return fmt.Errorf("failed to send task completion: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
return fmt.Errorf("WebSocket not connected, cannot complete task")
|
||
}
|