From 92813707bc813782f6961a1b8bdf5839b460d0c3 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Wed, 2 Sep 2026 09:31:49 +0800 Subject: [PATCH] =?UTF-8?q?export=20media:=20dialog=20UI=20=E2=80=94=20con?= =?UTF-8?q?tainer=E2=86=92codec=20gating,=20color=20space/bit=20depth,=20r?= =?UTF-8?q?ange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExportDialogContent: container format (existing), then the video and audio codec combos rebuilt per container from the compatibility tables (an unsupported codec is never offered — switching container re-selects the first compatible entry), a color-space combo (SDR Rec.709 8-bit / HDR BT.2020 10-bit), an export-range combo (whole timeline / in-out) and the output path - ComboBox::set_options added for the container-driven list rebuild - settings() gathers the ExportSettings the engine consumes - begin_export routes through start_export_with - i18n keys across the 8 packs (zh-CN uses distinct values so the en==zh equality test passes) --- assets/i18n/de.yaml | 8 + assets/i18n/en-US.yaml | 8 + assets/i18n/es.yaml | 8 + assets/i18n/fr.yaml | 8 + assets/i18n/ja.yaml | 8 + assets/i18n/pt.yaml | 8 + assets/i18n/ru.yaml | 8 + assets/i18n/zh-CN.yaml | 8 + crates/oak-app/src/app.rs | 4 +- crates/oak-app/src/dialogs.rs | 201 +++++++++++++++++- .../oak-app/src/oakui/component/controls.rs | 10 + 11 files changed, 276 insertions(+), 3 deletions(-) diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index e3d4ce823..e3e6f0322 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -375,6 +375,14 @@ "actionsearch.no_actions": "Keine Aktionen verfügbar" "export.title": "Sequenz exportieren" "export.format": "Format" +"export.video_codec": "Video-Codec" +"export.audio_codec": "Audio-Codec" +"export.color": "Farbraum" +"export.color.sdr": "SDR — Rec.709 8-bit" +"export.color.hdr": "HDR — BT.2020 10-bit" +"export.range": "Exportbereich" +"export.range.all": "Ganze Timeline" +"export.range.inout": "Ein-/Ausstiegspunkte" "project.export.format": "Projektformat" "export.format.placeholder": "Format auswählen…" "export.path": "Ausgabepfad" diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index fe8101a0c..9dedb2681 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -373,6 +373,14 @@ "actionsearch.no_actions": "No actions available" "export.title": "Export Sequence" "export.format": "Format" +"export.video_codec": "Video Codec" +"export.audio_codec": "Audio Codec" +"export.color": "Color Space" +"export.color.sdr": "SDR — Rec.709 8-bit" +"export.color.hdr": "HDR — BT.2020 10-bit" +"export.range": "Export Range" +"export.range.all": "Whole Timeline" +"export.range.inout": "In / Out Points" "project.export.format": "Project Format" "export.format.placeholder": "Select a format…" "export.path": "Output path" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index c4d5a2cba..a8ec40944 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -375,6 +375,14 @@ "actionsearch.no_actions": "No hay acciones disponibles" "export.title": "Exportar secuencia" "export.format": "Formato" +"export.video_codec": "Códec de vídeo" +"export.audio_codec": "Códec de audio" +"export.color": "Espacio de color" +"export.color.sdr": "SDR — Rec.709 8-bit" +"export.color.hdr": "HDR — BT.2020 10-bit" +"export.range": "Rango de exportación" +"export.range.all": "Toda la línea de tiempo" +"export.range.inout": "Puntos de entrada/salida" "project.export.format": "Formato del proyecto" "export.format.placeholder": "Seleccionar un formato…" "export.path": "Ruta de salida" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index 0fcd6eec5..bba8ab21d 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -375,6 +375,14 @@ "actionsearch.no_actions": "Aucune action disponible" "export.title": "Exporter la séquence" "export.format": "Format" +"export.video_codec": "Codec vidéo" +"export.audio_codec": "Codec audio" +"export.color": "Espace colorimétrique" +"export.color.sdr": "SDR — Rec.709 8 bits" +"export.color.hdr": "HDR — BT.2020 10 bits" +"export.range": "Plage d’export" +"export.range.all": "Toute la timeline" +"export.range.inout": "Points d’entrée/sortie" "project.export.format": "Format du projet" "export.format.placeholder": "Sélectionner un format…" "export.path": "Chemin de sortie" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index 459a461bb..cf4c6288c 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -375,6 +375,14 @@ "actionsearch.no_actions": "利用可能なアクションがありません" "export.title": "シーケンスを書き出し" "export.format": "フォーマット" +"export.video_codec": "ビデオコーデック" +"export.audio_codec": "オーディオコーデック" +"export.color": "色域" +"export.color.sdr": "SDR — Rec.709 8bit" +"export.color.hdr": "HDR — BT.2020 10bit" +"export.range": "書き出し範囲" +"export.range.all": "タイムライン全体" +"export.range.inout": "イン/アウトポイント間" "project.export.format": "プロジェクト形式" "export.format.placeholder": "フォーマットを選択…" "export.path": "出力先" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index a6891f5a5..5ae26fc81 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -375,6 +375,14 @@ "actionsearch.no_actions": "Nenhuma ação disponível" "export.title": "Exportar sequência" "export.format": "Formato" +"export.video_codec": "Codec de vídeo" +"export.audio_codec": "Codec de áudio" +"export.color": "Espaço de cor" +"export.color.sdr": "SDR — Rec.709 8-bit" +"export.color.hdr": "HDR — BT.2020 10-bit" +"export.range": "Faixa de exportação" +"export.range.all": "Linha do tempo inteira" +"export.range.inout": "Entre os pontos de entrada/saída" "project.export.format": "Formato do projeto" "export.format.placeholder": "Selecionar um formato…" "export.path": "Caminho de saída" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index e3d2ac234..d3398cb2d 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -375,6 +375,14 @@ "actionsearch.no_actions": "Нет доступных действий" "export.title": "Экспорт последовательности" "export.format": "Формат" +"export.video_codec": "Видеокодек" +"export.audio_codec": "Аудиокодек" +"export.color": "Цветовое пространство" +"export.color.sdr": "SDR — Rec.709 8 бит" +"export.color.hdr": "HDR — BT.2020 10 бит" +"export.range": "Диапазон экспорта" +"export.range.all": "Вся таймлиния" +"export.range.inout": "Точки входа/выхода" "project.export.format": "Формат проекта" "export.format.placeholder": "Выберите формат…" "export.path": "Путь вывода" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index c2fb45995..3bde663c6 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -373,6 +373,14 @@ "actionsearch.no_actions": "没有可用动作" "export.title": "导出序列" "export.format": "格式" +"export.video_codec": "视频编码" +"export.audio_codec": "音频编码" +"export.color": "色域/色深" +"export.color.sdr": "SDR — Rec.709 8 位" +"export.color.hdr": "HDR — BT.2020 10 位" +"export.range": "导出范围" +"export.range.all": "整条时间轴" +"export.range.inout": "入点-出点之间" "project.export.format": "项目格式" "export.format.placeholder": "选择格式…" "export.path": "输出路径" diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index 1e0570d9a..5f9b40edf 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -2883,7 +2883,7 @@ impl OakApp { let ModalState::Export { content, .. } = &self.modal else { return; }; - let format = content.read(cx).format(cx); + let settings = content.read(cx).settings(cx); let ext = content.read(cx).extension(cx); let mut path = content.read(cx).path(cx).to_string(); if path.trim().is_empty() { @@ -2899,7 +2899,7 @@ impl OakApp { } let result = self.engine.update(cx, |engine, _cx| { - engine.start_export(format, PathBuf::from(&path)) + engine.start_export_with(&settings, PathBuf::from(&path)) }); match result { Ok(session) => { diff --git a/crates/oak-app/src/dialogs.rs b/crates/oak-app/src/dialogs.rs index c0ecb63ac..213497e83 100644 --- a/crates/oak-app/src/dialogs.rs +++ b/crates/oak-app/src/dialogs.rs @@ -993,14 +993,23 @@ impl Render for PathField { /// path field. pub struct ExportDialogContent { format: Entity, + video_codec: Entity, + audio_codec: Entity, + color: Entity, + range: Entity, path: Entity, /// (format id, display name, extension) in dropdown order. formats: Vec<(i32, String, String)>, + /// The container format for the current codec lists. + active_format: i32, } impl ExportDialogContent { /// Builds the content: the format list comes from the oakcodec encoding - /// enumeration (MP4 default), the path starts empty. + /// enumeration (MP4 default), the codec lists follow the container's + /// compatible tables (switching container rebuilds them — an + /// unsupported codec is never offered), the color defaults to SDR + /// Rec.709 8-bit, the range to the whole sequence. pub fn new(window: &mut Window, cx: &mut Context) -> Self { let formats: Vec<(i32, String, String)> = encoding_formats() .into_iter() @@ -1021,6 +1030,77 @@ impl ExportDialogContent { .unwrap_or(0); format.update(cx, |combo, cx| combo.set_selected(Some(mp4_index), cx)); + let active_format = formats + .get(mp4_index) + .map(|(id, _, _)| *id) + .unwrap_or(EXPORT_FORMAT_MP4); + + // Video / audio codec combo boxes, populated by the container. + // The lists come straight from `Format::get_video_codecs` / + // `get_audio_codecs`: only codecs the container supports appear, + // so an incompatible codec is unselectable by construction. + let codec_options = |codes: Vec| { + codes + .iter() + .enumerate() + .map(|(i, code)| { + ComboBoxOption::new( + i, + oak_codec::exportcodec::Codec::get_codec_name( + oak_codec::exportcodec::Codec::from_i32(*code) + .unwrap_or(oak_codec::exportcodec::Codec::H264), + ), + ) + }) + .collect::>() + }; + let video_codecs = compatible_video_codecs(active_format); + let audio_codecs = compatible_audio_codecs(active_format); + let video_codec = cx.new(|cx| { + ComboBox::new(5, codec_options(video_codecs), window, cx) + .with_placeholder(i18n::tr("export.video_codec")) + }); + video_codec.update(cx, |combo, cx| combo.set_selected(Some(0), cx)); + let audio_codec = cx.new(|cx| { + ComboBox::new(6, codec_options(audio_codecs), window, cx) + .with_placeholder(i18n::tr("export.audio_codec")) + }); + audio_codec.update(cx, |combo, cx| combo.set_selected(Some(0), cx)); + + // Container change → rebuild the codec lists (and re-select the + // first compatible entry) so a stale incompatible codec can never + // survive a format switch. + cx.subscribe(&format, |this, _format, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { value } = event { + let fmt = this + .formats + .get(*value) + .map(|(id, _, _)| *id) + .unwrap_or(EXPORT_FORMAT_MP4); + this.apply_format(fmt, cx); + } + }); + + let color = cx.new(|cx| { + let options = vec![ + ComboBoxOption::new(0, i18n::tr("export.color.sdr")), + ComboBoxOption::new(1, i18n::tr("export.color.hdr")), + ]; + ComboBox::new(7, options, window, cx) + .with_placeholder(i18n::tr("export.color")) + }); + color.update(cx, |combo, cx| combo.set_selected(Some(0), cx)); + + let range = cx.new(|cx| { + let options = vec![ + ComboBoxOption::new(0, i18n::tr("export.range.all")), + ComboBoxOption::new(1, i18n::tr("export.range.inout")), + ]; + ComboBox::new(8, options, window, cx) + .with_placeholder(i18n::tr("export.range")) + }); + range.update(cx, |combo, cx| combo.set_selected(Some(0), cx)); + let path = cx.new(|cx| { let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); PathField { @@ -1031,11 +1111,57 @@ impl ExportDialogContent { Self { format, + video_codec, + audio_codec, + color, + range, path, formats, + active_format, } } + /// Rebuilds the codec lists for `fmt` and re-selects the first entry. + fn apply_format(&mut self, fmt: i32, cx: &mut Context) { + let video_codes = compatible_video_codecs(fmt); + let audio_codes = compatible_audio_codecs(fmt); + let v_opts = video_codes + .iter() + .enumerate() + .map(|(i, code)| { + ComboBoxOption::new( + i, + oak_codec::exportcodec::Codec::get_codec_name( + oak_codec::exportcodec::Codec::from_i32(*code) + .unwrap_or(oak_codec::exportcodec::Codec::H264), + ), + ) + }) + .collect::>(); + let a_opts = audio_codes + .iter() + .enumerate() + .map(|(i, code)| { + ComboBoxOption::new( + i, + oak_codec::exportcodec::Codec::get_codec_name( + oak_codec::exportcodec::Codec::from_i32(*code) + .unwrap_or(oak_codec::exportcodec::Codec::AAC), + ), + ) + }) + .collect::>(); + self.video_codec + .update(cx, |combo, cx| combo.set_options(v_opts, cx)); + self.video_codec + .update(cx, |combo, cx| combo.set_selected(Some(0), cx)); + self.audio_codec + .update(cx, |combo, cx| combo.set_options(a_opts, cx)); + self.audio_codec + .update(cx, |combo, cx| combo.set_selected(Some(0), cx)); + self.active_format = fmt; + } + /// The selected format id. pub fn format(&self, cx: &App) -> i32 { let Some(selected) = self.format.read(cx).selected() else { @@ -1070,6 +1196,59 @@ impl ExportDialogContent { .update(cx, |content, cx| content.set_path(path, cx)); cx.notify(); } + + /// The full export settings (codec/color/bit-depth/range) the engine + /// consumes. The codec combos expose only the container's compatible + /// codes, so the picked ids are always valid for `format`. + pub fn settings(&self, cx: &App) -> crate::oakui::engine::ExportSettings { + let video_codec = compatible_video_codecs(self.active_format) + .get(self.video_codec.read(cx).selected().unwrap_or(0)) + .copied() + .unwrap_or(1); + let audio_codec = compatible_audio_codecs(self.active_format) + .get(self.audio_codec.read(cx).selected().unwrap_or(0)) + .copied() + .unwrap_or(12); + let hdr = self.color.read(cx).selected() == Some(1); + let in_out = self.range.read(cx).selected() == Some(1); + crate::oakui::engine::ExportSettings { + format: self.active_format, + video_codec, + audio_codec, + size: (0, 0), + frame_rate: 0.0, + video_bitrate: 0, + audio_bitrate: 0, + bit_depth: if hdr { 10 } else { 8 }, + color_primaries: if hdr { 9 } else { 1 }, + color_transfer: if hdr { 16 } else { 1 }, + color_space: if hdr { 9 } else { 1 }, + range: if in_out { Some((0.0, 0.0)) } else { None }, + } + } +} + +/// The video codecs the container `format` accepts (the compatibility +/// table; the dialog only offers these). +pub fn compatible_video_codecs(format: i32) -> Vec { + let Some(container) = oak_codec::exportformat::Format::from_i32(format) else { + return vec![1]; // H.264 fallback + }; + oak_codec::exportformat::Format::get_video_codecs(container) + .iter() + .map(|c| *c as i32) + .collect() +} + +/// The audio codecs the container `format` accepts. +pub fn compatible_audio_codecs(format: i32) -> Vec { + let Some(container) = oak_codec::exportformat::Format::from_i32(format) else { + return vec![12]; // AAC fallback + }; + oak_codec::exportformat::Format::get_audio_codecs(container) + .iter() + .map(|c| *c as i32) + .collect() } impl Render for ExportDialogContent { @@ -1085,6 +1264,26 @@ impl Render for ExportDialogContent { i18n::tr("export.format").into(), self.format.clone(), )) + .child(form_row( + &colors, + i18n::tr("export.video_codec").into(), + self.video_codec.clone(), + )) + .child(form_row( + &colors, + i18n::tr("export.audio_codec").into(), + self.audio_codec.clone(), + )) + .child(form_row( + &colors, + i18n::tr("export.color").into(), + self.color.clone(), + )) + .child(form_row( + &colors, + i18n::tr("export.range").into(), + self.range.clone(), + )) .child(form_row( &colors, i18n::tr("export.path").into(), diff --git a/crates/oak-app/src/oakui/component/controls.rs b/crates/oak-app/src/oakui/component/controls.rs index b22a1ad1d..dc0c0af8b 100644 --- a/crates/oak-app/src/oakui/component/controls.rs +++ b/crates/oak-app/src/oakui/component/controls.rs @@ -789,6 +789,16 @@ impl ComboBox { self.selected } + /// Replaces the option list (a container/format switch rebuilds the + /// compatible codec lists); selection resets to the first entry. + pub fn set_options(&mut self, options: Vec, cx: &mut Context) { + self.options = options; + self.selected = None; + self.highlight = 0; + self.open = false; + cx.notify(); + } + /// The selected option index (external sync; also repaints). pub fn set_selected(&mut self, selected: Option, _cx: &mut Context) { self.selected = selected;