Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 78 additions & 3 deletions crates/enc-ffmpeg/src/video/prores.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,88 @@ 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,
output_format,
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thought on the failure path: if sws_getColorspaceDetails/sws_setColorspaceDetails fails, we’ll still declare Range::JPEG later, which reintroduces the mismatch you’re fixing (just with a warning). Might be worth tracking a declare_full_range flag here and falling back to Range::MPEG when swscale can’t be configured, so the file stays internally consistent.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and this is the better version of my own fix — adopted in bb913fd88.

My warnings described the mismatch instead of preventing it: the log said "output will stay limited-range while the stream declares full range", which is just an accurate description of the original bug with better observability. Tracking the flag makes the file internally consistent either way.

let mut declare_full_range = true;
// ... any of the three failure paths sets it to false
encoder.set_color_range(if declare_full_range { color::Range::JPEG } else { color::Range::MPEG });

So the two possible outcomes are now: swscale configured full-range + Range::JPEG declared (the fix), or swscale left at its limited-range default + Range::MPEG declared (correct, just not the wider range). Neither declares something the pixels don't match.

One thing I checked before wiring the flag, since it decides whether the fallback is reachable at all: declare_full_range is only ever cleared inside the converter branch, so I wanted to confirm the None branch isn't a silent third case. It isn't reachable in practice — ProResEncoder::input_format() is RawVideoFormat::Rgba and output_format is YUVA444P10LE, so input_config.pixel_format != output_format always holds and a converter is always built. The flag stays true on a path that can't occur, and if that input format ever changes there'd be no swscale conversion to disagree with.

cargo check, cargo clippy, cargo fmt --check all clean for cap-enc-ffmpeg.

tracing::warn!(
"sws_setColorspaceDetails failed ({ret}); falling back to declaring limited range"
);
declare_full_range = false;
}
}
}
}

Some(context)
} else {
None
};
Expand All @@ -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;
Expand Down