project file: export dialog with OTIO/OVE/FCPXML format choice

文件 → 导出工程文件… now opens an in-app dialog (format dropdown:
OpenTimelineIO / Oak / FCPXML + output path) instead of the bare
platform save prompt; OK routes the chosen extension through the
engine's export_project_path. Adds project.export.format i18n across
the 8 packs.
This commit is contained in:
2026-09-02 09:10:39 +08:00
parent 19a83f8359
commit c900804941
10 changed files with 200 additions and 3 deletions
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -374,6 +374,7 @@
"actionsearch.no_actions": "利用可能なアクションがありません"
"export.title": "シーケンスを書き出し"
"export.format": "フォーマット"
"project.export.format": "プロジェクト形式"
"export.format.placeholder": "フォーマットを選択…"
"export.path": "出力先"
"export.run": "書き出し"
+1
View File
@@ -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"
+1
View File
@@ -374,6 +374,7 @@
"actionsearch.no_actions": "Нет доступных действий"
"export.title": "Экспорт последовательности"
"export.format": "Формат"
"project.export.format": "Формат проекта"
"export.format.placeholder": "Выберите формат…"
"export.path": "Путь вывода"
"export.run": "Экспортировать"
+1
View File
@@ -372,6 +372,7 @@
"actionsearch.no_actions": "没有可用动作"
"export.title": "导出序列"
"export.format": "格式"
"project.export.format": "项目格式"
"export.format.placeholder": "选择格式…"
"export.path": "输出路径"
"export.run": "导出"
+97 -3
View File
@@ -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<E: AppEngine> {
content: Entity<crate::dialogs::RenameContent>,
entry_id: u64,
},
/// The project export dialog (文件 → 导出工程文件…): choose the
/// project format (OTIO / OVE / FCPXML) + the output path.
ExportProjectDfd {
modal: Entity<Modal>,
content: Entity<crate::dialogs::ExportProjectDialogContent>,
},
}
/// A running export: the session the tick loop drains for progress.
@@ -266,6 +274,7 @@ impl<E: AppEngine> ModalState<E> {
| ModalState::DropSequenceChoice { modal, .. }
| ModalState::MulticamWizard { modal, .. }
| ModalState::EntryRename { modal, .. } => Some(modal.clone()),
| ModalState::ExportProjectDfd { modal, .. } => Some(modal.clone()),
}
}
}
@@ -1014,9 +1023,8 @@ impl<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
});
}
/// 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<Self>) {
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<Self>) {
@@ -2946,6 +3003,43 @@ impl<E: AppEngine> OakApp<E> {
}
}
}
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();
+95
View File
@@ -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<ComboBox>,
path: Entity<PathField>,
/// (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>) -> 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<SharedString>, cx: &mut Context<Self>) {
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<Self>) -> 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::*;