Compare commits

...
6 Commits
Author SHA1 Message Date
Mike-Solar 2c64317a58 export media: editable resolution / frame rate / bit rate spin boxes
CI / Build & test (Linux) (push) Canceled after 0s
CI / Build & test (Windows) (push) Canceled after 0s
The three values are SpinBoxes (typed input + stepping): width & height
(0 = use the sequence size), frame rate in fps (0 = sequence rate),
bit rate in bit/s (0 = the codec default). The engine already accepted
arbitrary values, so the dialog now exposes them instead of the closed
preset lists. i18n across the 8 packs.
2026-09-02 09:35:54 +08:00
Mike-Solar 92813707bc export media: dialog UI — container→codec gating, color space/bit depth, range
- ExportDialogContent: container format (existing), then the video and
  audio codec combos rebuilt per container from the compatibility tables
  (an unsupported codec is never offered — switching container
  re-selects the first compatible entry), a color-space combo (SDR
  Rec.709 8-bit / HDR BT.2020 10-bit), an export-range combo (whole
  timeline / in-out) and the output path
- ComboBox::set_options added for the container-driven list rebuild
- settings() gathers the ExportSettings the engine consumes
- begin_export routes through start_export_with
- i18n keys across the 8 packs (zh-CN uses distinct values so the
  en==zh equality test passes)
2026-09-02 09:31:49 +08:00
Mike-Solar 499246b48e export media: settings protocol (codec/color/bit-depth/bitrate/range) end to end
- ExportSettings: container format, video/audio codecs, size/fps/
  bitrate, bit depth (8/10 → U8/U10), SDR 709 + HDR 2020 code points,
  export range
- AppEngine::start_export_with(settings, path); real builds the
  task params via encoding_params_with_settings (codec tables gate
  each container — a non-supporting combo errors, never silently
  defaults; the one menu only offers compatible codecs)
- oak-task EncodingParams: video_bit_rate / audio_bit_rate +
  color_override_enabled + primaries/trc/space (override wins over
  the project's delivery colorspace)
- mock start_export_with (no-op fake progress)

The dialog UI rebuild (fields + container→codec→color enability
linking) follows in the next unit.
2026-09-02 09:27:43 +08:00
Mike-Solar 56af662b83 project file: 另存为 — duplicate the project in the library under a new name and switch to the copy
- save_project_as(name): library_duplicate the open project's row,
  rename the copy, open it as the current project
- 文件 → 另存为 opens a name dialog (prefilled '<name> Copy'); OK
  duplicates + switches (the save-as semantics: a new, differently-
  named project in the library, editing continues on the copy)
2026-09-02 09:22:29 +08:00
Mike-Solar 660a32a1b3 project file: new-project dialog (name + sequence format), open prompt lists formats
- 文件 → 新建项目 opens an in-app dialog: project name + the first
  sequence's format (preset / custom / interlaced, reusing
  SequenceFormatFields); OK creates the library project then seeds the
  first sequence from the chosen format
- 打开项目 prompt advertises the supported project formats
  (.ove / .otio / .fcpxml)
- manager.new.project_name across the 8 i18n packs
- action-search first-item test follows the new flow (New Project now
  opens its dialog)
2026-09-02 09:15:30 +08:00
Mike-Solar c900804941 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.
2026-09-02 09:10:39 +08:00
16 changed files with 1064 additions and 40 deletions
+14 -1
View File
@@ -183,6 +183,7 @@
"manager.title": "Projektmanager"
"manager.new": "Neues Projekt"
"manager.new.default_name": "Unbenanntes Projekt"
"manager.new.project_name": "Projektname"
"manager.open": "Öffnen"
"manager.rename": "Umbenennen…"
"manager.rename.title": "Projekt umbenennen"
@@ -303,7 +304,7 @@
"about.description": "Oak Video Editor ist ein freier, quelloffener nichtlinearer Videoeditor. Diese Software ist unter der GNU GPL Version 3 lizenziert."
"about.thanks": "Besonderer Dank gilt Enzo GD, unserem Community Promoter, für seine großzügige Unterstützung bei der Verbreitung dieses Projekts in dessen Anfangszeit."
"about.fork_notice": "Dieses Projekt ist ein Fork des Olive Video Editors."
"file.open.title": "Projekt öffnen"
"file.open.title": "Projekt öffnen (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Material importieren"
"preferences.title": "Einstellungen"
"preferences.section.general": "Allgemein"
@@ -374,6 +375,18 @@
"actionsearch.no_actions": "Keine Aktionen verfügbar"
"export.title": "Sequenz exportieren"
"export.format": "Format"
"export.video_codec": "Video-Codec"
"export.audio_codec": "Audio-Codec"
"export.color": "Farbraum"
"export.color.sdr": "SDR — Rec.709 8-bit"
"export.resolution": "Auflösung (0 = Sequenz)"
"export.framerate": "Bildrate (0 = Sequenz)"
"export.bitrate": "Bitrate (0 = Codec-Standard)"
"export.color.hdr": "HDR — BT.2020 10-bit"
"export.range": "Exportbereich"
"export.range.all": "Ganze Timeline"
"export.range.inout": "Ein-/Ausstiegspunkte"
"project.export.format": "Projektformat"
"export.format.placeholder": "Format auswählen…"
"export.path": "Ausgabepfad"
"export.run": "Exportieren"
+14 -1
View File
@@ -181,6 +181,7 @@
"manager.title": "Project Manager"
"manager.new": "New Project"
"manager.new.default_name": "Untitled Project"
"manager.new.project_name": "Project Name"
"manager.open": "Open"
"manager.rename": "Rename…"
"manager.rename.title": "Rename Project"
@@ -301,7 +302,7 @@
"about.description": "Oak Video Editor is a free open source non-linear video editor. This software is licensed under the GNU GPL Version 3."
"about.thanks": "Special thanks to Enzo GD, our Community Promoter, for his generous support in spreading the word about this project in its early days."
"about.fork_notice": "This project is a fork of Olive Video Editor."
"file.open.title": "Open Project"
"file.open.title": "Open Project (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Import Footage"
"preferences.title": "Preferences"
"preferences.section.general": "General"
@@ -372,6 +373,18 @@
"actionsearch.no_actions": "No actions available"
"export.title": "Export Sequence"
"export.format": "Format"
"export.video_codec": "Video Codec"
"export.audio_codec": "Audio Codec"
"export.color": "Color Space"
"export.color.sdr": "SDR — Rec.709 8-bit"
"export.resolution": "Resolution (0 = sequence)"
"export.framerate": "Frame Rate (0 = sequence)"
"export.bitrate": "Bit Rate (0 = codec default)"
"export.color.hdr": "HDR — BT.2020 10-bit"
"export.range": "Export Range"
"export.range.all": "Whole Timeline"
"export.range.inout": "In / Out Points"
"project.export.format": "Project Format"
"export.format.placeholder": "Select a format…"
"export.path": "Output path"
"export.run": "Export"
+14 -1
View File
@@ -183,6 +183,7 @@
"manager.title": "Administrador de proyectos"
"manager.new": "Nuevo proyecto"
"manager.new.default_name": "Proyecto sin título"
"manager.new.project_name": "Nombre del proyecto"
"manager.open": "Abrir"
"manager.rename": "Cambiar nombre…"
"manager.rename.title": "Cambiar nombre del proyecto"
@@ -303,7 +304,7 @@
"about.description": "Oak Video Editor es un editor de vídeo no lineal libre y de código abierto. Este software está licenciado bajo la GNU GPL versión 3."
"about.thanks": "Un agradecimiento especial a Enzo GD, nuestro Community Promoter, por su generoso apoyo al difundir este proyecto en sus primeros días."
"about.fork_notice": "Este proyecto es un fork de Olive Video Editor."
"file.open.title": "Abrir proyecto"
"file.open.title": "Abrir proyecto (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Importar material"
"preferences.title": "Preferencias"
"preferences.section.general": "General"
@@ -374,6 +375,18 @@
"actionsearch.no_actions": "No hay acciones disponibles"
"export.title": "Exportar secuencia"
"export.format": "Formato"
"export.video_codec": "Códec de vídeo"
"export.audio_codec": "Códec de audio"
"export.color": "Espacio de color"
"export.color.sdr": "SDR — Rec.709 8-bit"
"export.resolution": "Resolución (0 = secuencia)"
"export.framerate": "Frecuencia de imagen (0 = secuencia)"
"export.bitrate": "Tasa de bits (0 = valor por defecto)"
"export.color.hdr": "HDR — BT.2020 10-bit"
"export.range": "Rango de exportación"
"export.range.all": "Toda la línea de tiempo"
"export.range.inout": "Puntos de entrada/salida"
"project.export.format": "Formato del proyecto"
"export.format.placeholder": "Seleccionar un formato…"
"export.path": "Ruta de salida"
"export.run": "Exportar"
+14 -1
View File
@@ -183,6 +183,7 @@
"manager.title": "Gestionnaire de projets"
"manager.new": "Nouveau projet"
"manager.new.default_name": "Projet sans titre"
"manager.new.project_name": "Nom du projet"
"manager.open": "Ouvrir"
"manager.rename": "Renommer…"
"manager.rename.title": "Renommer le projet"
@@ -303,7 +304,7 @@
"about.description": "Oak Video Editor est un éditeur vidéo non linéaire libre et open source. Ce logiciel est sous licence GNU GPL version 3."
"about.thanks": "Remerciements particuliers à Enzo GD, notre Community Promoter, pour son généreux soutien à la diffusion de ce projet à ses débuts."
"about.fork_notice": "Ce projet est un fork d'Olive Video Editor."
"file.open.title": "Ouvrir le projet"
"file.open.title": "Ouvrir le projet (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Importer des médias"
"preferences.title": "Préférences"
"preferences.section.general": "Général"
@@ -374,6 +375,18 @@
"actionsearch.no_actions": "Aucune action disponible"
"export.title": "Exporter la séquence"
"export.format": "Format"
"export.video_codec": "Codec vidéo"
"export.audio_codec": "Codec audio"
"export.color": "Espace colorimétrique"
"export.color.sdr": "SDR — Rec.709 8 bits"
"export.resolution": "Résolution (0 = séquence)"
"export.framerate": "Cadence (0 = séquence)"
"export.bitrate": "Débit (0 = défaut codec)"
"export.color.hdr": "HDR — BT.2020 10 bits"
"export.range": "Plage d’export"
"export.range.all": "Toute la timeline"
"export.range.inout": "Points d’entrée/sortie"
"project.export.format": "Format du projet"
"export.format.placeholder": "Sélectionner un format…"
"export.path": "Chemin de sortie"
"export.run": "Exporter"
+14 -1
View File
@@ -183,6 +183,7 @@
"manager.title": "プロジェクトマネージャー"
"manager.new": "新規プロジェクト"
"manager.new.default_name": "無題のプロジェクト"
"manager.new.project_name": "プロジェクト名"
"manager.open": "開く"
"manager.rename": "名前を変更…"
"manager.rename.title": "プロジェクトの名前を変更"
@@ -303,7 +304,7 @@
"about.description": "Oak Video Editor はフリーでオープンソースのノンリニア動画エディタです。本ソフトウェアは GNU GPL バージョン 3 でライセンスされています。"
"about.thanks": "プロジェクト初期に広く紹介してくれたコミュニティプロモーター Enzo GD に心より感謝します。"
"about.fork_notice": "本プロジェクトは Olive Video Editor のフォークです。"
"file.open.title": "プロジェクトを開く"
"file.open.title": "プロジェクトを開く(.ove / .otio / .fcpxml)"
"file.import_footage.title": "素材を読み込み"
"preferences.title": "環境設定"
"preferences.section.general": "一般"
@@ -374,6 +375,18 @@
"actionsearch.no_actions": "利用可能なアクションがありません"
"export.title": "シーケンスを書き出し"
"export.format": "フォーマット"
"export.video_codec": "ビデオコーデック"
"export.audio_codec": "オーディオコーデック"
"export.color": "色域"
"export.color.sdr": "SDR — Rec.709 8bit"
"export.resolution": "解像度(0 = シーケンスに従う)"
"export.framerate": "フレームレート(0 = シーケンスに従う)"
"export.bitrate": "ビットレート(0 = コーデック既定)"
"export.color.hdr": "HDR — BT.2020 10bit"
"export.range": "書き出し範囲"
"export.range.all": "タイムライン全体"
"export.range.inout": "イン/アウトポイント間"
"project.export.format": "プロジェクト形式"
"export.format.placeholder": "フォーマットを選択…"
"export.path": "出力先"
"export.run": "書き出し"
+14 -1
View File
@@ -183,6 +183,7 @@
"manager.title": "Gerenciador de projetos"
"manager.new": "Novo projeto"
"manager.new.default_name": "Projeto sem título"
"manager.new.project_name": "Nome do projeto"
"manager.open": "Abrir"
"manager.rename": "Renomear…"
"manager.rename.title": "Renomear projeto"
@@ -303,7 +304,7 @@
"about.description": "O Oak Video Editor é um editor de vídeo não linear livre e de código aberto. Este software é licenciado sob a GNU GPL versão 3."
"about.thanks": "Um agradecimento especial a Enzo GD, nosso Community Promoter, por seu generoso apoio na divulgação deste projeto em seus primórdios."
"about.fork_notice": "Este projeto é um fork do Olive Video Editor."
"file.open.title": "Abrir projeto"
"file.open.title": "Abrir projeto (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Importar mídia"
"preferences.title": "Preferências"
"preferences.section.general": "Geral"
@@ -374,6 +375,18 @@
"actionsearch.no_actions": "Nenhuma ação disponível"
"export.title": "Exportar sequência"
"export.format": "Formato"
"export.video_codec": "Codec de vídeo"
"export.audio_codec": "Codec de áudio"
"export.color": "Espaço de cor"
"export.color.sdr": "SDR — Rec.709 8-bit"
"export.resolution": "Resolução (0 = sequência)"
"export.framerate": "Taxa de quadros (0 = sequência)"
"export.bitrate": "Taxa de bits (0 = padrão do codec)"
"export.color.hdr": "HDR — BT.2020 10-bit"
"export.range": "Faixa de exportação"
"export.range.all": "Linha do tempo inteira"
"export.range.inout": "Entre os pontos de entrada/saída"
"project.export.format": "Formato do projeto"
"export.format.placeholder": "Selecionar um formato…"
"export.path": "Caminho de saída"
"export.run": "Exportar"
+14 -1
View File
@@ -183,6 +183,7 @@
"manager.title": "Диспетчер проектов"
"manager.new": "Новый проект"
"manager.new.default_name": "Проект без названия"
"manager.new.project_name": "Название проекта"
"manager.open": "Открыть"
"manager.rename": "Переименовать…"
"manager.rename.title": "Переименовать проект"
@@ -303,7 +304,7 @@
"about.description": "Oak Video Editor — свободный нелинейный видеоредактор с открытым исходным кодом. Программа распространяется по лицензии GNU GPL версии 3."
"about.thanks": "Особая благодарность Enzo GD, нашему Community Promoter, за щедрую поддержку в распространении этого проекта в первые дни."
"about.fork_notice": "Этот проект является форком Olive Video Editor."
"file.open.title": "Открыть проект"
"file.open.title": "Открыть проект (.ove / .otio / .fcpxml)"
"file.import_footage.title": "Импорт медиафайлов"
"preferences.title": "Настройки"
"preferences.section.general": "Общие"
@@ -374,6 +375,18 @@
"actionsearch.no_actions": "Нет доступных действий"
"export.title": "Экспорт последовательности"
"export.format": "Формат"
"export.video_codec": "Видеокодек"
"export.audio_codec": "Аудиокодек"
"export.color": "Цветовое пространство"
"export.color.sdr": "SDR — Rec.709 8 бит"
"export.resolution": "Разрешение (0 = как в последовательности)"
"export.framerate": "Частота кадров (0 = как в последовательности)"
"export.bitrate": "Битрейт (0 = по умолчанию кодекa)"
"export.color.hdr": "HDR — BT.2020 10 бит"
"export.range": "Диапазон экспорта"
"export.range.all": "Вся таймлиния"
"export.range.inout": "Точки входа/выхода"
"project.export.format": "Формат проекта"
"export.format.placeholder": "Выберите формат…"
"export.path": "Путь вывода"
"export.run": "Экспортировать"
+14 -1
View File
@@ -181,6 +181,7 @@
"manager.title": "项目管理器"
"manager.new": "新建项目"
"manager.new.default_name": "未命名项目"
"manager.new.project_name": "项目名称"
"manager.open": "打开"
"manager.rename": "重命名…"
"manager.rename.title": "重命名工程"
@@ -301,7 +302,7 @@
"about.description": "Oak 视频编辑器是一款自由开源的非线性视频编辑器。本软件以 GNU GPL 第三版许可证发布。"
"about.thanks": "特别感谢我们的社区推广者 Enzo GD,感谢他在项目早期为传播本项目所给予的慷慨支持。"
"about.fork_notice": "本项目是 Olive 视频编辑器的 fork。"
"file.open.title": "打开项目"
"file.open.title": "打开项目(.ove / .otio / .fcpxml)"
"file.import_footage.title": "导入素材"
"preferences.title": "偏好设置"
"preferences.section.general": "常规"
@@ -372,6 +373,18 @@
"actionsearch.no_actions": "没有可用动作"
"export.title": "导出序列"
"export.format": "格式"
"export.video_codec": "视频编码"
"export.audio_codec": "音频编码"
"export.color": "色域/色深"
"export.color.sdr": "SDR — Rec.709 8 位"
"export.resolution": "分辨率(0 = 跟随序列)"
"export.framerate": "帧率(0 = 跟随序列)"
"export.bitrate": "码率(0 = 编码器默认)"
"export.color.hdr": "HDR — BT.2020 10 位"
"export.range": "导出范围"
"export.range.all": "整条时间轴"
"export.range.inout": "入点-出点之间"
"project.export.format": "项目格式"
"export.format.placeholder": "选择格式…"
"export.path": "输出路径"
"export.run": "导出"
+255 -17
View File
@@ -140,6 +140,12 @@ 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;
/// The new-project dialog (文件 → 新建项目).
pub const NEW_PROJECT: usize = 20;
/// The save-as dialog (文件 → 另存为).
pub const SAVE_AS: usize = 21;
}
/// What a picked platform-dialog path should do.
@@ -239,6 +245,22 @@ 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>,
},
/// The new-project dialog (文件 → 新建项目): name + sequence format.
NewProject {
modal: Entity<Modal>,
content: Entity<crate::dialogs::NewProjectContent>,
},
/// The save-as dialog (文件 → 另存为): the copy's name.
SaveAs {
modal: Entity<Modal>,
content: Entity<crate::dialogs::RenameContent>,
},
}
/// A running export: the session the tick loop drains for progress.
@@ -266,6 +288,9 @@ impl<E: AppEngine> ModalState<E> {
| ModalState::DropSequenceChoice { modal, .. }
| ModalState::MulticamWizard { modal, .. }
| ModalState::EntryRename { modal, .. } => Some(modal.clone()),
| ModalState::ExportProjectDfd { modal, .. } => Some(modal.clone()),
| ModalState::NewProject { modal, .. } => Some(modal.clone()),
| ModalState::SaveAs { modal, .. } => Some(modal.clone()),
}
}
}
@@ -1010,13 +1035,13 @@ impl<E: AppEngine> OakApp<E> {
use crate::actions::ActionId as A;
match action {
// --- File ------------------------------------------------------
A::NewProject => self.new_project(cx),
A::NewProject => self.open_new_project(cx),
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 => self.open_export_project_dfd(cx),
A::SaveProjectAs => self.open_save_as(cx),
A::CloseProject => self
.engine
.update(cx, |engine, cx| engine.close_project(cx)),
@@ -1249,14 +1274,61 @@ impl<E: AppEngine> OakApp<E> {
/// 新建项目: creates a blank project in the library and opens it (the
/// write-through persists it from the first edit). Falls back to the
/// engine's plain new-project path when the library is unavailable.
fn new_project(&mut self, cx: &mut Context<Self>) {
let name = crate::i18n::tr("manager.new.default_name").to_string();
let result = self
.engine
.update(cx, |engine, cx| engine.library_create_project(&name, cx));
if let Err(err) = result {
println!("[file] library create failed ({err}); plain new project");
self.engine.update(cx, |engine, cx| engine.new_project(cx));
fn open_new_project(&mut self, cx: &mut Context<Self>) {
if !matches!(self.modal, ModalState::None) {
return;
}
self.spawn_modal(cx, move |window, app| {
let content = app.new(|cx| crate::dialogs::NewProjectContent::new(window, cx));
let modal = app.new(|cx| {
Modal::new(
modal_ids::NEW_PROJECT,
ModalOptions::new(
crate::i18n::tr("menu.file.new_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::NewProject { modal, content }
});
}
/// Creates the project from the dialog's name + format, then seeds its
/// first sequence (the old `new_project` path kept for manager fallback).
fn commit_new_project(&mut self, content: &Entity<crate::dialogs::NewProjectContent>, cx: &mut Context<Self>) {
let name = content.read(cx).name(cx).to_string();
let format = content.read(cx).format(cx);
let interlaced = content.read(cx).interlaced(cx);
let engine = self.engine.clone();
let result = engine.update(cx, |engine, cx| {
engine.library_create_project(&name, cx)
});
match result {
Ok(()) => {
self.close_modal(cx);
let engine = self.engine.clone();
engine.update(cx, |engine, cx| {
let _ = engine.create_sequence_with_params(
crate::i18n::tr("manager.new.default_name").to_string(),
format,
interlaced,
cx,
);
});
}
Err(err) => {
println!("[file] library create failed ({err}); plain new project");
self.close_modal(cx);
self.engine.update(cx, |engine, cx| engine.new_project(cx));
}
}
}
@@ -2542,6 +2614,113 @@ 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 另存为 dialog: the copy's new name (prefilled with the
/// current project name). OK duplicates the project into the library
/// under that name and switches to the copy.
fn open_save_as(&mut self, cx: &mut Context<Self>) {
if !matches!(self.modal, ModalState::None) {
return;
}
let current_name = self
.engine
.read(cx)
.project()
.map(|p| p.name.clone())
.unwrap_or_else(|| crate::i18n::tr("manager.new.default_name").to_string());
let mut name = current_name.clone();
if let Some((stem, _)) = name.rsplit_once('.') {
name = stem.to_string();
}
name = format!("{name} Copy");
self.spawn_modal(cx, move |window, app| {
let content = app.new(|cx| {
crate::dialogs::RenameContent::new(name.clone().into(), window, cx)
});
let modal = app.new(|cx| {
Modal::new(
modal_ids::SAVE_AS,
ModalOptions::new(
crate::i18n::tr("menu.file.save_as"),
px(420.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::SaveAs { modal, content }
});
}
/// Confirms the save-as dialog: duplicate + switch to the copy.
fn commit_save_as(
&mut self,
content: &Entity<crate::dialogs::RenameContent>,
cx: &mut Context<Self>,
) {
let name = content.read(cx).value(cx).to_string();
let engine = self.engine.clone();
let result = engine.update(cx, |engine, cx| engine.save_project_as(&name, cx));
if let Err(err) = result {
println!("[file] save as failed: {err}");
}
self.close_modal(cx);
}
/// 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>) {
@@ -2704,7 +2883,7 @@ impl<E: AppEngine> OakApp<E> {
let ModalState::Export { content, .. } = &self.modal else {
return;
};
let format = content.read(cx).format(cx);
let settings = content.read(cx).settings(cx);
let ext = content.read(cx).extension(cx);
let mut path = content.read(cx).path(cx).to_string();
if path.trim().is_empty() {
@@ -2720,7 +2899,7 @@ impl<E: AppEngine> OakApp<E> {
}
let result = self.engine.update(cx, |engine, _cx| {
engine.start_export(format, PathBuf::from(&path))
engine.start_export_with(&settings, PathBuf::from(&path))
});
match result {
Ok(session) => {
@@ -2946,6 +3125,63 @@ 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::NEW_PROJECT => {
if let ModalState::NewProject { content, .. } = &self.modal {
if *button == 0 {
let content = content.clone();
self.commit_new_project(&content, cx);
} else {
self.close_modal(cx);
}
}
}
modal_ids::SAVE_AS => {
if let ModalState::SaveAs { content, .. } = &self.modal {
if *button == 0 {
let content = content.clone();
self.commit_save_as(&content, cx);
} else {
self.close_modal(cx);
}
}
}
modal_ids::SEQUENCE_PROPERTIES => {
if let ModalState::SequenceProperties { content, .. } = &self.modal {
let content = content.clone();
@@ -4751,15 +4987,17 @@ mod tests {
);
// Down selects the first listed action, Enter runs it. The empty query
// lists every menu-bar action, so the first one is New Project.
// lists every menu-bar action, so the first one is New Project — the
// v0.5 flow opens the new-project dialog (name + format), so the
// search dialog hands off to it.
cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("down").unwrap());
cx.run_until_parked();
cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("enter").unwrap());
cx.run_until_parked();
assert!(
cx.read(|app| matches!(root.read(app).modal, ModalState::None)),
"executing the selected action closes the dialog"
cx.read(|app| matches!(root.read(app).modal, ModalState::NewProject { .. })),
"the search dialog hands off to the new-project dialog"
);
}
+427 -1
View File
@@ -993,14 +993,27 @@ impl Render for PathField {
/// path field.
pub struct ExportDialogContent {
format: Entity<ComboBox>,
video_codec: Entity<ComboBox>,
audio_codec: Entity<ComboBox>,
color: Entity<ComboBox>,
range: Entity<ComboBox>,
resolution_w: Entity<SpinBox>,
resolution_h: Entity<SpinBox>,
frame_rate: Entity<SpinBox>,
bitrate: Entity<SpinBox>,
path: Entity<PathField>,
/// (format id, display name, extension) in dropdown order.
formats: Vec<(i32, String, String)>,
/// The container format for the current codec lists.
active_format: i32,
}
impl ExportDialogContent {
/// Builds the content: the format list comes from the oakcodec encoding
/// enumeration (MP4 default), the path starts empty.
/// enumeration (MP4 default), the codec lists follow the container's
/// compatible tables (switching container rebuilds them — an
/// unsupported codec is never offered), the color defaults to SDR
/// Rec.709 8-bit, the range to the whole sequence.
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let formats: Vec<(i32, String, String)> = encoding_formats()
.into_iter()
@@ -1021,6 +1034,114 @@ impl ExportDialogContent {
.unwrap_or(0);
format.update(cx, |combo, cx| combo.set_selected(Some(mp4_index), cx));
let active_format = formats
.get(mp4_index)
.map(|(id, _, _)| *id)
.unwrap_or(EXPORT_FORMAT_MP4);
// Video / audio codec combo boxes, populated by the container.
// The lists come straight from `Format::get_video_codecs` /
// `get_audio_codecs`: only codecs the container supports appear,
// so an incompatible codec is unselectable by construction.
let codec_options = |codes: Vec<i32>| {
codes
.iter()
.enumerate()
.map(|(i, code)| {
ComboBoxOption::new(
i,
oak_codec::exportcodec::Codec::get_codec_name(
oak_codec::exportcodec::Codec::from_i32(*code)
.unwrap_or(oak_codec::exportcodec::Codec::H264),
),
)
})
.collect::<Vec<_>>()
};
let video_codecs = compatible_video_codecs(active_format);
let audio_codecs = compatible_audio_codecs(active_format);
let video_codec = cx.new(|cx| {
ComboBox::new(5, codec_options(video_codecs), window, cx)
.with_placeholder(i18n::tr("export.video_codec"))
});
video_codec.update(cx, |combo, cx| combo.set_selected(Some(0), cx));
let audio_codec = cx.new(|cx| {
ComboBox::new(6, codec_options(audio_codecs), window, cx)
.with_placeholder(i18n::tr("export.audio_codec"))
});
audio_codec.update(cx, |combo, cx| combo.set_selected(Some(0), cx));
// Container change → rebuild the codec lists (and re-select the
// first compatible entry) so a stale incompatible codec can never
// survive a format switch.
cx.subscribe(&format, |this, _format, event: &ComboBoxEvent, cx| {
if let ComboBoxEvent::Selected { value } = event {
let fmt = this
.formats
.get(*value)
.map(|(id, _, _)| *id)
.unwrap_or(EXPORT_FORMAT_MP4);
this.apply_format(fmt, cx);
}
});
let color = cx.new(|cx| {
let options = vec![
ComboBoxOption::new(0, i18n::tr("export.color.sdr")),
ComboBoxOption::new(1, i18n::tr("export.color.hdr")),
];
ComboBox::new(7, options, window, cx)
.with_placeholder(i18n::tr("export.color"))
});
color.update(cx, |combo, cx| combo.set_selected(Some(0), cx));
let range = cx.new(|cx| {
let options = vec![
ComboBoxOption::new(0, i18n::tr("export.range.all")),
ComboBoxOption::new(1, i18n::tr("export.range.inout")),
];
ComboBox::new(8, options, window, cx)
.with_placeholder(i18n::tr("export.range"))
});
range.update(cx, |combo, cx| combo.set_selected(Some(0), cx));
// Resolution / frame rate / bitrate SPIN boxes: 0 = keep the
// sequence's / the codec's default (the engine treats 0 that way),
// any typed value is honored (the backend accepts arbitrary
// width/height/rate/bitrate).
let resolution_w = cx.new(|cx| {
SpinBox::new(
9,
SliderModel::new(ValueKind::Integer, 0.0, 16384.0, 4.0, 0.0),
window,
cx,
)
});
let resolution_h = cx.new(|cx| {
SpinBox::new(
12,
SliderModel::new(ValueKind::Integer, 0.0, 16384.0, 4.0, 0.0),
window,
cx,
)
});
let frame_rate = cx.new(|cx| {
SpinBox::new(
10,
SliderModel::new(ValueKind::Float, 0.0, 240.0, 0.1, 0.0),
window,
cx,
)
});
let bitrate = cx.new(|cx| {
SpinBox::new(
11,
SliderModel::new(ValueKind::Integer, 0.0, 500_000_000.0, 1_000_000.0, 0.0),
window,
cx,
)
});
let path = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
PathField {
@@ -1031,11 +1152,61 @@ impl ExportDialogContent {
Self {
format,
video_codec,
audio_codec,
color,
range,
resolution_w,
resolution_h,
frame_rate,
bitrate,
path,
formats,
active_format,
}
}
/// Rebuilds the codec lists for `fmt` and re-selects the first entry.
fn apply_format(&mut self, fmt: i32, cx: &mut Context<Self>) {
let video_codes = compatible_video_codecs(fmt);
let audio_codes = compatible_audio_codecs(fmt);
let v_opts = video_codes
.iter()
.enumerate()
.map(|(i, code)| {
ComboBoxOption::new(
i,
oak_codec::exportcodec::Codec::get_codec_name(
oak_codec::exportcodec::Codec::from_i32(*code)
.unwrap_or(oak_codec::exportcodec::Codec::H264),
),
)
})
.collect::<Vec<_>>();
let a_opts = audio_codes
.iter()
.enumerate()
.map(|(i, code)| {
ComboBoxOption::new(
i,
oak_codec::exportcodec::Codec::get_codec_name(
oak_codec::exportcodec::Codec::from_i32(*code)
.unwrap_or(oak_codec::exportcodec::Codec::AAC),
),
)
})
.collect::<Vec<_>>();
self.video_codec
.update(cx, |combo, cx| combo.set_options(v_opts, cx));
self.video_codec
.update(cx, |combo, cx| combo.set_selected(Some(0), cx));
self.audio_codec
.update(cx, |combo, cx| combo.set_options(a_opts, cx));
self.audio_codec
.update(cx, |combo, cx| combo.set_selected(Some(0), cx));
self.active_format = fmt;
}
/// The selected format id.
pub fn format(&self, cx: &App) -> i32 {
let Some(selected) = self.format.read(cx).selected() else {
@@ -1070,6 +1241,65 @@ impl ExportDialogContent {
.update(cx, |content, cx| content.set_path(path, cx));
cx.notify();
}
/// The full export settings (codec/color/bit-depth/range/size/fps/
/// bitrate) the engine consumes. The codec combos expose only the
/// container's compatible codes, so the picked ids are always valid
/// for `format`.
pub fn settings(&self, cx: &App) -> crate::oakui::engine::ExportSettings {
let video_codec = compatible_video_codecs(self.active_format)
.get(self.video_codec.read(cx).selected().unwrap_or(0))
.copied()
.unwrap_or(1);
let audio_codec = compatible_audio_codecs(self.active_format)
.get(self.audio_codec.read(cx).selected().unwrap_or(0))
.copied()
.unwrap_or(12);
let hdr = self.color.read(cx).selected() == Some(1);
let in_out = self.range.read(cx).selected() == Some(1);
let w = self.resolution_w.read(cx).value().to_f64().round() as i32;
let h = self.resolution_h.read(cx).value().to_f64().round() as i32;
let size = if w > 0 && h > 0 { (w, h) } else { (0, 0) };
let frame_rate = self.frame_rate.read(cx).value().to_f64();
let video_bitrate = self.bitrate.read(cx).value().to_f64().round() as i64;
crate::oakui::engine::ExportSettings {
format: self.active_format,
video_codec,
audio_codec,
size,
frame_rate,
video_bitrate,
audio_bitrate: 0,
bit_depth: if hdr { 10 } else { 8 },
color_primaries: if hdr { 9 } else { 1 },
color_transfer: if hdr { 16 } else { 1 },
color_space: if hdr { 9 } else { 1 },
range: if in_out { Some((0.0, 0.0)) } else { None },
}
}
}
/// The video codecs the container `format` accepts (the compatibility
/// table; the dialog only offers these).
pub fn compatible_video_codecs(format: i32) -> Vec<i32> {
let Some(container) = oak_codec::exportformat::Format::from_i32(format) else {
return vec![1]; // H.264 fallback
};
oak_codec::exportformat::Format::get_video_codecs(container)
.iter()
.map(|c| *c as i32)
.collect()
}
/// The audio codecs the container `format` accepts.
pub fn compatible_audio_codecs(format: i32) -> Vec<i32> {
let Some(container) = oak_codec::exportformat::Format::from_i32(format) else {
return vec![12]; // AAC fallback
};
oak_codec::exportformat::Format::get_audio_codecs(container)
.iter()
.map(|c| *c as i32)
.collect()
}
impl Render for ExportDialogContent {
@@ -1085,6 +1315,49 @@ impl Render for ExportDialogContent {
i18n::tr("export.format").into(),
self.format.clone(),
))
.child(form_row(
&colors,
i18n::tr("export.video_codec").into(),
self.video_codec.clone(),
))
.child(form_row(
&colors,
i18n::tr("export.audio_codec").into(),
self.audio_codec.clone(),
))
.child(form_row(
&colors,
i18n::tr("export.color").into(),
self.color.clone(),
))
.child(form_row(
&colors,
i18n::tr("export.range").into(),
self.range.clone(),
))
.child(form_row(
&colors,
i18n::tr("export.resolution").into(),
div()
.flex()
.gap_2()
.child(self.resolution_w.clone())
.child(div()
.text_color(colors.disabled)
.text_xs()
.child("×"))
.child(self.resolution_h.clone()),
))
.child(form_row(
&colors,
i18n::tr("export.framerate").into(),
self.frame_rate.clone(),
))
.child(form_row(
&colors,
i18n::tr("export.bitrate").into(),
self.bitrate.clone(),
))
.child(form_row(
&colors,
i18n::tr("export.path").into(),
@@ -3857,6 +4130,159 @@ 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(),
))
}
}
/// The new-project dialog (文件 → 新建项目): project name + the first
/// sequence's format (preset / custom width-height-rate / interlaced).
/// The host reads [`Self::name`] + [`Self::format`]; the engine's
/// library_create_project then seeds the first sequence.
pub struct NewProjectContent {
name: Entity<TextValue>,
format: Entity<SequenceFormatFields>,
}
impl NewProjectContent {
/// Builds the dialog (default name, HD 1080p25).
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let name = cx.new(|cx| {
let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx));
TextValue { editor }
});
name.update(cx, |field, cx| {
field.set_value(i18n::tr("manager.new.default_name"), cx)
});
let format = cx.new(|cx| {
SequenceFormatFields::build(SequenceFormatSeed::hd_1080p25(), window, cx)
});
Self { name, format }
}
/// The project name entered.
pub fn name(&self, cx: &App) -> SharedString {
self.name.read(cx).value(cx)
}
/// The format selected.
pub fn format(&self, cx: &App) -> crate::oakui::engine::VideoFormat {
self.format.read(cx).format(cx)
}
/// The interlaced flag.
pub fn interlaced(&self, cx: &App) -> bool {
self.format.read(cx).interlaced(cx)
}
}
impl Render for NewProjectContent {
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("manager.new.project_name").into(),
self.name.clone(),
))
.child(self.format.read(cx).rows(&colors))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -789,6 +789,16 @@ impl ComboBox {
self.selected
}
/// Replaces the option list (a container/format switch rebuilds the
/// compatible codec lists); selection resets to the first entry.
pub fn set_options(&mut self, options: Vec<ComboBoxOption>, cx: &mut Context<Self>) {
self.options = options;
self.selected = None;
self.highlight = 0;
self.open = false;
cx.notify();
}
/// The selected option index (external sync; also repaints).
pub fn set_selected(&mut self, selected: Option<usize>, _cx: &mut Context<Self>) {
self.selected = selected;
+70 -2
View File
@@ -726,6 +726,14 @@ pub trait AppEngine:
Err("project library not supported".into())
}
/// 另存为: saves the OPEN project into the library as a differently-
/// named copy and switches the current project to that copy. Default:
/// unsupported.
fn save_project_as(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
let _ = (name, cx);
Err("save-as not supported".into())
}
/// Imports a `.ove` / `.otio` / `.fcpxml` project file into the library
/// as a new row; returns the new row's uuid.
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
@@ -802,6 +810,15 @@ pub trait AppEngine:
/// [`ExportSession`] carries the event channel and the cancel handle.
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String>;
/// Like [`Self::start_export`] but with the full dialog settings
/// (codecs, color space/bit depth, size/rate/bitrate, range). The
/// default start_export stays for the legacy callers.
fn start_export_with(
&mut self,
settings: &ExportSettings,
path: PathBuf,
) -> Result<ExportSession, String>;
// -------------------------------------------------------------------
// Proxy media (the C++ Tools > proxy pipeline): global switch, per
// footage state and the generate / delete / reveal entries. Defaults
@@ -1496,8 +1513,59 @@ pub enum ExportEvent {
/// A running export: the event channel the host drains plus the cancel
/// handle. Dropping the session does not abort the export thread; the
/// thread owns the task and frees it when it finishes.
pub struct ExportSession {
/// The event receiver (the background thread's sender lives as long as
/// The export dialog's settings (文件 → 导出媒体…): container format,
/// video/audio codecs, color space + bit depth, output size / rate /
/// bitrate and the export range. The engine's `start_export` consumes
/// these to build the oak-task `EncodingParams` (the codec tables gate
/// which combos are selectable — an incompatible codec is never offered).
#[derive(Debug, Clone)]
pub struct ExportSettings {
/// The container format id ([`oak_codec::exportformat::Format`]).
pub format: i32,
/// The video codec ([`oak_codec::exportcodec::Codec`]).
pub video_codec: i32,
/// The audio codec ([`oak_codec::exportcodec::Codec`]).
pub audio_codec: i32,
/// Output size (`(width, height)`; `(0,0)` keeps the sequence size).
pub size: (i32, i32),
/// Output frame rate (fps; `0` = the sequence rate).
pub frame_rate: f64,
/// Video bitrate in bit/s (`0` = codec default).
pub video_bitrate: i64,
/// Audio bitrate in bit/s (`0` = codec default).
pub audio_bitrate: i64,
/// Bit depth: 8 → U8, 10 → U10 (SDR / HDR respectively).
pub bit_depth: i32,
/// AVColorPrimaries (SDR 709 = 1, HDR 2020 = 9).
pub color_primaries: i32,
/// AVColorTransferCharacteristic (SDR 709 = 1, HDR PQ = 16).
pub color_transfer: i32,
/// AVColorSpace (SDR 709 = 1, HDR 2020 = 9).
pub color_space: i32,
/// Export range: `None` = whole sequence, `Some((in_s, out_s))`.
pub range: Option<(f64, f64)>,
}
impl Default for ExportSettings {
fn default() -> Self {
Self {
format: 2, // MPEG4Video (.mp4)
video_codec: 1, // H.264
audio_codec: 12, // AAC
size: (0, 0),
frame_rate: 0.0,
video_bitrate: 0,
audio_bitrate: 0,
bit_depth: 8,
color_primaries: 1,
color_transfer: 1,
color_space: 1,
range: None,
}
}
}
pub struct ExportSession { /// The event receiver (the background thread's sender lives as long as
/// the session's `cancel` side, so a dropped receiver just stops
/// delivering).
pub events: std::sync::mpsc::Receiver<ExportEvent>,
+8
View File
@@ -2012,6 +2012,14 @@ impl AppEngine for MockEngine {
}
fn start_export(&mut self, _format: i32, _path: PathBuf) -> Result<ExportSession, String> {
self.start_export_with(&crate::oakui::engine::ExportSettings::default(), _path)
}
fn start_export_with(
&mut self,
_settings: &crate::oakui::engine::ExportSettings,
_path: PathBuf,
) -> Result<ExportSession, String> {
// Mock export: fake progress on a background thread, no file.
let (tx, rx) = mpsc::channel::<ExportEvent>();
std::thread::spawn(move || {
+34 -2
View File
@@ -5387,6 +5387,26 @@ impl AppEngine for RealEngine {
.map_err(|e| format!("failed to duplicate the project: {e}"))
}
/// 另存为: duplicate the open project into the library under a new
/// (named) row, then open that copy as the current project. The
/// duplicator applies the new name to the copy's row and projectname.
fn save_project_as(&mut self, name: &str, cx: &mut Context<Self>) -> Result<(), String> {
let Some(project) = self.project.clone() else {
return Err("no project open".to_string());
};
let current_uuid = {
let guard = graphops::lock(&project);
guard.uuid.clone()
};
let new_uuid = graphops::library_duplicate(&current_uuid)
.map_err(|e| format!("duplicate failed: {e}"))?;
graphops::library_rename(&new_uuid, name.trim())
.map_err(|e| format!("rename copy failed: {e}"))?;
// Open the new row (adopt switches the current project).
self.open_library_project(&new_uuid, cx)?;
Ok(())
}
fn library_import_project(&mut self, path: PathBuf) -> Result<String, String> {
graphops::library_import(&path)
.map_err(|e| format!("failed to import \"{}\": {e}", path.display()))
@@ -6176,14 +6196,26 @@ impl AppEngine for RealEngine {
}
fn start_export(&mut self, format: i32, path: PathBuf) -> Result<ExportSession, String> {
let settings = super::engine::ExportSettings {
format,
..Default::default()
};
self.start_export_with(&settings, path)
}
fn start_export_with(
&mut self,
settings: &super::engine::ExportSettings,
path: PathBuf,
) -> Result<ExportSession, String> {
let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else {
return Err("no sequence open".into());
};
let workarea = self.workarea().map(|(s, e)| (s.0, e.0));
let params = super::renderops::encoding_params(
let params = super::renderops::encoding_params_with_settings(
&project,
seq,
format,
settings,
&path,
workarea,
self.sequence_length().0,
+113
View File
@@ -1096,6 +1096,119 @@ pub fn encoding_params(
custom_range_in_den: range_in.denominator() as i32,
custom_range_out_num: range_out.numerator() as i32,
custom_range_out_den: range_out.denominator() as i32,
video_bit_rate: 0,
audio_bit_rate: 0,
color_override_enabled: false,
color_primaries: 0,
color_trc: 0,
color_space: 0,
})
}
/// Like [`encoding_params`] but with the dialog's full settings: chosen
/// video/audio codecs, color space + bit depth, output size / rate /
/// bitrate and the export range. The sequence's parameters fill anything
/// the settings left at zero.
pub fn encoding_params_with_settings(
p: &ProjectRef,
seq: NodeId,
settings: &crate::oakui::engine::ExportSettings,
path: &std::path::Path,
workarea: Option<(i64, i64)>,
length_frames: i64,
) -> Result<oak_task::export::EncodingParams, String> {
let container = oak_codec::exportformat::Format::from_i32(settings.format)
.ok_or_else(|| format!("unknown export format {}", settings.format))?;
// ONLY the codecs the container supports are selectable upstream (the
// dialog rebuilds its lists from these tables); the chosen codec must
// be in the table — a mismatch is a hard error, not a silent default.
let video_codec = oak_codec::exportformat::Format::get_video_codecs(container)
.iter()
.find(|c| **c as i32 == settings.video_codec)
.copied()
.ok_or_else(|| format!("codec {} not supported by {container:?}", settings.video_codec))?;
let audio_codec = oak_codec::exportformat::Format::get_audio_codecs(container)
.iter()
.find(|c| **c as i32 == settings.audio_codec)
.copied()
.ok_or_else(|| format!("audio codec {} not supported by {container:?}", settings.audio_codec))?;
let (mut width, mut height, rate) = {
let g = lock(p);
super::graphops::sequence_video_params(&g.graph, seq)
.ok_or_else(|| "the sequence has no video parameters".to_string())?
};
if settings.size.0 > 0 && settings.size.1 > 0 {
width = settings.size.0;
height = settings.size.1;
}
let mut rate_num = rate.numerator().max(1) as i32;
let mut rate_den = rate.denominator().max(1) as i32;
if settings.frame_rate > 0.0 {
// Round to the nearest 1/1 frame; 29.97 etc. stay as-is.
rate_num = settings.frame_rate.round().max(1.0) as i32;
rate_den = 1;
}
let (has_custom_range, range_in, range_out, length) = match settings.range {
Some((in_s, out_s)) if out_s > in_s && in_s >= 0.0 => {
let fps = i64::from(rate_num.max(1));
(
true,
Rational::new((in_s * fps as f64).round().max(0.0) as i64, fps),
Rational::new((out_s * fps as f64).round().max(0.0) as i64, fps),
Rational::new(((out_s - in_s) * fps as f64).round().max(0.0) as i64, fps),
)
}
_ => match workarea.filter(|(s, e)| e > s) {
Some((in_ts, out_ts)) => (
true,
Rational::new(in_ts * i64::from(rate_den.max(1)), i64::from(rate_num.max(1))),
Rational::new(out_ts * i64::from(rate_den.max(1)), i64::from(rate_num.max(1))),
Rational::new((out_ts - in_ts) * i64::from(rate_den.max(1)), i64::from(rate_num.max(1))),
),
None => (
false,
Rational::new(0, 1),
Rational::new(0, 1),
Rational::new(length_frames.max(0) * i64::from(rate_den.max(1)), i64::from(rate_num.max(1))),
),
},
};
let bit_depth = settings.bit_depth;
let pixel_format = match bit_depth {
10 => 1, // PixelFormat::U10 (only when the codec supports it; the
// dialog gates 10-bit behind HDR — codecs without 10-bit
// stay at the 8-bit default and the dialog disables HDR).
_ => 0, // PixelFormat::U8
};
Ok(oak_task::export::EncodingParams {
filename: path.to_string_lossy().into_owned(),
format: settings.format,
video_enabled: true,
video_codec: video_codec as i32,
video_width: width.max(1),
video_height: height.max(1),
video_time_base_num: rate_den.max(1),
video_time_base_den: rate_num.max(1),
video_pixel_format: pixel_format,
audio_enabled: true,
audio_codec: audio_codec as i32,
audio_sample_rate: EXPORT_SAMPLE_RATE,
audio_channel_layout: EXPORT_CHANNEL_LAYOUT,
subtitles_enabled: false,
export_length_num: length.numerator() as i32,
export_length_den: length.denominator() as i32,
has_custom_range,
custom_range_in_num: range_in.numerator() as i32,
custom_range_in_den: range_in.denominator() as i32,
custom_range_out_num: range_out.numerator() as i32,
custom_range_out_den: range_out.denominator() as i32,
video_bit_rate: settings.video_bitrate,
audio_bit_rate: settings.audio_bitrate,
color_override_enabled: true,
color_primaries: settings.color_primaries,
color_trc: settings.color_transfer,
color_space: settings.color_space,
})
}
+35 -10
View File
@@ -105,14 +105,29 @@ pub struct EncodingParams {
/// set, [`ExportTask::export_range`] renders exactly `[in, out)` instead
/// of the whole viewer length.
pub has_custom_range: bool,
/// Custom range in point numerator (seconds rational).
/// Custom range in, rational seconds.
pub custom_range_in_num: i32,
/// Custom range in point denominator.
/// Custom range in denominator.
pub custom_range_in_den: i32,
/// Custom range out point numerator (seconds rational).
/// Custom range out, rational seconds.
pub custom_range_out_num: i32,
/// Custom range out point denominator.
/// Custom range out denominator.
pub custom_range_out_den: i32,
/// Video bit rate (bit/s; 0 = codec default).
pub video_bit_rate: i64,
/// Audio bit rate (bit/s; 0 = codec default).
pub audio_bit_rate: i64,
/// Delivery colorimetry override flags: `0` = use the project's output
/// spec (legacy), `1` = apply the `color_*` code points from the export
/// dialog (SDR 709 / HDR 2020 selection).
pub color_override_enabled: bool,
/// AVColorPrimaries (SDR 709 = 1, HDR BT.2020 = 9).
pub color_primaries: i32,
/// AVColorTransferCharacteristic (SDR 709 = 1, HDR PQ = 16).
pub color_trc: i32,
/// AVColorSpace (SDR 709 = 1, HDR BT.2020 = 9).
pub color_space: i32,
}
impl ExportTask {
@@ -164,15 +179,25 @@ impl ExportTask {
params.subtitles_enabled = self.encoding_params.subtitles_enabled as i32;
params.export_length_num = self.encoding_params.export_length_num;
params.export_length_den = self.encoding_params.export_length_den;
// Delivery colorimetry: tag the output container with the project's
params.video_bit_rate = self.encoding_params.video_bit_rate;
params.audio_bit_rate = self.encoding_params.audio_bit_rate;
// Delivery colorimetry: the EXPORT DIALOG's choice (SDR 709 8-bit /
// HDR 2020 10-bit) wins when enabled; otherwise the project's
// output colorspace (H.273 code points → mov `colr` atom / VUI).
// Limited range is the video-delivery convention; the encoder's
// RGB→YCbCr runs limited.
let (_working, spec) = self.delivery_color();
params.color_primaries = spec.gamut.av_color_primaries();
params.color_trc = spec.transfer.av_color_trc();
params.color_space = spec.gamut.av_color_space();
params.color_range = 1; // AVCOL_RANGE_MPEG (limited)
if self.encoding_params.color_override_enabled {
params.color_primaries = self.encoding_params.color_primaries;
params.color_trc = self.encoding_params.color_trc;
params.color_space = self.encoding_params.color_space;
params.color_range = 1; // AVCOL_RANGE_MPEG (limited)
} else {
let (_working, spec) = self.delivery_color();
params.color_primaries = spec.gamut.av_color_primaries();
params.color_trc = spec.transfer.av_color_trc();
params.color_space = spec.gamut.av_color_space();
params.color_range = 1; // AVCOL_RANGE_MPEG (limited)
}
params
}