export: fix render-ticket classification; export any sequence

Two export killers fixed and the feature surfaced properly:

- TicketArena::allocate() reaps finished fire-and-forget slots on every
  new submit, but RenderTask classified finished tickets by asking the
  arena for their kind/time afterwards - any ticket that completed
  before the next submit (all of them on the inline backend) failed
  with 'Render ticket reported an unexpected timestamp' and the export
  died after writing a header-only file. RenderTask now records each
  ticket's delivery key at submit time instead.
- File > Export becomes 导出序列 (all 8 locales): the dialog offers a
  sequence picker (any sequence in the project, the current one
  preselected) instead of silently exporting the current one, and the
  project bin's sequence context menu gains 导出序列 opening the same
  dialog. Engines expose sequence_entries / current_sequence_id /
  start_export_of.
- The export-project dialog drops its dead hand-typed path field (OK
  already asked the platform save dialog).
- New end-to-end test: a one-second sequence exports to a real
  H.264/AAC MP4 that probes and decodes back to the clip's content.
This commit is contained in:
2026-09-10 16:54:15 +08:00
parent fc90604245
commit 012659bf57
16 changed files with 660 additions and 68 deletions
+3 -1
View File
@@ -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"
+3 -1
View File
@@ -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"
+3 -1
View File
@@ -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"
+3 -1
View File
@@ -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"
+3 -1
View File
@@ -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": "新しいウィンドウで開く"
+3 -1
View File
@@ -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"
+3 -1
View File
@@ -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": "Открыть в новом окне"
+3 -1
View File
@@ -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": "在新窗口中打开"
+97 -12
View File
@@ -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<E: AppEngine> OakApp<E> {
)
.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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
}
/// Opens the export dialog.
fn open_export_dialog(&mut self, cx: &mut Context<Self>) {
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<u64>, cx: &mut Context<Self>) {
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<E: AppEngine> OakApp<E> {
});
}
/// 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<u64>,
) -> 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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
}
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
+105 -24
View File
@@ -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<ComboBox>,
format: Entity<ComboBox>,
video_codec: Entity<ComboBox>,
audio_codec: Entity<ComboBox>,
@@ -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<u64>,
cx: &mut Context<Self>,
) {
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::<Vec<_>>();
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<u64> {
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<Self>) {
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<ComboBox>,
path: Entity<PathField>,
/// (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>) -> 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<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 {
@@ -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]
+27
View File
@@ -819,6 +819,33 @@ pub trait AppEngine:
path: PathBuf,
) -> Result<ExportSession, String>;
/// 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<u64> {
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<ExportSession, String> {
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
+18
View File
@@ -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<u64> {
Some(4)
}
fn start_export_of(
&mut self,
_id: u64,
settings: &crate::oakui::engine::ExportSettings,
path: PathBuf,
) -> Result<ExportSession, String> {
self.start_export_with(settings, path)
}
fn use_proxy_media(&self) -> bool {
self.use_proxy
}
+79
View File
@@ -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<ProjectEntry>,
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<u64> {
self.sequence.map(|seq| seq.identity())
}
fn start_export_of(
&mut self,
id: u64,
settings: &super::engine::ExportSettings,
path: PathBuf,
) -> Result<ExportSession, String> {
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<MulticamState> {
self.multicam_state_internal()
}
@@ -114,6 +114,8 @@ impl<E: AppEngine> ProjectExplorerPanel<E> {
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<E: AppEngine> ProjectExplorerPanel<E> {
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<E: AppEngine> EventEmitter<DeleteRequested> for ProjectExplorerPanel<E> {}
/// 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<E: AppEngine> EventEmitter<ExportSequenceRequested> for ProjectExplorerPanel<E> {}
impl<E: AppEngine> DockPanel for ProjectExplorerPanel<E> {
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<usize> = 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);
}
}
+36 -24
View File
@@ -628,6 +628,7 @@ impl RenderTask {
time: Rational,
dispatch: *mut RenderDispatch,
in_flight: &mut Vec<TicketId>,
ticket_keys: &mut HashMap<TicketId, (i32, i64, i64)>,
) -> 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<TicketId>,
ticket_keys: &mut HashMap<TicketId, (i32, i64, i64)>,
) -> 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<TicketId, (i32, i64, i64)>,
id: TicketId,
slot_by_key: &HashMap<(i32, i64, i64), usize>,
) -> Option<usize> {
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<usize, (TicketId, TicketResult)> = 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<TicketId, (i32, i64, i64)> = 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;
+220
View File
@@ -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 <http://www.gnu.org/licenses/>.
//! 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<Mutex<Project>>, 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::<ClipBlockBehavior>()
.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::<TrackBehavior>()
.expect("video track")
.append_block(clip);
p.graph
.get_mut(tl)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.expect("video track list")
.tracks
.push(track);
p.graph
.get_mut(seq)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<SequenceBehavior>()
.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);
}