diff --git a/crates/enc-ffmpeg/src/video/prores.rs b/crates/enc-ffmpeg/src/video/prores.rs index f88ccb70cb1..198fed797ab 100644 --- a/crates/enc-ffmpeg/src/video/prores.rs +++ b/crates/enc-ffmpeg/src/video/prores.rs @@ -53,11 +53,15 @@ impl ProResEncoderBuilder { .unwrap_or((input_config.width, input_config.height)); let output_format = format::Pixel::YUVA444P10LE; + // Set to `false` if swscale can't be told to emit full range, so the + // range we declare on the stream keeps matching the pixels we hand it. + let mut declare_full_range = true; + let converter = if input_config.pixel_format != output_format || input_config.width != output_width || input_config.height != output_height { - Some(ffmpeg::software::scaling::Context::get( + let mut context = ffmpeg::software::scaling::Context::get( input_config.pixel_format, input_config.width, input_config.height, @@ -65,7 +69,72 @@ impl ProResEncoderBuilder { output_width, output_height, ffmpeg::software::scaling::flag::Flags::BICUBIC, - )?) + )?; + + // swscale defaults to limited-range (16-235) YUV output, but this + // encoder advertises `Range::JPEG` in the stream metadata below. + // Left as-is the two disagree: full-range RGB is squeezed into + // limited range, then players expand it again as if it were full + // range, which crushes contrast and shifts colour. + // + // ProRes 4444 is a full-range format, so tell swscale to match + // what we declare. + unsafe { + let mut inv_table: *mut i32 = std::ptr::null_mut(); + let mut table: *mut i32 = std::ptr::null_mut(); + let mut src_range: i32 = 0; + let mut dst_range: i32 = 0; + let mut brightness: i32 = 0; + let mut contrast: i32 = 0; + let mut saturation: i32 = 0; + + let details = ffmpeg::ffi::sws_getColorspaceDetails( + context.as_mut_ptr(), + &mut inv_table, + &mut src_range, + &mut table, + &mut dst_range, + &mut brightness, + &mut contrast, + &mut saturation, + ); + + if details < 0 { + tracing::warn!( + "sws_getColorspaceDetails failed ({details}); falling back to declaring limited range" + ); + declare_full_range = false; + } else { + let coefficients = ffmpeg::ffi::sws_getCoefficients(ffmpeg::ffi::SWS_CS_ITU709); + + if coefficients.is_null() { + tracing::warn!( + "sws_getCoefficients returned null for ITU709; falling back to declaring limited range" + ); + declare_full_range = false; + } else { + let ret = ffmpeg::ffi::sws_setColorspaceDetails( + context.as_mut_ptr(), + coefficients, + 1, + coefficients, + 1, + brightness, + contrast, + saturation, + ); + + if ret < 0 { + tracing::warn!( + "sws_setColorspaceDetails failed ({ret}); falling back to declaring limited range" + ); + declare_full_range = false; + } + } + } + } + + Some(context) } else { None }; @@ -83,7 +152,13 @@ impl ProResEncoderBuilder { encoder.set_time_base(input_config.time_base); encoder.set_frame_rate(Some(input_config.frame_rate)); encoder.set_colorspace(color::Space::BT709); - encoder.set_color_range(color::Range::JPEG); + encoder.set_color_range(if declare_full_range { + color::Range::JPEG + } else { + // swscale is still emitting its limited-range default, so declaring + // full range here would recreate the very mismatch this avoids. + color::Range::MPEG + }); unsafe { (*encoder.as_mut_ptr()).color_primaries = ffmpeg::ffi::AVColorPrimaries::AVCOL_PRI_BT709;