diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index ac47d03f9..d6b9fd366 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -22,7 +22,7 @@ "menu.file.export_project": "Projektdatei exportieren…" "menu.file.save_as": "Speichern unter…" "menu.file.revert": "Wiederherstellen" -"menu.file.export_media": "Medien exportieren…" +"menu.file.export_media": "Sequenz exportieren…" "menu.file.project_properties": "Projekteigenschaften…" "menu.file.close": "Projekt schließen" "menu.file.quit": "Beenden" @@ -389,6 +389,7 @@ "project.export.format": "Projektformat" "export.format.placeholder": "Format auswählen…" "export.path": "Ausgabepfad" +"export.sequence": "Sequenz" "export.run": "Exportieren" "export.hint": "Die Sequenz wird über den oaktask-Exportpfad exportiert; der Fortschritt wird im Dialog angezeigt." "export.progress.title": "Exportieren" @@ -483,6 +484,7 @@ "project.context.reveal_in_finder": "Im Finder anzeigen" "project.context.replace_footage": "Material ersetzen" "project.context.rename": "Umbenennen" +"project.context.export_sequence": "Sequenz exportieren…" "project.context.delete": "Löschen" "project.context.open_in_new_tab": "In neuem Tab öffnen" "project.context.open_in_new_window": "In neuem Fenster öffnen" diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index 7405c8fa4..206d1edfe 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -22,7 +22,7 @@ "menu.file.export_project": "Export Project File…" "menu.file.save_as": "Save As…" "menu.file.revert": "Revert" -"menu.file.export_media": "Export Media…" +"menu.file.export_media": "Export Sequence…" "menu.file.project_properties": "Project Properties…" "menu.file.close": "Close Project" "menu.file.quit": "Quit" @@ -387,6 +387,7 @@ "project.export.format": "Project Format" "export.format.placeholder": "Select a format…" "export.path": "Output path" +"export.sequence": "Sequence" "export.run": "Export" "export.hint": "The sequence is exported through the oaktask export path; progress is shown in the dialog." "export.progress.title": "Exporting" @@ -481,6 +482,7 @@ "project.context.reveal_in_finder": "Reveal in Finder" "project.context.replace_footage": "Replace Footage" "project.context.rename": "Rename" +"project.context.export_sequence": "Export Sequence…" "project.context.delete": "Delete" "project.context.open_in_new_tab": "Open in New Tab" "project.context.open_in_new_window": "Open in New Window" diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index 80e230f06..fb824f7aa 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -22,7 +22,7 @@ "menu.file.export_project": "Exportar archivo de proyecto…" "menu.file.save_as": "Guardar como…" "menu.file.revert": "Revertir" -"menu.file.export_media": "Exportar medios…" +"menu.file.export_media": "Exportar secuencia…" "menu.file.project_properties": "Propiedades del proyecto…" "menu.file.close": "Cerrar proyecto" "menu.file.quit": "Salir" @@ -389,6 +389,7 @@ "project.export.format": "Formato del proyecto" "export.format.placeholder": "Seleccionar un formato…" "export.path": "Ruta de salida" +"export.sequence": "Secuencia" "export.run": "Exportar" "export.hint": "La secuencia se exporta a través de la ruta de exportación oaktask; el progreso se muestra en el diálogo." "export.progress.title": "Exportando" @@ -483,6 +484,7 @@ "project.context.reveal_in_finder": "Mostrar en Finder" "project.context.replace_footage": "Reemplazar material" "project.context.rename": "Cambiar nombre" +"project.context.export_sequence": "Exportar secuencia…" "project.context.delete": "Eliminar" "project.context.open_in_new_tab": "Abrir en pestaña nueva" "project.context.open_in_new_window": "Abrir en ventana nueva" diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index feabac77c..f89f70ed3 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -22,7 +22,7 @@ "menu.file.export_project": "Exporter un fichier projet…" "menu.file.save_as": "Enregistrer sous…" "menu.file.revert": "Restaurer" -"menu.file.export_media": "Exporter les médias…" +"menu.file.export_media": "Exporter la séquence…" "menu.file.project_properties": "Propriétés du projet…" "menu.file.close": "Fermer le projet" "menu.file.quit": "Quitter" @@ -389,6 +389,7 @@ "project.export.format": "Format du projet" "export.format.placeholder": "Sélectionner un format…" "export.path": "Chemin de sortie" +"export.sequence": "Séquence" "export.run": "Exporter" "export.hint": "La séquence est exportée via le chemin d'exportation oaktask ; la progression s'affiche dans la boîte de dialogue." "export.progress.title": "Exportation" @@ -483,6 +484,7 @@ "project.context.reveal_in_finder": "Révéler dans le Finder" "project.context.replace_footage": "Remplacer les médias" "project.context.rename": "Renommer" +"project.context.export_sequence": "Exporter la séquence…" "project.context.delete": "Supprimer" "project.context.open_in_new_tab": "Ouvrir dans un nouvel onglet" "project.context.open_in_new_window": "Ouvrir dans une nouvelle fenêtre" diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index 04c47ce78..5567c6fc4 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -22,7 +22,7 @@ "menu.file.export_project": "プロジェクトファイルを書き出し…" "menu.file.save_as": "別名で保存…" "menu.file.revert": "保存済みに戻す" -"menu.file.export_media": "メディアを書き出し…" +"menu.file.export_media": "シーケンスを書き出し…" "menu.file.project_properties": "プロジェクトのプロパティ…" "menu.file.close": "プロジェクトを閉じる" "menu.file.quit": "終了" @@ -389,6 +389,7 @@ "project.export.format": "プロジェクト形式" "export.format.placeholder": "フォーマットを選択…" "export.path": "出力先" +"export.sequence": "シーケンス" "export.run": "書き出し" "export.hint": "シーケンスはoaktaskの書き出しパスを通じて書き出されます。進捗はダイアログに表示されます。" "export.progress.title": "書き出し中" @@ -483,6 +484,7 @@ "project.context.reveal_in_finder": "Finderに表示" "project.context.replace_footage": "素材を置き換え" "project.context.rename": "名前を変更" +"project.context.export_sequence": "シーケンスを書き出し…" "project.context.delete": "削除" "project.context.open_in_new_tab": "新しいタブで開く" "project.context.open_in_new_window": "新しいウィンドウで開く" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index d7c210a2b..a7188b5ef 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -22,7 +22,7 @@ "menu.file.export_project": "Exportar arquivo de projeto…" "menu.file.save_as": "Salvar como…" "menu.file.revert": "Reverter" -"menu.file.export_media": "Exportar mídia…" +"menu.file.export_media": "Exportar sequência…" "menu.file.project_properties": "Propriedades do projeto…" "menu.file.close": "Fechar projeto" "menu.file.quit": "Sair" @@ -389,6 +389,7 @@ "project.export.format": "Formato do projeto" "export.format.placeholder": "Selecionar um formato…" "export.path": "Caminho de saída" +"export.sequence": "Sequência" "export.run": "Exportar" "export.hint": "A sequência é exportada pelo caminho de exportação do oaktask; o progresso é exibido no diálogo." "export.progress.title": "Exportando" @@ -483,6 +484,7 @@ "project.context.reveal_in_finder": "Revelar no Finder" "project.context.replace_footage": "Substituir mídia" "project.context.rename": "Renomear" +"project.context.export_sequence": "Exportar sequência…" "project.context.delete": "Excluir" "project.context.open_in_new_tab": "Abrir em nova guia" "project.context.open_in_new_window": "Abrir em nova janela" diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index b4439fd6b..9d6a58119 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -22,7 +22,7 @@ "menu.file.export_project": "Экспортировать файл проекта…" "menu.file.save_as": "Сохранить как…" "menu.file.revert": "Восстановить" -"menu.file.export_media": "Экспортировать медиафайлы…" +"menu.file.export_media": "Экспортировать секвенцию…" "menu.file.project_properties": "Свойства проекта…" "menu.file.close": "Закрыть проект" "menu.file.quit": "Выход" @@ -389,6 +389,7 @@ "project.export.format": "Формат проекта" "export.format.placeholder": "Выберите формат…" "export.path": "Путь вывода" +"export.sequence": "Секвенция" "export.run": "Экспортировать" "export.hint": "Последовательность экспортируется через путь экспорта oaktask; ход выполнения отображается в диалоговом окне." "export.progress.title": "Экспорт" @@ -483,6 +484,7 @@ "project.context.reveal_in_finder": "Показать в Finder" "project.context.replace_footage": "Заменить медиафайлы" "project.context.rename": "Переименовать" +"project.context.export_sequence": "Экспортировать секвенцию…" "project.context.delete": "Удалить" "project.context.open_in_new_tab": "Открыть в новой вкладке" "project.context.open_in_new_window": "Открыть в новом окне" diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index 9c169b681..bcd82e187 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -22,7 +22,7 @@ "menu.file.export_project": "导出工程文件…" "menu.file.save_as": "另存为…" "menu.file.revert": "还原" -"menu.file.export_media": "导出媒体…" +"menu.file.export_media": "导出序列…" "menu.file.project_properties": "项目属性…" "menu.file.close": "关闭项目" "menu.file.quit": "退出" @@ -387,6 +387,7 @@ "project.export.format": "项目格式" "export.format.placeholder": "选择格式…" "export.path": "输出路径" +"export.sequence": "序列" "export.run": "导出" "export.hint": "序列通过 oaktask 导出路径导出;进度显示在对话框中。" "export.progress.title": "正在导出" @@ -481,6 +482,7 @@ "project.context.reveal_in_finder": "在 Finder 中显示" "project.context.replace_footage": "替换素材" "project.context.rename": "重命名" +"project.context.export_sequence": "导出序列…" "project.context.delete": "删除" "project.context.open_in_new_tab": "在新标签页中打开" "project.context.open_in_new_window": "在新窗口中打开" diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index 0906453a7..78d846831 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -48,7 +48,7 @@ use gpui::dock::{ use gpui::timeline::{ClipData, ClipId, Frame, FrameRange, TimelineEvent, TimelineView, TrackData}; use gpui::{ colors::DefaultColors, div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, - Entity, PathPromptOptions, Render, Window, WindowBounds, WindowOptions, + Entity, PathPromptOptions, Render, SharedString, Window, WindowBounds, WindowOptions, }; use gpui_widgets::audio_meter::{AudioLevelMeter, MeterOrientation}; use gpui_widgets::dialog::progress::{progress_dialog, ProgressContent}; @@ -605,6 +605,19 @@ impl OakApp { ) .detach(); + // The project explorer's 导出序列 context item opens the export + // dialog with that sequence preselected. + cx.subscribe( + &panels.project, + |this, + _panel, + event: &crate::panels::project_explorer::ExportSequenceRequested, + cx| { + this.open_export_dialog(Some(event.0), cx); + }, + ) + .detach(); + // The project explorer's 重命名 / 删除 context items operate on // the project's entry (rename prompts; delete removes the node). cx.subscribe( @@ -1048,7 +1061,7 @@ impl OakApp { A::CloseProject => self .engine .update(cx, |engine, cx| engine.close_project(cx)), - A::Export => self.open_export_dialog(cx), + A::Export => self.open_export_dialog(None, cx), A::Exit => cx.quit(), // --- Edit ------------------------------------------------------ A::Undo => self.engine.update(cx, |engine, cx| engine.undo(cx)), @@ -2846,15 +2859,23 @@ impl OakApp { } /// Opens the export dialog. - fn open_export_dialog(&mut self, cx: &mut Context) { - if self.engine.read(cx).current_sequence().is_none() { - println!("[export] no sequence open"); + /// Opens the 导出序列 dialog: any sequence of the open project is + /// pickable (the current one preselected, or `preselect` when the + /// project explorer's 导出序列 context item opened the dialog). + fn open_export_dialog(&mut self, preselect: Option, cx: &mut Context) { + let sequences = self.engine.read(cx).sequence_entries(); + if sequences.is_empty() { + println!("[export] the project has no sequences"); return; } - let default_path = self.default_export_path(cx); + let selected = preselect + .or_else(|| self.engine.read(cx).current_sequence_id()) + .filter(|id| sequences.iter().any(|(entry, _)| entry == id)); + let default_path = self.default_export_path(cx, &sequences, selected); self.spawn_modal(cx, move |window, app| { let content = app.new(|cx| ExportDialogContent::new(window, cx)); content.update(app, |content, cx| { + content.set_sequences(sequences, selected, cx); content.set_path(default_path.clone(), cx) }); let modal = app.new(|cx| { @@ -2872,13 +2893,22 @@ impl OakApp { }); } - /// A default output path for the export dialog: the project name with - /// the format's extension, next to the project file. - fn default_export_path(&self, cx: &App) -> String { + /// A default output path for the export dialog: the picked sequence's + /// name with the format's extension, next to the project file. + fn default_export_path( + &self, + cx: &App, + sequences: &[(u64, SharedString)], + selected: Option, + ) -> String { + let seq_name = selected + .and_then(|id| sequences.iter().find(|(entry, _)| *entry == id)) + .or_else(|| sequences.first()) + .map(|(_, name)| name.to_string()); let project = self.engine.read(cx).project(); - let name = project - .map(|p| p.name.clone()) + let name = seq_name .filter(|n| !n.is_empty()) + .or_else(|| project.map(|p| p.name.clone()).filter(|n| !n.is_empty())) .unwrap_or_else(|| "untitled".to_string()); let dir = project .and_then(|p| p.path.parent().map(|d| d.to_path_buf())) @@ -2900,6 +2930,9 @@ impl OakApp { if path.trim().is_empty() { return; } + let Some(sequence) = content.read(cx).selected_sequence(cx) else { + return; + }; // Append the format's extension when the user left it off. let has_ext = std::path::Path::new(&path) .extension() @@ -2910,7 +2943,7 @@ impl OakApp { } let result = self.engine.update(cx, |engine, _cx| { - engine.start_export_with(&settings, PathBuf::from(&path)) + engine.start_export_of(sequence, &settings, PathBuf::from(&path)) }); match result { Ok(session) => { @@ -5481,6 +5514,58 @@ mod tests { // Project manager (M13 D4) // ------------------------------------------------------------------- + /// 文件 → 导出序列 opens the dialog with the project's sequences (the + /// current one preselected); OK exports the picked sequence and swaps + /// in the progress dialog. + #[gpui::test] + async fn export_sequence_dialog_picks_and_starts(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let _guard = crate::i18n::lang_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); + let (_window, root) = mock_shell(cx); + + cx.update(|app| { + root.update(app, |app, cx| app.on_menu(ActionId::Export.menu_id(), cx)) + }); + cx.run_until_parked(); + + let content = cx.read(|app| match &root.read(app).modal { + ModalState::Export { content, .. } => content.clone(), + _ => panic!("the export dialog should be open"), + }); + // The mock bin's one sequence (entry 4) is preselected; the + // default output path is a real file name. + assert_eq!( + cx.read(|app| content.read(app).selected_sequence(app)), + Some(4), + "the current sequence is preselected" + ); + let path = cx.read(|app| content.read(app).path(app).to_string()); + assert!(path.ends_with(".mp4"), "default export path: {path}"); + + // OK starts the export of the picked sequence: the progress dialog + // replaces the settings dialog. + cx.update(|app| { + root.update(app, |app, cx| { + app.on_modal( + &ModalEvent::ButtonClicked { + control: modal_ids::EXPORT, + button: 0, + }, + cx, + ) + }) + }); + cx.run_until_parked(); + assert!( + cx.read(|app| matches!(root.read(app).modal, ModalState::Progress { .. })), + "OK starts the export and opens the progress dialog" + ); + } + /// Dispatch a synthetic keystroke and settle with a double park. /// gpui's window key handling may defer the binding dispatch (its /// pending/repeat machinery); parking twice drains the executor diff --git a/crates/oak-app/src/dialogs.rs b/crates/oak-app/src/dialogs.rs index fbbb5c9da..5d95c07cb 100644 --- a/crates/oak-app/src/dialogs.rs +++ b/crates/oak-app/src/dialogs.rs @@ -994,6 +994,7 @@ impl Render for PathField { /// The export dialog content: the container-format dropdown and the output /// path field. pub struct ExportDialogContent { + sequence: Entity, format: Entity, video_codec: Entity, audio_codec: Entity, @@ -1008,6 +1009,9 @@ pub struct ExportDialogContent { formats: Vec<(i32, String, String)>, /// The container format for the current codec lists. active_format: i32, + /// (entry id, display name) of the pickable sequences, in dropdown + /// order; populated by the host from the open project. + sequences: Vec<(u64, SharedString)>, } impl ExportDialogContent { @@ -1152,7 +1156,16 @@ impl ExportDialogContent { } }); + // The sequence picker is filled by the host (it owns the project); + // an empty list disables the dialog's OK via the picker staying + // unselected. + let sequence = cx.new(|cx| { + ComboBox::new(13, Vec::new(), window, cx) + .with_placeholder(i18n::tr("export.sequence")) + }); + Self { + sequence, format, video_codec, audio_codec, @@ -1165,9 +1178,44 @@ impl ExportDialogContent { path, formats, active_format, + sequences: Vec::new(), } } + /// Populates the sequence picker from the open project and preselects + /// `selected` (falling back to the first entry — the dialog always has + /// a target to export). + pub fn set_sequences( + &mut self, + sequences: Vec<(u64, SharedString)>, + selected: Option, + cx: &mut Context, + ) { + let preselect = selected + .and_then(|id| sequences.iter().position(|(entry, _)| *entry == id)) + .or(if sequences.is_empty() { None } else { Some(0) }); + let options = sequences + .iter() + .enumerate() + .map(|(i, (_, name))| ComboBoxOption::new(i, name.clone())) + .collect::>(); + self.sequence.update(cx, |combo, cx| combo.set_options(options, cx)); + self.sequence + .update(cx, |combo, cx| combo.set_selected(preselect, cx)); + self.sequences = sequences; + cx.notify(); + } + + /// The picked sequence's project-entry id (`None` only when the + /// project has no sequences — the host refuses to open the dialog + /// then). + pub fn selected_sequence(&self, cx: &App) -> Option { + self.sequence + .read(cx) + .selected() + .and_then(|index| self.sequences.get(index).map(|(id, _)| *id)) + } + /// 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); @@ -1312,6 +1360,11 @@ impl Render for ExportDialogContent { .flex_col() .gap_3() .w_full() + .child(form_row( + &colors, + i18n::tr("export.sequence").into(), + self.sequence.clone(), + )) .child(form_row( &colors, i18n::tr("export.format").into(), @@ -4139,7 +4192,6 @@ impl Render for RenameContent { /// [`Self::format`] + [`Self::path`] on OK. pub struct ExportProjectDialogContent { format: Entity, - path: Entity, /// (format id, display name) in dropdown order. formats: Vec<(i32, String)>, } @@ -4151,7 +4203,9 @@ pub const PROJECT_FORMAT_OVE: i32 = 1; pub const PROJECT_FORMAT_FCPXML: i32 = 2; impl ExportProjectDialogContent { - /// Builds the dialog (OTIO default, empty path). + /// Builds the dialog (OTIO default). The output path is NOT entered + /// here: OK hands off to the platform save dialog (the suggested name + /// carries the chosen format's extension). pub fn new(window: &mut Window, cx: &mut Context) -> Self { let formats: Vec<(i32, String)> = vec![ (PROJECT_FORMAT_OTIO, "OpenTimelineIO (.otio)".to_string()), @@ -4168,11 +4222,7 @@ impl ExportProjectDialogContent { .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 } + Self { format, formats } } /// The selected project format id. @@ -4194,18 +4244,6 @@ impl ExportProjectDialogContent { _ => "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 { @@ -4221,11 +4259,6 @@ impl Render for ExportProjectDialogContent { i18n::tr("project.export.format").into(), self.format.clone(), )) - .child(form_row( - &colors, - i18n::tr("export.path").into(), - self.path.clone(), - )) } } @@ -4294,6 +4327,54 @@ mod tests { gpui::Keystroke::parse(key).unwrap() } + /// The export dialog's sequence picker: the host populates it from + /// the project, a known preselection wins, an unknown one falls back + /// to the first entry, and an empty project leaves nothing to export. + #[gpui::test] + async fn export_dialog_sequence_picker(cx: &mut gpui::TestAppContext) { + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(gpui::size(gpui::px(440.0), gpui::px(400.0)), |window, cx| { + ExportDialogContent::new(window, cx) + }); + cx.run_until_parked(); + let content = window.root(cx).expect("dialog content root"); + + let sequences = vec![ + (7u64, SharedString::from("Opening")), + (9u64, SharedString::from("Finale")), + ]; + cx.update(|cx| { + content.update(cx, |content, cx| { + content.set_sequences(sequences.clone(), Some(9), cx) + }); + }); + assert_eq!( + cx.read(|cx| content.read(cx).selected_sequence(cx)), + Some(9), + "the known preselection wins" + ); + + cx.update(|cx| { + content.update(cx, |content, cx| { + content.set_sequences(sequences.clone(), Some(42), cx) + }); + }); + assert_eq!( + cx.read(|cx| content.read(cx).selected_sequence(cx)), + Some(7), + "an unknown preselection falls back to the first entry" + ); + + cx.update(|cx| { + content.update(cx, |content, cx| content.set_sequences(Vec::new(), None, cx)); + }); + assert_eq!( + cx.read(|cx| content.read(cx).selected_sequence(cx)), + None, + "an empty project leaves nothing to export" + ); + } + /// The 4K presets are in the dropdown and resolve to their formats /// (4K UHD 3840×2160@25, 4K DCI 4096×2160@24). #[test] diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index ba694edf0..f69e575ba 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -819,6 +819,33 @@ pub trait AppEngine: path: PathBuf, ) -> Result; + /// The project's sequence entries `(id, display name)` in + /// project-explorer order — the export dialog's sequence picker. + /// Default: none (engines without a project browser cannot export). + fn sequence_entries(&self) -> Vec<(u64, SharedString)> { + Vec::new() + } + + /// The project-entry id of the sequence currently open in the + /// timeline (the export dialog's preselection), if any. + fn current_sequence_id(&self) -> Option { + None + } + + /// Starts an export of the sequence behind project entry `id` (the + /// export dialog's sequence picker / the explorer's 导出序列 item). + /// The work area only applies when `id` is the open sequence. + /// Default: unsupported. + fn start_export_of( + &mut self, + id: u64, + settings: &ExportSettings, + path: PathBuf, + ) -> Result { + let _ = (id, settings, path); + Err("export not supported".into()) + } + // ------------------------------------------------------------------- // Proxy media (the C++ Tools > proxy pipeline): global switch, per // footage state and the generate / delete / reveal entries. Defaults diff --git a/crates/oak-app/src/oakui/mock.rs b/crates/oak-app/src/oakui/mock.rs index 13070af89..20cb9de5e 100644 --- a/crates/oak-app/src/oakui/mock.rs +++ b/crates/oak-app/src/oakui/mock.rs @@ -2036,6 +2036,24 @@ impl AppEngine for MockEngine { }) } + fn sequence_entries(&self) -> Vec<(u64, SharedString)> { + // The mock bin's one sequence entry (id 4 = "aaa.ove"). + vec![(4, self.sequence.name.clone().into())] + } + + fn current_sequence_id(&self) -> Option { + Some(4) + } + + fn start_export_of( + &mut self, + _id: u64, + settings: &crate::oakui::engine::ExportSettings, + path: PathBuf, + ) -> Result { + self.start_export_with(settings, path) + } + fn use_proxy_media(&self) -> bool { self.use_proxy } diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 77f9a40fc..907adda81 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -6223,6 +6223,85 @@ impl AppEngine for RealEngine { Ok(super::renderops::spawn_export(&project, seq, params)) } + fn sequence_entries(&self) -> Vec<(u64, SharedString)> { + /// DFS the bin in explorer order, keeping sequence leaves. + fn collect( + project: &ProjectRef, + entries: Vec, + out: &mut Vec<(u64, SharedString)>, + ) { + for entry in entries { + if entry.is_dir { + collect( + project, + crate::oakui::projectbrowser::children(project, entry.id), + out, + ); + continue; + } + let is_seq = graphops::id_of(entry.id).is_some_and(|node| { + let guard = graphops::lock(project); + graphops::sequence_behavior(&guard.graph, node).is_some() + }); + if is_seq { + out.push((entry.id, entry.name)); + } + } + } + let Some(project) = self.project_ref() else { + return Vec::new(); + }; + let mut out = Vec::new(); + collect(project, crate::oakui::projectbrowser::roots(project), &mut out); + out + } + + fn current_sequence_id(&self) -> Option { + self.sequence.map(|seq| seq.identity()) + } + + fn start_export_of( + &mut self, + id: u64, + settings: &super::engine::ExportSettings, + path: PathBuf, + ) -> Result { + let Some(project) = self.project.clone() else { + return Err("no project open".into()); + }; + let Some(seq) = graphops::id_of(id) else { + return Err("the entry is not a sequence".into()); + }; + { + let guard = graphops::lock(&project); + if graphops::sequence_behavior(&guard.graph, seq).is_none() { + return Err("the entry is not a sequence".into()); + } + } + // The work area belongs to the OPEN sequence; a bin export of a + // different sequence renders it whole. + let workarea = match self.sequence { + Some(current) if current == seq => self.workarea().map(|(s, e)| (s.0, e.0)), + _ => None, + }; + let length_frames = { + let guard = graphops::lock(&project); + let length = graphops::sequence_length(&guard.graph, seq); + graphops::sequence_time_base(&guard.graph, seq) + .map(|tb| graphops::rational_to_ts(length, tb)) + .unwrap_or(0) + }; + let params = super::renderops::encoding_params_with_settings( + &project, + seq, + settings, + &path, + workarea, + length_frames, + )?; + Ok(super::renderops::spawn_export(&project, seq, params)) + } + fn multicam_state(&self) -> Option { self.multicam_state_internal() } diff --git a/crates/oak-app/src/panels/project_explorer.rs b/crates/oak-app/src/panels/project_explorer.rs index 6a775db20..20812937a 100644 --- a/crates/oak-app/src/panels/project_explorer.rs +++ b/crates/oak-app/src/panels/project_explorer.rs @@ -114,6 +114,8 @@ impl ProjectExplorerPanel { if self.engine.read(cx).entry_path(id).is_some() { let proxy = self.engine.read(cx).proxy_row(id); footage_menu(true, proxy.as_ref()) + } else if self.engine.read(cx).entry_is_sequence(id) { + sequence_menu() } else { entry_menu() } @@ -184,6 +186,12 @@ impl ProjectExplorerPanel { println!("[project explorer] properties for non-sequence entry {id} (not implemented yet)"); } } + LOCAL_EXPORT_SEQUENCE => { + let Some(id) = self.context_entry else { + return; + }; + cx.emit(ExportSequenceRequested(id)); + } LOCAL_PROXY_GENERATE | LOCAL_PROXY_USE | LOCAL_PROXY_REVEAL | LOCAL_PROXY_DELETE => { let Some(id) = self.context_entry else { return; @@ -320,6 +328,13 @@ pub struct DeleteRequested(pub u64); impl EventEmitter for ProjectExplorerPanel {} +/// The project explorer asked the shell to open the 导出序列 dialog for +/// the given sequence entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExportSequenceRequested(pub u64); + +impl EventEmitter for ProjectExplorerPanel {} + impl DockPanel for ProjectExplorerPanel { fn panel_id(&self) -> gpui::dock::PanelId { PROJECT @@ -353,6 +368,7 @@ const LOCAL_PROXY_DELETE: usize = 2208; const LOCAL_RENAME: usize = 2209; const LOCAL_DELETE: usize = 2210; const LOCAL_PROPERTIES: usize = 2211; +const LOCAL_EXPORT_SEQUENCE: usize = 2212; /// The proxy submenu (shared shape with the timeline's): enable state /// follows the footage's proxy fields (the C++ project explorer gates @@ -448,6 +464,20 @@ pub(crate) fn entry_menu() -> Menu { ]) } +/// A sequence entry's context menu: the generic entry items plus 导出序列 +/// (the 文件 → 导出序列 dialog preselected to this sequence). +pub(crate) fn sequence_menu() -> Menu { + let mut menu = entry_menu(); + menu.items.push( + MenuItem::new( + LOCAL_EXPORT_SEQUENCE, + crate::i18n::tr("project.context.export_sequence"), + ) + .separated(), + ); + menu +} + /// Reveals `path` in the platform file manager (Finder on macOS, Explorer /// on Windows, `xdg-open` on the parent directory elsewhere). fn reveal_in_finder(path: &std::path::Path) { @@ -531,4 +561,28 @@ mod tests { ] ); } + + /// The sequence menu appends 导出序列 to the generic entry items. + #[test] + fn sequence_menu_appends_export() { + let menu = sequence_menu(); + let ids: Vec = menu.items.iter().map(|item| item.id).collect(); + assert_eq!( + ids, + vec![ + LOCAL_OPEN_IN_NEW_TAB, + LOCAL_OPEN_IN_NEW_WINDOW, + LOCAL_RENAME, + LOCAL_DELETE, + LOCAL_PROPERTIES, + LOCAL_EXPORT_SEQUENCE, + ] + ); + let export = menu.items.last().expect("export item"); + assert_eq!( + export.label, + crate::i18n::tr("project.context.export_sequence") + ); + assert!(export.enabled); + } } diff --git a/crates/oak-task/src/render.rs b/crates/oak-task/src/render.rs index 5c2f9bed8..acaf5682e 100644 --- a/crates/oak-task/src/render.rs +++ b/crates/oak-task/src/render.rs @@ -628,6 +628,7 @@ impl RenderTask { time: Rational, dispatch: *mut RenderDispatch, in_flight: &mut Vec, + ticket_keys: &mut HashMap, ) -> Result<()> { unsafe { (&*dispatch).running.fetch_add(1, Ordering::SeqCst); @@ -635,6 +636,10 @@ impl RenderTask { match self.submit_video_ticket(arena, time, dispatch) { Ok(id) => { in_flight.push(id); + ticket_keys.insert( + id, + (TICKET_VIDEO, time.numerator(), time.denominator()), + ); Ok(()) } Err(e) => { @@ -654,6 +659,7 @@ impl RenderTask { range: TimeRange, dispatch: *mut RenderDispatch, in_flight: &mut Vec, + ticket_keys: &mut HashMap, ) -> Result<()> { unsafe { (&*dispatch).running.fetch_add(1, Ordering::SeqCst); @@ -661,6 +667,14 @@ impl RenderTask { match self.submit_audio_ticket(arena, range, dispatch) { Ok(id) => { in_flight.push(id); + ticket_keys.insert( + id, + ( + TICKET_AUDIO, + range.in_().numerator(), + range.in_().denominator(), + ), + ); Ok(()) } Err(e) => { @@ -673,31 +687,20 @@ impl RenderTask { } /// Map a finished ticket back to its delivery slot: the audio slot for - /// audio tickets, the matching frame slot for video tickets. + /// audio tickets, the matching frame slot for video tickets. The key + /// comes from `ticket_keys` (the submitter-side record) — NOT from the + /// arena: the arena reaps finished fire-and-forget slots on the next + /// `allocate()`, so a ticket that completed before the next submit is + /// already gone from the arena's own map (the "Render ticket reported + /// an unexpected timestamp" failure that killed every export). fn classify_ticket( &self, - arena: &TicketArena, + ticket_keys: &HashMap, id: TicketId, slot_by_key: &HashMap<(i32, i64, i64), usize>, ) -> Option { - let kind = arena.kind(id)?; - if kind == TICKET_AUDIO { - let range = arena.range(id)?; - slot_by_key - .get(&( - TICKET_AUDIO, - range.in_().numerator(), - range.in_().denominator(), - )) - .copied() - } else if kind == TICKET_VIDEO { - let time = arena.time(id)?; - slot_by_key - .get(&(TICKET_VIDEO, time.numerator(), time.denominator())) - .copied() - } else { - None - } + let key = ticket_keys.get(&id)?; + slot_by_key.get(key).copied() } /// Drive the whole render: keep up to `max_inflight` frame tickets in @@ -815,14 +818,21 @@ impl RenderTask { let mut next_slot = 0usize; // Reorder buffer: finished tickets not yet deliverable. let mut pending: HashMap = HashMap::new(); + // Submitter-side ticket id -> delivery-slot key (the arena reaps + // finished fire-and-forget slots; this record is authoritative). + let mut ticket_keys: HashMap = HashMap::new(); let mut progress_counter = 0.0; let mut result: Result<()> = Ok(()); // Queue audio first (mirrors the C++ order). if self.audio_enabled && result.is_ok() { - if let Err(e) = - self.start_audio_ticket(&arena, self.export_range, dispatch, &mut in_flight) - { + if let Err(e) = self.start_audio_ticket( + &arena, + self.export_range, + dispatch, + &mut in_flight, + &mut ticket_keys, + ) { result = Err(e); } } @@ -835,6 +845,7 @@ impl RenderTask { frame_times[next_frame_index], dispatch, &mut in_flight, + &mut ticket_keys, ) { result = Err(e); break; @@ -847,7 +858,7 @@ impl RenderTask { // Drain the completion queue into the reorder buffer. while let Some((id, ticket_result)) = dispatch_ref.pop_finished() { consumed += 1; - match self.classify_ticket(&arena, id, &slot_by_key) { + match self.classify_ticket(&ticket_keys, id, &slot_by_key) { Some(slot_index) => { pending.insert(slot_index, (id, ticket_result)); } @@ -965,6 +976,7 @@ impl RenderTask { frame_times[next_frame_index], dispatch, &mut in_flight, + &mut ticket_keys, ) { result = Err(e); break; diff --git a/crates/oak-task/tests/export_test.rs b/crates/oak-task/tests/export_test.rs new file mode 100644 index 000000000..e9971e022 --- /dev/null +++ b/crates/oak-task/tests/export_test.rs @@ -0,0 +1,220 @@ +// Oak Video Editor - Non-Linear Video Editor +// Copyright (C) 2026 Oak Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//! End-to-end export: a sequence carrying one clip renders through +//! [`oak_task::export::ExportTask`] into a real H.264/AAC MP4 — the file +//! exists, probes to the sequence geometry, and decodes back to the +//! clip's content. This is the "export is a fake" report's regression +//! guard: the whole chain (graph montage -> render tickets -> encoder) +//! must produce a playable file. + +use std::sync::{Arc, Mutex}; + +use oak_core::{PixelFormat, Rational, TimeRange}; +use oak_node::block::ClipBlockBehavior; +use oak_node::footage::FootageBehavior; +use oak_node::id::NodeId; +use oak_node::node::NodeCore; +use oak_node::project::Project; +use oak_node::sequence::SequenceBehavior; +use oak_node::track::{TrackBehavior, TrackListBehavior}; +use oak_render::manager::{RenderBackendChoice, RenderManager}; + +/// Unique temp path per test (the process id disambiguates parallel test +/// binaries; the tag separates files inside one binary). +fn clip_path(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("oaktask_export_{tag}_{}.mp4", std::process::id())) +} + +/// One sequence + one video track list + one track + one clip of `media` +/// covering `[0, 1s)`. +fn build_project(media: &str) -> (Arc>, NodeId) { + // The export converts through the pipeline working space on the way + // out; pin the legacy sRGB pass-through so the decoded-back pixels + // keep their display-referred values for the assertions below. + oak_core::color::set_pipeline_color_settings( + oak_core::colormath::WorkingColorSpace::SrgbLegacy, + oak_core::colormath::OutputColorSpec::default(), + ); + let project = Project::new(); + let seq; + { + let mut p = project.lock().unwrap(); + let (score, sbehavior) = SequenceBehavior::create(); + seq = p.graph.add_node(score, sbehavior); + + let (tcore, tbehavior) = TrackListBehavior::create(); + let tl = p.graph.add_node(tcore, tbehavior); + + let (tcore, tbehavior) = TrackBehavior::create(); + let track = p.graph.add_node(tcore, tbehavior); + + let mut footage = FootageBehavior::new(media); + footage.probe().expect("probe the generated clip"); + let footage = p.graph.add_node(NodeCore::new(), Box::new(footage)); + + let (ccore, cbehavior) = oak_node::block::clip_create(); + let clip = p.graph.add_node(ccore, cbehavior); + p.graph + .connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1) + .expect("connect footage to clip"); + p.graph + .get_mut(clip) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .expect("clip block") + .core + .range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)); + + p.graph + .get_mut(track) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .expect("video track") + .append_block(clip); + p.graph + .get_mut(tl) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .expect("video track list") + .tracks + .push(track); + p.graph + .get_mut(seq) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .expect("sequence") + .track_lists + .push(tl); + } + (project, seq) +} + +/// The full export chain: a 64x64 one-second sequence exports to MP4; +/// the result probes to 64x64 and decodes back to the clip's solid green. +#[test] +fn export_writes_a_real_playable_mp4() { + let media = clip_path("src"); + oak_codec::testmedia::write_test_clip_solid(&media, 64, 64, 10, 10, [0.0, 1.0, 0.0, 1.0]) + .expect("green clip generation"); + let (project, seq) = build_project(&media.to_string_lossy()); + let out = clip_path("out"); + let _ = std::fs::remove_file(&out); + + // The export's render loop runs on the process-wide manager arena; + // the inline backend keeps the test in-process. + if RenderManager::global().is_none() { + RenderManager::init_with_backend(RenderBackendChoice::Threads) + .expect("render manager init"); + } + + let encoding = oak_task::export::EncodingParams { + filename: out.to_string_lossy().into_owned(), + format: oak_codec::exportformat::Format::MPEG4Video as i32, + video_enabled: true, + video_codec: oak_codec::exportcodec::Codec::H264 as i32, + video_width: 64, + video_height: 64, + video_time_base_num: 1, + video_time_base_den: 30, + video_pixel_format: 0, + audio_enabled: true, + audio_codec: oak_codec::exportcodec::Codec::AAC as i32, + audio_sample_rate: 48000, + audio_channel_layout: 0x3, + subtitles_enabled: false, + export_length_num: 1, + export_length_den: 1, + has_custom_range: false, + custom_range_in_num: 0, + custom_range_in_den: 1, + custom_range_out_num: 0, + custom_range_out_den: 1, + video_bit_rate: 0, + audio_bit_rate: 0, + color_override_enabled: false, + color_primaries: 0, + color_trc: 0, + color_space: 0, + }; + let inner = oak_task::export::ExportTask::new((project.clone(), seq), encoding); + let mut driver = oak_task::task::Task::new("Exporting...", None); + driver.set_behavior(Box::new(inner)); + if let Err(e) = driver.start() { + panic!( + "export failed: {e:?} / {}", + driver.error().unwrap_or("unknown error") + ); + } + + // The file exists and is a real container (video + audio for a second + // is far past a header-only file). + let bytes = std::fs::metadata(&out) + .expect("the export wrote the file") + .len(); + assert!( + bytes > 1024, + "a playable mp4 with video+audio is more than 1K, got {bytes}" + ); + + // It probes to the sequence geometry. + let mut probe = FootageBehavior::new(&out.to_string_lossy()); + probe.probe().expect("the export probes as media"); + let params = probe + .video_params(0) + .expect("the export has a video stream"); + assert_eq!( + (params.width, params.height), + (64, 64), + "the export's video stream is the sequence geometry" + ); + + // And a decoded frame is the clip's solid green (H.264 YCbCr keeps + // the hue; loose thresholds). + let tex = oak_render::eval::render_footage_frame( + &out.to_string_lossy(), + 0, + Rational::new(0, 1), + (64, 64), + PixelFormat::F32, + ) + .expect("decode the exported frame"); + let oak_core::texture::Texture::Cpu(frame) = &tex else { + panic!("decode produced a GPU texture"); + }; + let off = 32 * frame.linesize_bytes() as usize + 32 * 16; + let r = f32::from_le_bytes(frame.data[off..off + 4].try_into().unwrap()); + let g = f32::from_le_bytes(frame.data[off + 4..off + 8].try_into().unwrap()); + assert!( + g > 0.4 && r < 0.3, + "the exported frame is the clip's green (r={r} g={g})" + ); + + let _ = std::fs::remove_file(&media); + let _ = std::fs::remove_file(&out); +}