export media: settings protocol (codec/color/bit-depth/bitrate/range) end to end

- ExportSettings: container format, video/audio codecs, size/fps/
  bitrate, bit depth (8/10 → U8/U10), SDR 709 + HDR 2020 code points,
  export range
- AppEngine::start_export_with(settings, path); real builds the
  task params via encoding_params_with_settings (codec tables gate
  each container — a non-supporting combo errors, never silently
  defaults; the one menu only offers compatible codecs)
- oak-task EncodingParams: video_bit_rate / audio_bit_rate +
  color_override_enabled + primaries/trc/space (override wins over
  the project's delivery colorspace)
- mock start_export_with (no-op fake progress)

The dialog UI rebuild (fields + container→codec→color enability
linking) follows in the next unit.
This commit is contained in:
2026-09-02 09:27:43 +08:00
parent 56af662b83
commit 499246b48e
5 changed files with 232 additions and 14 deletions
+62 -2
View File
@@ -810,6 +810,15 @@ pub trait AppEngine:
/// [`ExportSession`] carries the event channel and the cancel handle.
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String>;
/// Like [`Self::start_export`] but with the full dialog settings
/// (codecs, color space/bit depth, size/rate/bitrate, range). The
/// default start_export stays for the legacy callers.
fn start_export_with(
&mut self,
settings: &ExportSettings,
path: PathBuf,
) -> Result<ExportSession, String>;
// -------------------------------------------------------------------
// Proxy media (the C++ Tools > proxy pipeline): global switch, per
// footage state and the generate / delete / reveal entries. Defaults
@@ -1504,8 +1513,59 @@ pub enum ExportEvent {
/// A running export: the event channel the host drains plus the cancel
/// handle. Dropping the session does not abort the export thread; the
/// thread owns the task and frees it when it finishes.
pub struct ExportSession {
/// The event receiver (the background thread's sender lives as long as
/// The export dialog's settings (文件 → 导出媒体…): container format,
/// video/audio codecs, color space + bit depth, output size / rate /
/// bitrate and the export range. The engine's `start_export` consumes
/// these to build the oak-task `EncodingParams` (the codec tables gate
/// which combos are selectable — an incompatible codec is never offered).
#[derive(Debug, Clone)]
pub struct ExportSettings {
/// The container format id ([`oak_codec::exportformat::Format`]).
pub format: i32,
/// The video codec ([`oak_codec::exportcodec::Codec`]).
pub video_codec: i32,
/// The audio codec ([`oak_codec::exportcodec::Codec`]).
pub audio_codec: i32,
/// Output size (`(width, height)`; `(0,0)` keeps the sequence size).
pub size: (i32, i32),
/// Output frame rate (fps; `0` = the sequence rate).
pub frame_rate: f64,
/// Video bitrate in bit/s (`0` = codec default).
pub video_bitrate: i64,
/// Audio bitrate in bit/s (`0` = codec default).
pub audio_bitrate: i64,
/// Bit depth: 8 → U8, 10 → U10 (SDR / HDR respectively).
pub bit_depth: i32,
/// AVColorPrimaries (SDR 709 = 1, HDR 2020 = 9).
pub color_primaries: i32,
/// AVColorTransferCharacteristic (SDR 709 = 1, HDR PQ = 16).
pub color_transfer: i32,
/// AVColorSpace (SDR 709 = 1, HDR 2020 = 9).
pub color_space: i32,
/// Export range: `None` = whole sequence, `Some((in_s, out_s))`.
pub range: Option<(f64, f64)>,
}
impl Default for ExportSettings {
fn default() -> Self {
Self {
format: 2, // MPEG4Video (.mp4)
video_codec: 1, // H.264
audio_codec: 12, // AAC
size: (0, 0),
frame_rate: 0.0,
video_bitrate: 0,
audio_bitrate: 0,
bit_depth: 8,
color_primaries: 1,
color_transfer: 1,
color_space: 1,
range: None,
}
}
}
pub struct ExportSession { /// The event receiver (the background thread's sender lives as long as
/// the session's `cancel` side, so a dropped receiver just stops
/// delivering).
pub events: std::sync::mpsc::Receiver<ExportEvent>,
+8
View File
@@ -2012,6 +2012,14 @@ impl AppEngine for MockEngine {
}
fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result<ExportSession, String> {
self.start_export_with(&crate::oakui::engine::ExportSettings::default(), _path)
}
fn start_export_with(
&mut self,
_settings: &crate::oakui::engine::ExportSettings,
_path: PathBuf,
) -> Result<ExportSession, String> {
// Mock export: fake progress on a background thread, no file.
let (tx, rx) = mpsc::channel::<ExportEvent>();
std::thread::spawn(move || {
+14 -2
View File
@@ -6196,14 +6196,26 @@ impl AppEngine for RealEngine {
}
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String> {
let settings = super::engine::ExportSettings {
format,
..Default::default()
};
self.start_export_with(&settings, path)
}
fn start_export_with(
&mut self,
settings: &super::engine::ExportSettings,
path: PathBuf,
) -> Result<ExportSession, String> {
let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else {
return Err("no sequence open".into());
};
let workarea = self.workarea().map(|(s, e)| (s.0, e.0));
let params = super::renderops::encoding_params(
let params = super::renderops::encoding_params_with_settings(
&project,
seq,
format,
settings,
&path,
workarea,
self.sequence_length().0,
+113
View File
@@ -1096,6 +1096,119 @@ pub fn encoding_params(
custom_range_in_den: range_in.denominator() as i32,
custom_range_out_num: range_out.numerator() as i32,
custom_range_out_den: range_out.denominator() as i32,
video_bit_rate: 0,
audio_bit_rate: 0,
color_override_enabled: false,
color_primaries: 0,
color_trc: 0,
color_space: 0,
})
}
/// Like [`encoding_params`] but with the dialog's full settings: chosen
/// video/audio codecs, color space + bit depth, output size / rate /
/// bitrate and the export range. The sequence's parameters fill anything
/// the settings left at zero.
pub fn encoding_params_with_settings(
p: &ProjectRef,
seq: NodeId,
settings: &crate::oakui::engine::ExportSettings,
path: &std::path::Path,
workarea: Option<(i64, i64)>,
length_frames: i64,
) -> Result<oak_task::export::EncodingParams, String> {
let container = oak_codec::exportformat::Format::from_i32(settings.format)
.ok_or_else(|| format!("unknown export format {}", settings.format))?;
// ONLY the codecs the container supports are selectable upstream (the
// dialog rebuilds its lists from these tables); the chosen codec must
// be in the table — a mismatch is a hard error, not a silent default.
let video_codec = oak_codec::exportformat::Format::get_video_codecs(container)
.iter()
.find(|c| **c as i32 == settings.video_codec)
.copied()
.ok_or_else(|| format!("codec {} not supported by {container:?}", settings.video_codec))?;
let audio_codec = oak_codec::exportformat::Format::get_audio_codecs(container)
.iter()
.find(|c| **c as i32 == settings.audio_codec)
.copied()
.ok_or_else(|| format!("audio codec {} not supported by {container:?}", settings.audio_codec))?;
let (mut width, mut height, rate) = {
let g = lock(p);
super::graphops::sequence_video_params(&g.graph, seq)
.ok_or_else(|| "the sequence has no video parameters".to_string())?
};
if settings.size.0 > 0 && settings.size.1 > 0 {
width = settings.size.0;
height = settings.size.1;
}
let mut rate_num = rate.numerator().max(1) as i32;
let mut rate_den = rate.denominator().max(1) as i32;
if settings.frame_rate > 0.0 {
// Round to the nearest 1/1 frame; 29.97 etc. stay as-is.
rate_num = settings.frame_rate.round().max(1.0) as i32;
rate_den = 1;
}
let (has_custom_range, range_in, range_out, length) = match settings.range {
Some((in_s, out_s)) if out_s > in_s && in_s >= 0.0 => {
let fps = i64::from(rate_num.max(1));
(
true,
Rational::new((in_s * fps as f64).round().max(0.0) as i64, fps),
Rational::new((out_s * fps as f64).round().max(0.0) as i64, fps),
Rational::new(((out_s - in_s) * fps as f64).round().max(0.0) as i64, fps),
)
}
_ => match workarea.filter(|(s, e)| e > s) {
Some((in_ts, out_ts)) => (
true,
Rational::new(in_ts * i64::from(rate_den.max(1)), i64::from(rate_num.max(1))),
Rational::new(out_ts * i64::from(rate_den.max(1)), i64::from(rate_num.max(1))),
Rational::new((out_ts - in_ts) * i64::from(rate_den.max(1)), i64::from(rate_num.max(1))),
),
None => (
false,
Rational::new(0, 1),
Rational::new(0, 1),
Rational::new(length_frames.max(0) * i64::from(rate_den.max(1)), i64::from(rate_num.max(1))),
),
},
};
let bit_depth = settings.bit_depth;
let pixel_format = match bit_depth {
10 => 1, // PixelFormat::U10 (only when the codec supports it; the
// dialog gates 10-bit behind HDR — codecs without 10-bit
// stay at the 8-bit default and the dialog disables HDR).
_ => 0, // PixelFormat::U8
};
Ok(oak_task::export::EncodingParams {
filename: path.to_string_lossy().into_owned(),
format: settings.format,
video_enabled: true,
video_codec: video_codec as i32,
video_width: width.max(1),
video_height: height.max(1),
video_time_base_num: rate_den.max(1),
video_time_base_den: rate_num.max(1),
video_pixel_format: pixel_format,
audio_enabled: true,
audio_codec: audio_codec as i32,
audio_sample_rate: EXPORT_SAMPLE_RATE,
audio_channel_layout: EXPORT_CHANNEL_LAYOUT,
subtitles_enabled: false,
export_length_num: length.numerator() as i32,
export_length_den: length.denominator() as i32,
has_custom_range,
custom_range_in_num: range_in.numerator() as i32,
custom_range_in_den: range_in.denominator() as i32,
custom_range_out_num: range_out.numerator() as i32,
custom_range_out_den: range_out.denominator() as i32,
video_bit_rate: settings.video_bitrate,
audio_bit_rate: settings.audio_bitrate,
color_override_enabled: true,
color_primaries: settings.color_primaries,
color_trc: settings.color_transfer,
color_space: settings.color_space,
})
}
+35 -10
View File
@@ -105,14 +105,29 @@ pub struct EncodingParams {
/// set, [`ExportTask::export_range`] renders exactly `[in, out)` instead
/// of the whole viewer length.
pub has_custom_range: bool,
/// Custom range in point numerator (seconds rational).
/// Custom range in, rational seconds.
pub custom_range_in_num: i32,
/// Custom range in point denominator.
/// Custom range in denominator.
pub custom_range_in_den: i32,
/// Custom range out point numerator (seconds rational).
/// Custom range out, rational seconds.
pub custom_range_out_num: i32,
/// Custom range out point denominator.
/// Custom range out denominator.
pub custom_range_out_den: i32,
/// Video bit rate (bit/s; 0 = codec default).
pub video_bit_rate: i64,
/// Audio bit rate (bit/s; 0 = codec default).
pub audio_bit_rate: i64,
/// Delivery colorimetry override flags: `0` = use the project's output
/// spec (legacy), `1` = apply the `color_*` code points from the export
/// dialog (SDR 709 / HDR 2020 selection).
pub color_override_enabled: bool,
/// AVColorPrimaries (SDR 709 = 1, HDR BT.2020 = 9).
pub color_primaries: i32,
/// AVColorTransferCharacteristic (SDR 709 = 1, HDR PQ = 16).
pub color_trc: i32,
/// AVColorSpace (SDR 709 = 1, HDR BT.2020 = 9).
pub color_space: i32,
}
impl ExportTask {
@@ -164,15 +179,25 @@ impl ExportTask {
params.subtitles_enabled = self.encoding_params.subtitles_enabled as i32;
params.export_length_num = self.encoding_params.export_length_num;
params.export_length_den = self.encoding_params.export_length_den;
// Delivery colorimetry: tag the output container with the project's
params.video_bit_rate = self.encoding_params.video_bit_rate;
params.audio_bit_rate = self.encoding_params.audio_bit_rate;
// Delivery colorimetry: the EXPORT DIALOG's choice (SDR 709 8-bit /
// HDR 2020 10-bit) wins when enabled; otherwise the project's
// output colorspace (H.273 code points → mov `colr` atom / VUI).
// Limited range is the video-delivery convention; the encoder's
// RGB→YCbCr runs limited.
let (_working, spec) = self.delivery_color();
params.color_primaries = spec.gamut.av_color_primaries();
params.color_trc = spec.transfer.av_color_trc();
params.color_space = spec.gamut.av_color_space();
params.color_range = 1; // AVCOL_RANGE_MPEG (limited)
if self.encoding_params.color_override_enabled {
params.color_primaries = self.encoding_params.color_primaries;
params.color_trc = self.encoding_params.color_trc;
params.color_space = self.encoding_params.color_space;
params.color_range = 1; // AVCOL_RANGE_MPEG (limited)
} else {
let (_working, spec) = self.delivery_color();
params.color_primaries = spec.gamut.av_color_primaries();
params.color_trc = spec.transfer.av_color_trc();
params.color_space = spec.gamut.av_color_space();
params.color_range = 1; // AVCOL_RANGE_MPEG (limited)
}
params
}