export media: dialog UI — container→codec gating, color space/bit depth, range

- 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)
This commit is contained in:
2026-09-02 09:31:49 +08:00
parent 499246b48e
commit 92813707bc
11 changed files with 276 additions and 3 deletions
+8
View File
@@ -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"
+8
View File
@@ -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"
+8
View File
@@ -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"
+8
View File
@@ -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 dexport"
"export.range.all": "Toute la timeline"
"export.range.inout": "Points dentrée/sortie"
"project.export.format": "Format du projet"
"export.format.placeholder": "Sélectionner un format…"
"export.path": "Chemin de sortie"
+8
View File
@@ -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": "出力先"
+8
View File
@@ -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"
+8
View File
@@ -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": "Путь вывода"
+8
View File
@@ -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": "输出路径"
+2 -2
View File
@@ -2883,7 +2883,7 @@ impl<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
}
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) => {
+200 -1
View File
@@ -993,14 +993,23 @@ impl Render for PathField {
/// path field.
pub struct ExportDialogContent {
format: Entity<ComboBox>,
video_codec: Entity<ComboBox>,
audio_codec: Entity<ComboBox>,
color: Entity<ComboBox>,
range: Entity<ComboBox>,
path: Entity<PathField>,
/// (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>) -> 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<i32>| {
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::<Vec<_>>()
};
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<Self>) {
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::<Vec<_>>();
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::<Vec<_>>();
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<i32> {
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<i32> {
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(),
@@ -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<ComboBoxOption>, cx: &mut Context<Self>) {
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<usize>, _cx: &mut Context<Self>) {
self.selected = selected;