From c0ea49df41af7da34fbf4455276a110d36a94b28 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 19:15:24 +0900 Subject: [PATCH 1/4] fix(export): match ProRes swscale output to the declared full range The ProRes encoder advertises Range::JPEG (full range) in the stream metadata, but the RGBA -> YUVA444P10LE conversion used swscale's default, which emits limited range (16-235). Players then expand the already limited-range samples as if they were full range, crushing contrast and shifting colour. Tell swscale to emit full range so the pixels match what the stream declares. ProRes 4444 is a full-range format, so the declaration is correct and the conversion was the side that was wrong. --- crates/enc-ffmpeg/src/video/prores.rs | 49 +++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/enc-ffmpeg/src/video/prores.rs b/crates/enc-ffmpeg/src/video/prores.rs index f88ccb70cb1..34fd2c3d298 100644 --- a/crates/enc-ffmpeg/src/video/prores.rs +++ b/crates/enc-ffmpeg/src/video/prores.rs @@ -57,7 +57,7 @@ impl ProResEncoderBuilder { || 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 +65,52 @@ 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: *const i32 = std::ptr::null(); + let mut table: *const i32 = std::ptr::null(); + 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; + + if ffmpeg::ffi::sws_getColorspaceDetails( + context.as_mut_ptr(), + &mut inv_table as *mut _ as *mut *mut i32, + &mut src_range, + &mut table as *mut _ as *mut *mut i32, + &mut dst_range, + &mut brightness, + &mut contrast, + &mut saturation, + ) >= 0 + { + let coefficients = ffmpeg::ffi::sws_getCoefficients(ffmpeg::ffi::SWS_CS_ITU709); + + ffmpeg::ffi::sws_setColorspaceDetails( + context.as_mut_ptr(), + coefficients, + 1, + coefficients, + 1, + brightness, + contrast, + saturation, + ); + } + } + + Some(context) } else { None }; From c2f455fd10092bded7ef2f295854d6774ba0a245 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 19:20:05 +0900 Subject: [PATCH 2/4] refactor: match the FFI pointer types instead of casting Addresses review feedback: sws_getColorspaceDetails takes *mut *mut i32, so declaring the locals as *mut i32 removes the const-to-mut casts. --- crates/enc-ffmpeg/src/video/prores.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/enc-ffmpeg/src/video/prores.rs b/crates/enc-ffmpeg/src/video/prores.rs index 34fd2c3d298..d2f0488fecf 100644 --- a/crates/enc-ffmpeg/src/video/prores.rs +++ b/crates/enc-ffmpeg/src/video/prores.rs @@ -76,8 +76,8 @@ impl ProResEncoderBuilder { // ProRes 4444 is a full-range format, so tell swscale to match // what we declare. unsafe { - let mut inv_table: *const i32 = std::ptr::null(); - let mut table: *const i32 = std::ptr::null(); + 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; @@ -86,9 +86,9 @@ impl ProResEncoderBuilder { if ffmpeg::ffi::sws_getColorspaceDetails( context.as_mut_ptr(), - &mut inv_table as *mut _ as *mut *mut i32, + &mut inv_table, &mut src_range, - &mut table as *mut _ as *mut *mut i32, + &mut table, &mut dst_range, &mut brightness, &mut contrast, From b217944a71f4e4766585e63e4336feb446bd8f12 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 21:30:14 +0900 Subject: [PATCH 3/4] fix(export): log when the ProRes colour-range override cannot be applied --- crates/enc-ffmpeg/src/video/prores.rs | 43 +++++++++++++++++++-------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/enc-ffmpeg/src/video/prores.rs b/crates/enc-ffmpeg/src/video/prores.rs index d2f0488fecf..3290638f221 100644 --- a/crates/enc-ffmpeg/src/video/prores.rs +++ b/crates/enc-ffmpeg/src/video/prores.rs @@ -84,7 +84,7 @@ impl ProResEncoderBuilder { let mut contrast: i32 = 0; let mut saturation: i32 = 0; - if ffmpeg::ffi::sws_getColorspaceDetails( + let details = ffmpeg::ffi::sws_getColorspaceDetails( context.as_mut_ptr(), &mut inv_table, &mut src_range, @@ -93,20 +93,37 @@ impl ProResEncoderBuilder { &mut brightness, &mut contrast, &mut saturation, - ) >= 0 - { - let coefficients = ffmpeg::ffi::sws_getCoefficients(ffmpeg::ffi::SWS_CS_ITU709); + ); - ffmpeg::ffi::sws_setColorspaceDetails( - context.as_mut_ptr(), - coefficients, - 1, - coefficients, - 1, - brightness, - contrast, - saturation, + if details < 0 { + tracing::warn!( + "sws_getColorspaceDetails failed ({details}); ProRes output will stay limited-range while the stream declares full range" ); + } else { + let coefficients = ffmpeg::ffi::sws_getCoefficients(ffmpeg::ffi::SWS_CS_ITU709); + + if coefficients.is_null() { + tracing::warn!( + "sws_getCoefficients returned null for ITU709; leaving swscale colour range unchanged" + ); + } 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}); ProRes output will stay limited-range while the stream declares full range" + ); + } + } } } From bb913fd885c38359512a17924c8644e369b5a265 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 21:40:16 +0900 Subject: [PATCH 4/4] fix(export): declare limited range when swscale can't be set to full range --- crates/enc-ffmpeg/src/video/prores.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/enc-ffmpeg/src/video/prores.rs b/crates/enc-ffmpeg/src/video/prores.rs index 3290638f221..198fed797ab 100644 --- a/crates/enc-ffmpeg/src/video/prores.rs +++ b/crates/enc-ffmpeg/src/video/prores.rs @@ -53,6 +53,10 @@ 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 @@ -97,15 +101,17 @@ impl ProResEncoderBuilder { if details < 0 { tracing::warn!( - "sws_getColorspaceDetails failed ({details}); ProRes output will stay limited-range while the stream declares full range" + "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; leaving swscale colour range unchanged" + "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(), @@ -120,8 +126,9 @@ impl ProResEncoderBuilder { if ret < 0 { tracing::warn!( - "sws_setColorspaceDetails failed ({ret}); ProRes output will stay limited-range while the stream declares full range" + "sws_setColorspaceDetails failed ({ret}); falling back to declaring limited range" ); + declare_full_range = false; } } } @@ -145,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;