diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index 177bf0699..e7a385986 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -374,6 +374,7 @@ "actionsearch.no_actions": "Keine Aktionen verfügbar" "export.title": "Sequenz exportieren" "export.format": "Format" +"project.export.format": "Projektformat" "export.format.placeholder": "Format auswählen…" "export.path": "Ausgabepfad" "export.run": "Exportieren" diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index cefe27f77..42f081fb6 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -372,6 +372,7 @@ "actionsearch.no_actions": "No actions available" "export.title": "Export Sequence" "export.format": "Format" +"project.export.format": "Project Format" "export.format.placeholder": "Select a format…" "export.path": "Output path" "export.run": "Export" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index 65d9e00c3..ee5c8ca84 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -374,6 +374,7 @@ "actionsearch.no_actions": "No hay acciones disponibles" "export.title": "Exportar secuencia" "export.format": "Formato" +"project.export.format": "Formato del proyecto" "export.format.placeholder": "Seleccionar un formato…" "export.path": "Ruta de salida" "export.run": "Exportar" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index 381ca1de0..9c3b20538 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -374,6 +374,7 @@ "actionsearch.no_actions": "Aucune action disponible" "export.title": "Exporter la séquence" "export.format": "Format" +"project.export.format": "Format du projet" "export.format.placeholder": "Sélectionner un format…" "export.path": "Chemin de sortie" "export.run": "Exporter" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index 7edad9756..a337aeded 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -374,6 +374,7 @@ "actionsearch.no_actions": "利用可能なアクションがありません" "export.title": "シーケンスを書き出し" "export.format": "フォーマット" +"project.export.format": "プロジェクト形式" "export.format.placeholder": "フォーマットを選択…" "export.path": "出力先" "export.run": "書き出し" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index 136a1fa15..45a87b2be 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -374,6 +374,7 @@ "actionsearch.no_actions": "Nenhuma ação disponível" "export.title": "Exportar sequência" "export.format": "Formato" +"project.export.format": "Formato do projeto" "export.format.placeholder": "Selecionar um formato…" "export.path": "Caminho de saída" "export.run": "Exportar" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index 3bd6fca66..27ba33ced 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -374,6 +374,7 @@ "actionsearch.no_actions": "Нет доступных действий" "export.title": "Экспорт последовательности" "export.format": "Формат" +"project.export.format": "Формат проекта" "export.format.placeholder": "Выберите формат…" "export.path": "Путь вывода" "export.run": "Экспортировать" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index 22a57f6fd..3b838ad43 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -372,6 +372,7 @@ "actionsearch.no_actions": "没有可用动作" "export.title": "导出序列" "export.format": "格式" +"project.export.format": "项目格式" "export.format.placeholder": "选择格式…" "export.path": "输出路径" "export.run": "导出" diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index fdbabe0c4..bc57ec546 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -140,6 +140,8 @@ mod modal_ids { pub const MULTICAM_WIZARD: usize = 17; /// The rename dialog (project explorer 重命名). pub const RENAME: usize = 18; + /// The project export dialog (文件 → 导出工程文件…). + pub const EXPORT_PROJECT_DFD: usize = 19; } /// What a picked platform-dialog path should do. @@ -239,6 +241,12 @@ enum ModalState { content: Entity, entry_id: u64, }, + /// The project export dialog (文件 → 导出工程文件…): choose the + /// project format (OTIO / OVE / FCPXML) + the output path. + ExportProjectDfd { + modal: Entity, + content: Entity, + }, } /// A running export: the session the tick loop drains for progress. @@ -266,6 +274,7 @@ impl ModalState { | ModalState::DropSequenceChoice { modal, .. } | ModalState::MulticamWizard { modal, .. } | ModalState::EntryRename { modal, .. } => Some(modal.clone()), + | ModalState::ExportProjectDfd { modal, .. } => Some(modal.clone()), } } } @@ -1014,9 +1023,8 @@ impl OakApp { A::OpenProject => self.open_file_dialog(FileAction::Open, cx), A::OpenFromLibrary | A::ProjectManager => self.show_project_manager(cx), A::Import => self.open_file_dialog(FileAction::ImportFootage, cx), - A::SaveProject | A::SaveProjectAs => { - self.open_file_dialog(FileAction::ExportProjectFile, cx) - } + // The 导出工程文件… dialog: choose OTIO / OVE / FCPXML + path. + A::SaveProject | A::SaveProjectAs => self.open_export_project_dfd(cx), A::CloseProject => self .engine .update(cx, |engine, cx| engine.close_project(cx)), @@ -2542,6 +2550,55 @@ impl OakApp { }); } + /// Opens the 导出工程文件 dialog: choose the project file format + /// (OTIO / OVE / FCPXML) and the output path; the OK button runs the + /// engine's export_project_path with the picked extension. + fn open_export_project_dfd(&mut self, cx: &mut Context) { + if !matches!(self.modal, ModalState::None) { + return; + } + let current = self + .engine + .read(cx) + .project() + .map(|p| p.path.clone()) + .filter(|p| !p.as_os_str().is_empty()); + let suggested = current + .and_then(|path| { + path.file_stem() + .map(|name| name.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| "untitled".to_string()); + self.spawn_modal(cx, move |window, app| { + let content = app.new(|cx| { + crate::dialogs::ExportProjectDialogContent::new(window, cx) + }); + // Default selection is OTIO: pre-fill the pathway with the + // correct extension, the OK handler applies the chosen one. + content.update(app, |content, cx| { + content.set_path(format!("{suggested}.otio"), cx) + }); + let modal = app.new(|cx| { + Modal::new( + modal_ids::EXPORT_PROJECT_DFD, + ModalOptions::new( + crate::i18n::tr("menu.file.export_project"), + px(480.0), + ) + .with_button(DialogButton::primary(crate::i18n::tr("dialog.ok"))) + .with_button(DialogButton::new( + crate::i18n::tr("dialog.cancel"), + gpui_widgets::dialog::DialogButtonRole::Secondary, + )), + window, + cx, + ) + .with_content(content.clone()) + }); + ModalState::ExportProjectDfd { modal, content } + }); + } + /// Opens the multicam wizard (窗口 → 多机位向导): pick the angle /// footage, choose the sync mode, then create the multicam sequence. fn open_multicam_wizard(&mut self, cx: &mut Context) { @@ -2946,6 +3003,43 @@ impl OakApp { } } } + modal_ids::EXPORT_PROJECT_DFD => { + if let ModalState::ExportProjectDfd { content, .. } = &self.modal { + if *button == 0 { + // OK: export with the chosen format — the path + // gets the format's extension appended. + let format = content.read(cx).format(cx); + let ext = match format { + crate::dialogs::PROJECT_FORMAT_OVE => "ove", + crate::dialogs::PROJECT_FORMAT_FCPXML => "fcpxml", + _ => "otio", + }; + let path = content.read(cx).path(cx).to_string(); + let path = if path.to_ascii_lowercase().ends_with(".otio") + || path.to_ascii_lowercase().ends_with(".ove") + || path.to_ascii_lowercase().ends_with(".fcpxml") + { + // Stem swap: the entered name already has + // an extension — replace it. + let stem = path + .split('.') + .next() + .unwrap_or(&path) + .to_string(); + format!("{stem}.{ext}") + } else { + format!("{path}.{ext}") + }; + let engine = self.engine.clone(); + engine.update(cx, |engine, cx| { + engine.export_project_path(PathBuf::from(path), cx) + }); + self.close_modal(cx); + } else { + self.close_modal(cx); + } + } + } modal_ids::SEQUENCE_PROPERTIES => { if let ModalState::SequenceProperties { content, .. } = &self.modal { let content = content.clone(); diff --git a/crates/oak-app/src/dialogs.rs b/crates/oak-app/src/dialogs.rs index 378891199..e769fc913 100644 --- a/crates/oak-app/src/dialogs.rs +++ b/crates/oak-app/src/dialogs.rs @@ -3857,6 +3857,101 @@ impl Render for RenameContent { } } +/// The project export dialog (文件 → 导出工程文件…): choose the project +/// file format (OTIO / OVE / FCPXML) and the output path. The host reads +/// [`Self::format`] + [`Self::path`] on OK. +pub struct ExportProjectDialogContent { + format: Entity, + path: Entity, + /// (format id, display name) in dropdown order. + formats: Vec<(i32, String)>, +} + +/// The project-file format ids (mirror the engine's serializer dispatch; +/// the extension is derived on the host). +pub const PROJECT_FORMAT_OTIO: i32 = 0; +pub const PROJECT_FORMAT_OVE: i32 = 1; +pub const PROJECT_FORMAT_FCPXML: i32 = 2; + +impl ExportProjectDialogContent { + /// Builds the dialog (OTIO default, empty path). + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let formats: Vec<(i32, String)> = vec![ + (PROJECT_FORMAT_OTIO, "OpenTimelineIO (.otio)".to_string()), + (PROJECT_FORMAT_OVE, "Oak (.ove)".to_string()), + (PROJECT_FORMAT_FCPXML, "Final Cut Pro XML (.fcpxml)".to_string()), + ]; + let options = formats + .iter() + .enumerate() + .map(|(i, (_, name))| ComboBoxOption::new(i, name.clone())) + .collect(); + let format = cx.new(|cx| { + ComboBox::new(4, options, window, cx) + .with_placeholder(i18n::tr("project.export.format")) + }); + format.update(cx, |combo, cx| combo.set_selected(Some(PROJECT_FORMAT_OTIO as usize), cx)); + let path = cx.new(|cx| { + let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + PathField { editor, enabled: true } + }); + Self { format, path, formats } + } + + /// The selected project format id. + pub fn format(&self, cx: &App) -> i32 { + let Some(selected) = self.format.read(cx).selected() else { + return PROJECT_FORMAT_OTIO; + }; + self.formats + .get(selected) + .map(|(id, _)| *id) + .unwrap_or(PROJECT_FORMAT_OTIO) + } + + /// The format's file extension (no dot). + pub fn extension(&self, cx: &App) -> &'static str { + match self.format(cx) { + PROJECT_FORMAT_OVE => "ove", + PROJECT_FORMAT_FCPXML => "fcpxml", + _ => "otio", + } + } + + /// The output path currently entered. + pub fn path(&self, cx: &App) -> SharedString { + self.path.read(cx).path(cx) + } + + /// Pre-fills the output path (the suggested file name). + pub fn set_path(&mut self, path: impl Into, cx: &mut Context) { + let path = path.into(); + self.path.update(cx, |field, cx| field.set_path(path, cx)); + cx.notify(); + } +} + +impl Render for ExportProjectDialogContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(form_row( + &colors, + i18n::tr("project.export.format").into(), + self.format.clone(), + )) + .child(form_row( + &colors, + i18n::tr("export.path").into(), + self.path.clone(), + )) + } +} + #[cfg(test)] mod tests { use super::*;