package encoding import ( "fmt" "log" "os/exec" "strconv" "strings" ) const ( // CRFH264 is the Constant Rate Factor for H.264 encoding (lower = higher quality, range 0-51) CRFH264 = 15 // CRFAV1 is the Constant Rate Factor for AV1 encoding (lower = higher quality, range 0-63) CRFAV1 = 30 // CRFVP9 is the Constant Rate Factor for VP9 encoding (lower = higher quality, range 0-63) CRFVP9 = 30 ) // tonemapFilter returns the appropriate filter for EXR input. // For HDR preservation: converts linear RGB (EXR) to bt2020 YUV with HLG transfer function // Uses zscale to properly convert colorspace from linear RGB to bt2020 YUV while preserving HDR range // Step 1: Ensure format is gbrpf32le (linear RGB) // Step 2: Convert transfer function from linear to HLG (arib-std-b67) with bt2020 primaries/matrix // Step 3: Convert to YUV format func tonemapFilter(useAlpha bool) string { // Convert from linear RGB (gbrpf32le) to HLG with bt709 primaries to match PNG appearance // Based on best practices: convert linear RGB directly to HLG with bt709 primaries // This matches PNG color appearance (bt709 primaries) while preserving HDR range (HLG transfer) // zscale uses numeric values: // primaries: 1=bt709 (matches PNG), 9=bt2020 // matrix: 1=bt709, 9=bt2020nc, 0=gbr (RGB input) // transfer: 8=linear, 18=arib-std-b67 (HLG) // Direct conversion: linear RGB -> HLG with bt709 primaries -> bt2020 YUV (for wider gamut metadata) // The bt709 primaries in the conversion match PNG, but we set bt2020 in metadata for HDR displays // Convert linear RGB to sRGB first, then convert to HLG // This approach: linear -> sRGB -> HLG -> bt2020 // Fixes red tint by using sRGB conversion, preserves HDR range with HLG filter := "format=gbrpf32le,zscale=transferin=8:transfer=13:primariesin=1:primaries=1:matrixin=0:matrix=1:rangein=full:range=full,zscale=transferin=13:transfer=18:primariesin=1:primaries=9:matrixin=1:matrix=9:rangein=full:range=full" if useAlpha { return filter + ",format=yuva420p10le" } return filter + ",format=yuv420p10le" } // SoftwareEncoder implements software encoding (libx264, libaom-av1, libvpx-vp9). type SoftwareEncoder struct { codec string } func (e *SoftwareEncoder) Name() string { return "software" } func (e *SoftwareEncoder) Codec() string { return e.codec } func (e *SoftwareEncoder) Available() bool { return true // Software encoding is always available } func (e *SoftwareEncoder) BuildCommand(config *EncodeConfig) *exec.Cmd { // Use HDR pixel formats for EXR, SDR for PNG var pixFmt string var colorPrimaries, colorTrc, colorspace string if config.SourceFormat == "png" { // PNG: SDR format pixFmt = "yuv420p" if config.UseAlpha { pixFmt = "yuva420p" } colorPrimaries = "bt709" colorTrc = "bt709" colorspace = "bt709" } else { // EXR: Use HDR encoding if PreserveHDR is true, otherwise SDR (like PNG) if config.PreserveHDR { // HDR: Use HLG transfer with bt709 primaries to preserve HDR range while matching PNG color pixFmt = "yuv420p10le" // 10-bit to preserve HDR range if config.UseAlpha { pixFmt = "yuva420p10le" } colorPrimaries = "bt709" // bt709 primaries to match PNG color appearance colorTrc = "arib-std-b67" // HLG transfer function - preserves HDR range, works on SDR displays colorspace = "bt709" // bt709 colorspace to match PNG } else { // SDR: Treat as SDR (like PNG) - encode as bt709 pixFmt = "yuv420p" if config.UseAlpha { pixFmt = "yuva420p" } colorPrimaries = "bt709" colorTrc = "bt709" colorspace = "bt709" } } var codecArgs []string switch e.codec { case "libaom-av1": codecArgs = []string{"-crf", strconv.Itoa(CRFAV1), "-b:v", "0", "-tiles", "2x2", "-g", "240"} case "libvpx-vp9": // VP9 supports alpha and HDR, use good quality settings codecArgs = []string{"-crf", strconv.Itoa(CRFVP9), "-b:v", "0", "-row-mt", "1", "-g", "240"} default: // H.264: Use High 10 profile for HDR EXR (10-bit), High profile for SDR if config.SourceFormat != "png" && config.PreserveHDR { codecArgs = []string{"-preset", "veryslow", "-crf", strconv.Itoa(CRFH264), "-profile:v", "high10", "-level", "5.2", "-tune", "film", "-keyint_min", "24", "-g", "240", "-bf", "2", "-refs", "4"} } else { codecArgs = []string{"-preset", "veryslow", "-crf", strconv.Itoa(CRFH264), "-profile:v", "high", "-level", "5.2", "-tune", "film", "-keyint_min", "24", "-g", "240", "-bf", "2", "-refs", "4"} } } args := []string{ "-y", "-f", "image2", "-start_number", fmt.Sprintf("%d", config.StartFrame), "-framerate", fmt.Sprintf("%.2f", config.FrameRate), "-i", config.InputPattern, "-c:v", e.codec, "-pix_fmt", pixFmt, "-r", fmt.Sprintf("%.2f", config.FrameRate), "-color_primaries", colorPrimaries, "-color_trc", colorTrc, "-colorspace", colorspace, "-color_range", "tv", } // Add video filter for EXR: convert linear RGB based on HDR setting // PNG doesn't need any filter as it's already in sRGB if config.SourceFormat != "png" { var vf string if config.PreserveHDR { // HDR: Convert linear RGB -> sRGB -> HLG with bt709 primaries // This preserves HDR range while matching PNG color appearance vf = "format=gbrpf32le,zscale=transferin=8:transfer=13:primariesin=1:primaries=1:matrixin=0:matrix=1:rangein=full:range=full,zscale=transferin=13:transfer=18:primariesin=1:primaries=1:matrixin=1:matrix=1:rangein=full:range=full" if config.UseAlpha { vf += ",format=yuva420p10le" } else { vf += ",format=yuv420p10le" } } else { // SDR: Convert linear RGB (EXR) to sRGB (bt709) - simple conversion like Krita does // zscale: linear (8) -> sRGB (13) with bt709 primaries/matrix vf = "format=gbrpf32le,zscale=transferin=8:transfer=13:primariesin=1:primaries=1:matrixin=0:matrix=1:rangein=full:range=full" if config.UseAlpha { vf += ",format=yuva420p" } else { vf += ",format=yuv420p" } } args = append(args, "-vf", vf) } args = append(args, codecArgs...) if config.TwoPass { // For 2-pass, this builds pass 2 command args = append(args, "-pass", "2") } args = append(args, config.OutputPath) if config.TwoPass { log.Printf("Build Software Pass 2 command: ffmpeg %s", strings.Join(args, " ")) } else { log.Printf("Build Software command: ffmpeg %s", strings.Join(args, " ")) } cmd := exec.Command("ffmpeg", args...) cmd.Dir = config.WorkDir return cmd } // BuildPass1Command builds the first pass command for 2-pass encoding. func (e *SoftwareEncoder) BuildPass1Command(config *EncodeConfig) *exec.Cmd { // Use HDR pixel formats for EXR, SDR for PNG var pixFmt string var colorPrimaries, colorTrc, colorspace string if config.SourceFormat == "png" { // PNG: SDR format pixFmt = "yuv420p" if config.UseAlpha { pixFmt = "yuva420p" } colorPrimaries = "bt709" colorTrc = "bt709" colorspace = "bt709" } else { // EXR: Use HDR encoding if PreserveHDR is true, otherwise SDR (like PNG) if config.PreserveHDR { // HDR: Use HLG transfer with bt709 primaries to preserve HDR range while matching PNG color pixFmt = "yuv420p10le" // 10-bit to preserve HDR range if config.UseAlpha { pixFmt = "yuva420p10le" } colorPrimaries = "bt709" // bt709 primaries to match PNG color appearance colorTrc = "arib-std-b67" // HLG transfer function - preserves HDR range, works on SDR displays colorspace = "bt709" // bt709 colorspace to match PNG } else { // SDR: Treat as SDR (like PNG) - encode as bt709 pixFmt = "yuv420p" if config.UseAlpha { pixFmt = "yuva420p" } colorPrimaries = "bt709" colorTrc = "bt709" colorspace = "bt709" } } var codecArgs []string switch e.codec { case "libaom-av1": codecArgs = []string{"-crf", strconv.Itoa(CRFAV1), "-b:v", "0", "-tiles", "2x2", "-g", "240"} case "libvpx-vp9": // VP9 supports alpha and HDR, use good quality settings codecArgs = []string{"-crf", strconv.Itoa(CRFVP9), "-b:v", "0", "-row-mt", "1", "-g", "240"} default: // H.264: Use High 10 profile for HDR EXR (10-bit), High profile for SDR if config.SourceFormat != "png" && config.PreserveHDR { codecArgs = []string{"-preset", "veryslow", "-crf", strconv.Itoa(CRFH264), "-profile:v", "high10", "-level", "5.2", "-tune", "film", "-keyint_min", "24", "-g", "240", "-bf", "2", "-refs", "4"} } else { codecArgs = []string{"-preset", "veryslow", "-crf", strconv.Itoa(CRFH264), "-profile:v", "high", "-level", "5.2", "-tune", "film", "-keyint_min", "24", "-g", "240", "-bf", "2", "-refs", "4"} } } args := []string{ "-y", "-f", "image2", "-start_number", fmt.Sprintf("%d", config.StartFrame), "-framerate", fmt.Sprintf("%.2f", config.FrameRate), "-i", config.InputPattern, "-c:v", e.codec, "-pix_fmt", pixFmt, "-r", fmt.Sprintf("%.2f", config.FrameRate), "-color_primaries", colorPrimaries, "-color_trc", colorTrc, "-colorspace", colorspace, "-color_range", "tv", } // Add video filter for EXR: convert linear RGB based on HDR setting // PNG doesn't need any filter as it's already in sRGB if config.SourceFormat != "png" { var vf string if config.PreserveHDR { // HDR: Convert linear RGB -> sRGB -> HLG with bt709 primaries // This preserves HDR range while matching PNG color appearance vf = "format=gbrpf32le,zscale=transferin=8:transfer=13:primariesin=1:primaries=1:matrixin=0:matrix=1:rangein=full:range=full,zscale=transferin=13:transfer=18:primariesin=1:primaries=1:matrixin=1:matrix=1:rangein=full:range=full" if config.UseAlpha { vf += ",format=yuva420p10le" } else { vf += ",format=yuv420p10le" } } else { // SDR: Convert linear RGB (EXR) to sRGB (bt709) - simple conversion like Krita does // zscale: linear (8) -> sRGB (13) with bt709 primaries/matrix vf = "format=gbrpf32le,zscale=transferin=8:transfer=13:primariesin=1:primaries=1:matrixin=0:matrix=1:rangein=full:range=full" if config.UseAlpha { vf += ",format=yuva420p" } else { vf += ",format=yuv420p" } } args = append(args, "-vf", vf) } args = append(args, codecArgs...) args = append(args, "-pass", "1", "-f", "null", "/dev/null") log.Printf("Build Software Pass 1 command: ffmpeg %s", strings.Join(args, " ")) cmd := exec.Command("ffmpeg", args...) cmd.Dir = config.WorkDir return cmd }