package tasks import ( "bufio" "errors" "fmt" "log" "math" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "jiggablend/internal/runner/encoding" ) // EncodeProcessor handles encode tasks. type EncodeProcessor struct{} // NewEncodeProcessor creates a new encode processor. func NewEncodeProcessor() *EncodeProcessor { return &EncodeProcessor{} } // Process executes an encode task. func (p *EncodeProcessor) Process(ctx *Context) error { ctx.Info(fmt.Sprintf("Starting encode task: job %d", ctx.JobID)) log.Printf("Processing encode task %d for job %d", ctx.TaskID, ctx.JobID) // Create temporary work directory workDir, err := ctx.Workspace.CreateVideoDir(ctx.JobID) if err != nil { return fmt.Errorf("failed to create work directory: %w", err) } defer func() { if err := ctx.Workspace.CleanupVideoDir(ctx.JobID); err != nil { log.Printf("Warning: Failed to cleanup encode work directory: %v", err) } }() // Get output format and frame rate outputFormat := ctx.GetOutputFormat() if outputFormat == "" { outputFormat = "EXR_264_MP4" } frameRate := ctx.GetFrameRate() ctx.Info(fmt.Sprintf("Encode: detected output format '%s'", outputFormat)) ctx.Info(fmt.Sprintf("Encode: using frame rate %.2f fps", frameRate)) // Get job files files, err := ctx.Manager.GetJobFiles(ctx.JobID) if err != nil { ctx.Error(fmt.Sprintf("Failed to get job files: %v", err)) return fmt.Errorf("failed to get job files: %w", err) } ctx.Info(fmt.Sprintf("GetJobFiles returned %d total files for job %d", len(files), ctx.JobID)) // Log all files for debugging for _, file := range files { ctx.Info(fmt.Sprintf("File: %s (type: %s, size: %d)", file.FileName, file.FileType, file.FileSize)) } // Determine source format based on output format sourceFormat := "exr" fileExt := ".exr" // Find and deduplicate frame files (EXR or PNG) frameFileSet := make(map[string]bool) var frameFilesList []string for _, file := range files { if file.FileType == "output" && strings.HasSuffix(strings.ToLower(file.FileName), fileExt) { // Deduplicate by filename if !frameFileSet[file.FileName] { frameFileSet[file.FileName] = true frameFilesList = append(frameFilesList, file.FileName) } } } if len(frameFilesList) == 0 { // Log why no files matched (deduplicate for error reporting) outputFileSet := make(map[string]bool) frameFilesOtherTypeSet := make(map[string]bool) var outputFiles []string var frameFilesOtherType []string for _, file := range files { if file.FileType == "output" { if !outputFileSet[file.FileName] { outputFileSet[file.FileName] = true outputFiles = append(outputFiles, file.FileName) } } if strings.HasSuffix(strings.ToLower(file.FileName), fileExt) { key := fmt.Sprintf("%s (type: %s)", file.FileName, file.FileType) if !frameFilesOtherTypeSet[key] { frameFilesOtherTypeSet[key] = true frameFilesOtherType = append(frameFilesOtherType, key) } } } ctx.Error(fmt.Sprintf("no %s frame files found for encode: found %d total files, %d unique output files, %d unique %s files (with other types)", strings.ToUpper(fileExt[1:]), len(files), len(outputFiles), len(frameFilesOtherType), strings.ToUpper(fileExt[1:]))) if len(outputFiles) > 0 { ctx.Error(fmt.Sprintf("Output files found: %v", outputFiles)) } if len(frameFilesOtherType) > 0 { ctx.Error(fmt.Sprintf("%s files with wrong type: %v", strings.ToUpper(fileExt[1:]), frameFilesOtherType)) } err := fmt.Errorf("no %s frame files found for encode", strings.ToUpper(fileExt[1:])) return err } ctx.Info(fmt.Sprintf("Found %d %s frames for encode", len(frameFilesList), strings.ToUpper(fileExt[1:]))) // Download frames ctx.Info(fmt.Sprintf("Downloading %d %s frames for encode...", len(frameFilesList), strings.ToUpper(fileExt[1:]))) var frameFiles []string for i, fileName := range frameFilesList { ctx.Info(fmt.Sprintf("Downloading frame %d/%d: %s", i+1, len(frameFilesList), fileName)) framePath := filepath.Join(workDir, fileName) if err := ctx.Manager.DownloadFrame(ctx.JobID, fileName, framePath); err != nil { ctx.Error(fmt.Sprintf("Failed to download %s frame %s: %v", strings.ToUpper(fileExt[1:]), fileName, err)) log.Printf("Failed to download %s frame for encode %s: %v", strings.ToUpper(fileExt[1:]), fileName, err) continue } ctx.Info(fmt.Sprintf("Successfully downloaded frame %d/%d: %s", i+1, len(frameFilesList), fileName)) frameFiles = append(frameFiles, framePath) } if len(frameFiles) == 0 { err := fmt.Errorf("failed to download any %s frames for encode", strings.ToUpper(fileExt[1:])) ctx.Error(err.Error()) return err } sort.Strings(frameFiles) ctx.Info(fmt.Sprintf("Downloaded %d frames", len(frameFiles))) // Check if EXR files have alpha channel and HDR content (only for EXR source format) hasAlpha := false hasHDR := false if sourceFormat == "exr" { // Check first frame for alpha channel and HDR using ffprobe firstFrame := frameFiles[0] hasAlpha = detectAlphaChannel(ctx, firstFrame) if hasAlpha { ctx.Info("Detected alpha channel in EXR files") } else { ctx.Info("No alpha channel detected in EXR files") } hasHDR = detectHDR(ctx, firstFrame) if hasHDR { ctx.Info("Detected HDR content in EXR files") } else { ctx.Info("No HDR content detected in EXR files (SDR range)") } } // Generate video // Use alpha if: // 1. User explicitly enabled it OR source has alpha channel AND // 2. Codec supports alpha (AV1 or VP9) preserveAlpha := ctx.ShouldPreserveAlpha() useAlpha := (preserveAlpha || hasAlpha) && (outputFormat == "EXR_AV1_MP4" || outputFormat == "EXR_VP9_WEBM") if (preserveAlpha || hasAlpha) && outputFormat == "EXR_264_MP4" { ctx.Warn("Alpha channel requested/detected but H.264 does not support alpha. Consider using EXR_AV1_MP4 or EXR_VP9_WEBM to preserve alpha.") } if preserveAlpha && !hasAlpha { ctx.Warn("Alpha preservation requested but no alpha channel detected in EXR files.") } if useAlpha { if preserveAlpha && hasAlpha { ctx.Info("Alpha preservation enabled: Using alpha channel encoding") } else if hasAlpha { ctx.Info("Alpha channel detected - automatically enabling alpha encoding") } } var outputExt string switch outputFormat { case "EXR_VP9_WEBM": outputExt = "webm" ctx.Info("Encoding WebM video with VP9 codec (with alpha channel and HDR support)...") case "EXR_AV1_MP4": outputExt = "mp4" ctx.Info("Encoding MP4 video with AV1 codec (with alpha channel)...") default: outputExt = "mp4" ctx.Info("Encoding MP4 video with H.264 codec...") } outputVideo := filepath.Join(workDir, fmt.Sprintf("output_%d.%s", ctx.JobID, outputExt)) // Build input pattern firstFrame := frameFiles[0] baseName := filepath.Base(firstFrame) re := regexp.MustCompile(`_(\d+)\.`) var pattern string var startNumber int frameNumStr := re.FindStringSubmatch(baseName) if len(frameNumStr) > 1 { pattern = re.ReplaceAllString(baseName, "_%04d.") fmt.Sscanf(frameNumStr[1], "%d", &startNumber) } else { startNumber = extractFrameNumber(baseName) pattern = strings.Replace(baseName, fmt.Sprintf("%d", startNumber), "%04d", 1) } patternPath := filepath.Join(workDir, pattern) // Select encoder and build command (software encoding only) var encoder encoding.Encoder switch outputFormat { case "EXR_AV1_MP4": encoder = ctx.Encoder.SelectAV1() case "EXR_VP9_WEBM": encoder = ctx.Encoder.SelectVP9() default: encoder = ctx.Encoder.SelectH264() } ctx.Info(fmt.Sprintf("Using encoder: %s (%s)", encoder.Name(), encoder.Codec())) // All software encoders use 2-pass for optimal quality ctx.Info("Starting 2-pass encode for optimal quality...") // Pass 1 ctx.Info("Pass 1/2: Analyzing content for optimal encode...") softEncoder := encoder.(*encoding.SoftwareEncoder) // Use HDR if: user explicitly enabled it OR HDR content was detected preserveHDR := (ctx.ShouldPreserveHDR() || hasHDR) && sourceFormat == "exr" if hasHDR && !ctx.ShouldPreserveHDR() { ctx.Info("HDR content detected - automatically enabling HDR preservation") } pass1Cmd := softEncoder.BuildPass1Command(&encoding.EncodeConfig{ InputPattern: patternPath, OutputPath: outputVideo, StartFrame: startNumber, FrameRate: frameRate, WorkDir: workDir, UseAlpha: useAlpha, TwoPass: true, SourceFormat: sourceFormat, PreserveHDR: preserveHDR, }) if err := pass1Cmd.Run(); err != nil { ctx.Warn(fmt.Sprintf("Pass 1 completed (warnings expected): %v", err)) } // Pass 2 ctx.Info("Pass 2/2: Encoding with optimal quality...") preserveHDR = (ctx.ShouldPreserveHDR() || hasHDR) && sourceFormat == "exr" if preserveHDR { if hasHDR && !ctx.ShouldPreserveHDR() { ctx.Info("HDR preservation enabled (auto-detected): Using HLG transfer with bt709 primaries") } else { ctx.Info("HDR preservation enabled: Using HLG transfer with bt709 primaries") } } config := &encoding.EncodeConfig{ InputPattern: patternPath, OutputPath: outputVideo, StartFrame: startNumber, FrameRate: frameRate, WorkDir: workDir, UseAlpha: useAlpha, TwoPass: true, // Software encoding always uses 2-pass for quality SourceFormat: sourceFormat, PreserveHDR: preserveHDR, } cmd := encoder.BuildCommand(config) if cmd == nil { return errors.New("failed to build encode command") } // Set up pipes stdoutPipe, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("failed to create stdout pipe: %w", err) } stderrPipe, err := cmd.StderrPipe() if err != nil { return fmt.Errorf("failed to create stderr pipe: %w", err) } if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start encode command: %w", err) } ctx.Processes.Track(ctx.TaskID, cmd) defer ctx.Processes.Untrack(ctx.TaskID) // Stream stdout stdoutDone := make(chan bool) go func() { defer close(stdoutDone) scanner := bufio.NewScanner(stdoutPipe) for scanner.Scan() { line := scanner.Text() if line != "" { ctx.Info(line) } } }() // Stream stderr stderrDone := make(chan bool) go func() { defer close(stderrDone) scanner := bufio.NewScanner(stderrPipe) for scanner.Scan() { line := scanner.Text() if line != "" { ctx.Warn(line) } } }() err = cmd.Wait() <-stdoutDone <-stderrDone if err != nil { var errMsg string if exitErr, ok := err.(*exec.ExitError); ok { if exitErr.ExitCode() == 137 { errMsg = "FFmpeg was killed due to excessive memory usage (OOM)" } else { errMsg = fmt.Sprintf("ffmpeg encoding failed: %v", err) } } else { errMsg = fmt.Sprintf("ffmpeg encoding failed: %v", err) } if sizeErr := checkFFmpegSizeError(errMsg); sizeErr != nil { ctx.Error(sizeErr.Error()) return sizeErr } ctx.Error(errMsg) return errors.New(errMsg) } // Verify output if _, err := os.Stat(outputVideo); os.IsNotExist(err) { err := fmt.Errorf("video %s file not created: %s", outputExt, outputVideo) ctx.Error(err.Error()) return err } // Clean up 2-pass log files os.Remove(filepath.Join(workDir, "ffmpeg2pass-0.log")) os.Remove(filepath.Join(workDir, "ffmpeg2pass-0.log.mbtree")) ctx.Info(fmt.Sprintf("%s video encoded successfully", strings.ToUpper(outputExt))) // Upload video ctx.Info(fmt.Sprintf("Uploading encoded %s video...", strings.ToUpper(outputExt))) uploadPath := fmt.Sprintf("/api/runner/jobs/%d/upload", ctx.JobID) if err := ctx.Manager.UploadFile(uploadPath, ctx.JobToken, outputVideo); err != nil { ctx.Error(fmt.Sprintf("Failed to upload %s: %v", strings.ToUpper(outputExt), err)) return fmt.Errorf("failed to upload %s: %w", strings.ToUpper(outputExt), err) } ctx.Info(fmt.Sprintf("Successfully uploaded %s: %s", strings.ToUpper(outputExt), filepath.Base(outputVideo))) log.Printf("Successfully generated and uploaded %s for job %d: %s", strings.ToUpper(outputExt), ctx.JobID, filepath.Base(outputVideo)) return nil } // detectAlphaChannel checks if an EXR file has an alpha channel using ffprobe func detectAlphaChannel(ctx *Context, filePath string) bool { // Use ffprobe to check pixel format and stream properties // EXR files with alpha will have formats like gbrapf32le (RGBA) vs gbrpf32le (RGB) cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=pix_fmt:stream=codec_name", "-of", "default=noprint_wrappers=1", filePath, ) output, err := cmd.Output() if err != nil { // If ffprobe fails, assume no alpha (conservative approach) ctx.Warn(fmt.Sprintf("Failed to detect alpha channel in %s: %v", filepath.Base(filePath), err)) return false } outputStr := string(output) // Check pixel format - EXR with alpha typically has 'a' in the format name (e.g., gbrapf32le) // Also check for formats that explicitly indicate alpha hasAlpha := strings.Contains(outputStr, "pix_fmt=gbrap") || strings.Contains(outputStr, "pix_fmt=rgba") || strings.Contains(outputStr, "pix_fmt=yuva") || strings.Contains(outputStr, "pix_fmt=abgr") if hasAlpha { ctx.Info(fmt.Sprintf("Detected alpha channel in EXR file: %s", filepath.Base(filePath))) } return hasAlpha } // detectHDR checks if an EXR file contains HDR content using ffprobe func detectHDR(ctx *Context, filePath string) bool { // First, check if the pixel format supports HDR (32-bit float) cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=pix_fmt", "-of", "default=noprint_wrappers=1:nokey=1", filePath, ) output, err := cmd.Output() if err != nil { // If ffprobe fails, assume no HDR (conservative approach) ctx.Warn(fmt.Sprintf("Failed to detect HDR in %s: %v", filepath.Base(filePath), err)) return false } pixFmt := strings.TrimSpace(string(output)) // EXR files with 32-bit float format (gbrpf32le, gbrapf32le) can contain HDR // Check if it's a 32-bit float format isFloat32 := strings.Contains(pixFmt, "f32") || strings.Contains(pixFmt, "f32le") if !isFloat32 { // Not a float format, definitely not HDR return false } // For 32-bit float EXR, sample pixels to check if values exceed SDR range (> 1.0) // Use ffmpeg to extract pixel statistics - check max pixel values // This is more efficient than sampling individual pixels cmd = exec.Command("ffmpeg", "-v", "error", "-i", filePath, "-vf", "signalstats", "-f", "null", "-", ) output, err = cmd.CombinedOutput() if err != nil { // If stats extraction fails, try sampling a few pixels directly return detectHDRBySampling(ctx, filePath) } // Check output for max pixel values outputStr := string(output) // Look for max values in the signalstats output // If we find values > 1.0, it's HDR if strings.Contains(outputStr, "MAX") { // Try to extract max values from signalstats output // Format is typically like: YMAX:1.234 UMAX:0.567 VMAX:0.890 // For EXR (RGB), we need to check R, G, B channels // Since signalstats works on YUV, we'll use a different approach return detectHDRBySampling(ctx, filePath) } // Fallback to pixel sampling return detectHDRBySampling(ctx, filePath) } // detectHDRBySampling samples pixels from multiple regions to detect HDR content func detectHDRBySampling(ctx *Context, filePath string) bool { // Sample multiple 10x10 regions from different parts of the image // This gives us better coverage than a single sample sampleRegions := []string{ "crop=10:10:iw/4:ih/4", // Top-left quadrant "crop=10:10:iw*3/4:ih/4", // Top-right quadrant "crop=10:10:iw/4:ih*3/4", // Bottom-left quadrant "crop=10:10:iw*3/4:ih*3/4", // Bottom-right quadrant "crop=10:10:iw/2:ih/2", // Center } for _, region := range sampleRegions { cmd := exec.Command("ffmpeg", "-v", "error", "-i", filePath, "-vf", fmt.Sprintf("%s,scale=1:1", region), "-f", "rawvideo", "-pix_fmt", "gbrpf32le", "-", ) output, err := cmd.Output() if err != nil { continue // Skip this region if sampling fails } // Parse the float32 values (4 bytes per float, 3 channels RGB) if len(output) >= 12 { // At least 3 floats (RGB) = 12 bytes for i := 0; i < len(output)-11; i += 12 { // Read RGB values (little-endian float32) r := float32FromBytes(output[i : i+4]) g := float32FromBytes(output[i+4 : i+8]) b := float32FromBytes(output[i+8 : i+12]) // Check if any channel exceeds 1.0 (SDR range) if r > 1.0 || g > 1.0 || b > 1.0 { maxVal := max(r, max(g, b)) ctx.Info(fmt.Sprintf("Detected HDR content in EXR file: %s (max value: %.2f)", filepath.Base(filePath), maxVal)) return true } } } } // If we sampled multiple regions and none exceed 1.0, it's likely SDR content // But since it's 32-bit float format, user can still manually enable HDR if needed return false } // float32FromBytes converts 4 bytes (little-endian) to float32 func float32FromBytes(bytes []byte) float32 { if len(bytes) < 4 { return 0 } bits := uint32(bytes[0]) | uint32(bytes[1])<<8 | uint32(bytes[2])<<16 | uint32(bytes[3])<<24 return math.Float32frombits(bits) } // max returns the maximum of two float32 values func max(a, b float32) float32 { if a > b { return a } return b } 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 } func checkFFmpegSizeError(output string) error { outputLower := strings.ToLower(output) if strings.Contains(outputLower, "hardware does not support encoding at size") { 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", constraintsMatch[1], constraintsMatch[2], constraintsMatch[3], constraintsMatch[4]) } return fmt.Errorf("video frame size is outside hardware encoder limits") } 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", sizeMatch[1], sizeMatch[2]) } return fmt.Errorf("invalid video frame size") } if strings.Contains(outputLower, "error while opening encoder") && (strings.Contains(outputLower, "width") || strings.Contains(outputLower, "height") || strings.Contains(outputLower, "size")) { 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", sizeMatch[1], sizeMatch[2]) } return fmt.Errorf("hardware encoder error: frame size may be invalid") } if strings.Contains(outputLower, "invalid") && (strings.Contains(outputLower, "width") || strings.Contains(outputLower, "height") || strings.Contains(outputLower, "dimension")) { return fmt.Errorf("invalid frame dimensions detected") } return nil }