diff --git a/assets/i18n/de.yaml b/assets/i18n/de.yaml index 3fb6103cb..88a7d5953 100644 --- a/assets/i18n/de.yaml +++ b/assets/i18n/de.yaml @@ -248,6 +248,22 @@ "projprops.cache.default": "Standardort verwenden" "projprops.cache.alongside": "Zusammen mit dem Projekt speichern" "projprops.cache.custom": "Benutzerdefinierten Speicherort verwenden:" +"seqprops.new.title": "Neue Sequenz" +"seqprops.title": "Sequenzeigenschaften" +"seqprops.default_name": "Sequenz 1" +"seqprops.name": "Sequenzname" +"seqprops.preset": "Voreinstellung" +"seqprops.preset.custom": "Benutzerdefiniert" +"seqprops.preset.pal": "PAL (720×576, 25 fps)" +"seqprops.preset.ntsc": "NTSC (720×480, 29,97 fps)" +"seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 fps)" +"seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 fps)" +"seqprops.width": "Breite" +"seqprops.height": "Höhe" +"seqprops.frame_rate": "Bildrate" +"seqprops.interlaced": "Interlaced" +"seqprops.error.no_project": "Kein Projekt ist geöffnet." +"seqprops.error.no_sequence": "Die Sequenz existiert nicht mehr." "about.title": "Über Oak" "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." diff --git a/assets/i18n/en-US.yaml b/assets/i18n/en-US.yaml index 529cbe01a..620081a84 100644 --- a/assets/i18n/en-US.yaml +++ b/assets/i18n/en-US.yaml @@ -248,6 +248,22 @@ "projprops.cache.default": "Use Default Location" "projprops.cache.alongside": "Store Alongside Project" "projprops.cache.custom": "Use Custom Location:" +"seqprops.new.title": "New Sequence" +"seqprops.title": "Sequence Properties" +"seqprops.default_name": "Sequence 1" +"seqprops.name": "Sequence Name" +"seqprops.preset": "Preset" +"seqprops.preset.custom": "Custom" +"seqprops.preset.pal": "PAL (720×576, 25 fps)" +"seqprops.preset.ntsc": "NTSC (720×480, 29.97 fps)" +"seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 fps)" +"seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 fps)" +"seqprops.width": "Width" +"seqprops.height": "Height" +"seqprops.frame_rate": "Frame Rate" +"seqprops.interlaced": "Interlaced" +"seqprops.error.no_project": "No project is open." +"seqprops.error.no_sequence": "The sequence no longer exists." "about.title": "About Oak" "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." diff --git a/assets/i18n/es.yaml b/assets/i18n/es.yaml index e224cdd5b..db4e5b6f1 100644 --- a/assets/i18n/es.yaml +++ b/assets/i18n/es.yaml @@ -248,6 +248,22 @@ "projprops.cache.default": "Usar ubicación predeterminada" "projprops.cache.alongside": "Almacenar junto al proyecto" "projprops.cache.custom": "Usar ubicación personalizada:" +"seqprops.new.title": "Nueva secuencia" +"seqprops.title": "Propiedades de la secuencia" +"seqprops.default_name": "Secuencia 1" +"seqprops.name": "Nombre de la secuencia" +"seqprops.preset": "Ajuste preestablecido" +"seqprops.preset.custom": "Personalizado" +"seqprops.preset.pal": "PAL (720×576, 25 fps)" +"seqprops.preset.ntsc": "NTSC (720×480, 29,97 fps)" +"seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 fps)" +"seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 fps)" +"seqprops.width": "Ancho" +"seqprops.height": "Alto" +"seqprops.frame_rate": "Velocidad de fotogramas" +"seqprops.interlaced": "Entrelazado" +"seqprops.error.no_project": "No hay ningún proyecto abierto." +"seqprops.error.no_sequence": "La secuencia ya no existe." "about.title": "Acerca de Oak" "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." diff --git a/assets/i18n/fr.yaml b/assets/i18n/fr.yaml index 7db081fdb..56c37e458 100644 --- a/assets/i18n/fr.yaml +++ b/assets/i18n/fr.yaml @@ -248,6 +248,22 @@ "projprops.cache.default": "Utiliser l'emplacement par défaut" "projprops.cache.alongside": "Stocker à côté du projet" "projprops.cache.custom": "Utiliser un emplacement personnalisé :" +"seqprops.new.title": "Nouvelle séquence" +"seqprops.title": "Propriétés de la séquence" +"seqprops.default_name": "Séquence 1" +"seqprops.name": "Nom de la séquence" +"seqprops.preset": "Présélection" +"seqprops.preset.custom": "Personnalisé" +"seqprops.preset.pal": "PAL (720×576, 25 i/s)" +"seqprops.preset.ntsc": "NTSC (720×480, 29,97 i/s)" +"seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 i/s)" +"seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 i/s)" +"seqprops.width": "Largeur" +"seqprops.height": "Hauteur" +"seqprops.frame_rate": "Cadence" +"seqprops.interlaced": "Entrelacé" +"seqprops.error.no_project": "Aucun projet n'est ouvert." +"seqprops.error.no_sequence": "La séquence n'existe plus." "about.title": "À propos d'Oak" "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." diff --git a/assets/i18n/ja.yaml b/assets/i18n/ja.yaml index 488a467f7..d152f073d 100644 --- a/assets/i18n/ja.yaml +++ b/assets/i18n/ja.yaml @@ -248,6 +248,22 @@ "projprops.cache.default": "デフォルトの場所を使用" "projprops.cache.alongside": "プロジェクトと同じ場所に保存" "projprops.cache.custom": "カスタムの場所を使用:" +"seqprops.new.title": "新規シーケンス" +"seqprops.title": "シーケンスのプロパティ" +"seqprops.default_name": "シーケンス 1" +"seqprops.name": "シーケンス名" +"seqprops.preset": "プリセット" +"seqprops.preset.custom": "カスタム" +"seqprops.preset.pal": "PAL (720×576, 25 fps)" +"seqprops.preset.ntsc": "NTSC (720×480, 29.97 fps)" +"seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 fps)" +"seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 fps)" +"seqprops.width": "幅" +"seqprops.height": "高さ" +"seqprops.frame_rate": "フレームレート" +"seqprops.interlaced": "インターレース" +"seqprops.error.no_project": "プロジェクトが開かれていません。" +"seqprops.error.no_sequence": "シーケンスはもう存在しません。" "about.title": "Oak について" "about.description": "Oak Video Editor はフリーでオープンソースのノンリニア動画エディタです。本ソフトウェアは GNU GPL バージョン 3 でライセンスされています。" "about.thanks": "プロジェクト初期に広く紹介してくれたコミュニティプロモーター Enzo GD に心より感謝します。" diff --git a/assets/i18n/pt.yaml b/assets/i18n/pt.yaml index 7387742c6..a1f31cead 100644 --- a/assets/i18n/pt.yaml +++ b/assets/i18n/pt.yaml @@ -248,6 +248,22 @@ "projprops.cache.default": "Usar localização padrão" "projprops.cache.alongside": "Armazenar junto ao projeto" "projprops.cache.custom": "Usar localização personalizada:" +"seqprops.new.title": "Nova sequência" +"seqprops.title": "Propriedades da sequência" +"seqprops.default_name": "Sequência 1" +"seqprops.name": "Nome da sequência" +"seqprops.preset": "Predefinição" +"seqprops.preset.custom": "Personalizado" +"seqprops.preset.pal": "PAL (720×576, 25 qps)" +"seqprops.preset.ntsc": "NTSC (720×480, 29,97 qps)" +"seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 qps)" +"seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 qps)" +"seqprops.width": "Largura" +"seqprops.height": "Altura" +"seqprops.frame_rate": "Taxa de quadros" +"seqprops.interlaced": "Entrelaçado" +"seqprops.error.no_project": "Nenhum projeto está aberto." +"seqprops.error.no_sequence": "A sequência não existe mais." "about.title": "Sobre o Oak" "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." diff --git a/assets/i18n/ru.yaml b/assets/i18n/ru.yaml index d8ae25629..9d60db7c5 100644 --- a/assets/i18n/ru.yaml +++ b/assets/i18n/ru.yaml @@ -248,6 +248,22 @@ "projprops.cache.default": "Использовать расположение по умолчанию" "projprops.cache.alongside": "Хранить рядом с проектом" "projprops.cache.custom": "Использовать пользовательское расположение:" +"seqprops.new.title": "Новая последовательность" +"seqprops.title": "Свойства последовательности" +"seqprops.default_name": "Последовательность 1" +"seqprops.name": "Имя последовательности" +"seqprops.preset": "Пресет" +"seqprops.preset.custom": "Пользовательский" +"seqprops.preset.pal": "PAL (720×576, 25 кадр/с)" +"seqprops.preset.ntsc": "NTSC (720×480, 29,97 кадр/с)" +"seqprops.preset.hd_1080_25": "HD 1080p25 (1920×1080, 25 кадр/с)" +"seqprops.preset.hd_1080_30": "HD 1080p30 (1920×1080, 30 кадр/с)" +"seqprops.width": "Ширина" +"seqprops.height": "Высота" +"seqprops.frame_rate": "Частота кадров" +"seqprops.interlaced": "Чересстрочная" +"seqprops.error.no_project": "Нет открытого проекта." +"seqprops.error.no_sequence": "Последовательность больше не существует." "about.title": "О программе Oak" "about.description": "Oak Video Editor — свободный нелинейный видеоредактор с открытым исходным кодом. Программа распространяется по лицензии GNU GPL версии 3." "about.thanks": "Особая благодарность Enzo GD, нашему Community Promoter, за щедрую поддержку в распространении этого проекта в первые дни." diff --git a/assets/i18n/zh-CN.yaml b/assets/i18n/zh-CN.yaml index cf2ebb160..5fb49bf34 100644 --- a/assets/i18n/zh-CN.yaml +++ b/assets/i18n/zh-CN.yaml @@ -248,6 +248,22 @@ "projprops.cache.default": "使用默认位置" "projprops.cache.alongside": "随工程文件存储" "projprops.cache.custom": "使用自定义位置:" +"seqprops.new.title": "新建序列" +"seqprops.title": "序列属性" +"seqprops.default_name": "序列 1" +"seqprops.name": "序列名称" +"seqprops.preset": "预设" +"seqprops.preset.custom": "自定义" +"seqprops.preset.pal": "PAL(720×576,25 fps)" +"seqprops.preset.ntsc": "NTSC(720×480,29.97 fps)" +"seqprops.preset.hd_1080_25": "HD 1080p25(1920×1080,25 fps)" +"seqprops.preset.hd_1080_30": "HD 1080p30(1920×1080,30 fps)" +"seqprops.width": "宽度" +"seqprops.height": "高度" +"seqprops.frame_rate": "帧速率" +"seqprops.interlaced": "隔行扫描" +"seqprops.error.no_project": "当前没有打开的项目。" +"seqprops.error.no_sequence": "该序列已不存在。" "about.title": "关于 Oak" "about.description": "Oak 视频编辑器是一款自由开源的非线性视频编辑器。本软件以 GNU GPL 第三版许可证发布。" "about.thanks": "特别感谢我们的社区推广者 Enzo GD,感谢他在项目早期为传播本项目所给予的慷慨支持。" diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index c95f7f622..2cadd894b 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -125,6 +125,11 @@ mod modal_ids { pub const ABOUT: usize = 13; /// The project properties dialog (File > Project Properties…). pub const PROJECT_PROPERTIES: usize = 12; + /// The new-sequence dialog (File > New > Sequence…). + pub const NEW_SEQUENCE: usize = 14; + /// The sequence properties dialog (right-click a sequence in the + /// project explorer > Sequence Properties). + pub const SEQUENCE_PROPERTIES: usize = 15; } /// What a picked platform-dialog path should do. @@ -191,6 +196,17 @@ enum ModalState { }, /// The about dialog (Help > About Oak…; static content). About { modal: Entity }, + /// The new-sequence dialog (File > New > Sequence…). + NewSequence { + modal: Entity, + content: Entity>, + }, + /// The sequence properties dialog (project-explorer context menu > + /// Sequence Properties). + SequenceProperties { + modal: Entity, + content: Entity>, + }, } /// A running export: the session the tick loop drains for progress. @@ -212,7 +228,9 @@ impl ModalState { | ModalState::Proxy { modal, .. } | ModalState::ActionSearch { modal, .. } | ModalState::ProjectProperties { modal, .. } - | ModalState::About { modal } => Some(modal.clone()), + | ModalState::About { modal } + | ModalState::NewSequence { modal, .. } + | ModalState::SequenceProperties { modal, .. } => Some(modal.clone()), } } } @@ -494,6 +512,17 @@ impl OakApp { Self::wire_panel_context_menu(cx, &panels.inspector, INSPECTOR); Self::wire_panel_context_menu(cx, &panels.timeline, TIMELINE); + // The project explorer's 序列属性 context item opens the sequence + // properties dialog. + cx.subscribe( + &panels.project, + |this, _panel, event: &crate::panels::project_explorer::SequencePropertiesRequested, + cx| { + this.open_sequence_properties(event.0, cx); + }, + ) + .detach(); + // Arrange the default workspace: the design's 素材查看器 | 序列查看器 | // 检查器 row (project bin docked on the left), node editor + history // as tabs, timeline full width at the bottom. @@ -1062,6 +1091,15 @@ impl OakApp { } A::ProxySettings => self.open_proxy_dialog(cx), A::ProjectProperties => self.open_project_properties(cx), + A::NewSequence => self.open_new_sequence(cx), + A::NewFolder => { + if let Err(err) = self + .engine + .update(cx, |engine, cx| engine.create_folder(String::new(), cx)) + { + println!("[sequence] new folder: {err}"); + } + } // The multicam source-switch hotkeys are scoped to the Multicam // panel (the focused-panel route handles them there); a fall-through // from any other focused panel is a silent no-op. @@ -2064,6 +2102,73 @@ impl OakApp { }); } + /// Opens the new-sequence dialog (File > New > Sequence…; the C++ + /// `NewSequenceDialog`): the sequence name plus the format fields. The + /// OK button creates the sequence through the content's `commit`. + fn open_new_sequence(&mut self, cx: &mut Context) { + if !matches!(self.modal, ModalState::None) { + return; + } + let engine = self.engine.clone(); + self.spawn_modal(cx, move |window, app| { + let content = app.new(|cx| crate::dialogs::NewSequenceContent::new(engine, window, cx)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::NEW_SEQUENCE, + ModalOptions::new(crate::i18n::tr("seqprops.new.title"), 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::NewSequence { modal, content } + }); + } + + /// Opens the sequence properties dialog for the given sequence (the + /// project explorer's context menu; the C++ `SequencePropertiesDialog`). + /// The OK button applies the name / format edits through the content's + /// `commit`. + fn open_sequence_properties(&mut self, sequence_id: u64, cx: &mut Context) { + if !matches!(self.modal, ModalState::None) { + return; + } + let engine = self.engine.clone(); + let sequence_name = self + .engine + .read(cx) + .sequence_parameters(sequence_id) + .map(|p| p.name) + .unwrap_or_default(); + self.spawn_modal(cx, move |window, app| { + let content = + app.new(|cx| crate::dialogs::SequencePropertiesContent::new(engine, sequence_id, window, cx)); + let modal = app.new(|cx| { + Modal::new( + modal_ids::SEQUENCE_PROPERTIES, + ModalOptions::new( + format!("{} — {sequence_name}", crate::i18n::tr("seqprops.title")), + 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::SequenceProperties { modal, content } + }); + } + /// Opens the export dialog. fn open_export_dialog(&mut self, cx: &mut Context) { if self.engine.read(cx).current_sequence().is_none() { @@ -2277,6 +2382,42 @@ impl OakApp { } } } + modal_ids::NEW_SEQUENCE => { + if let ModalState::NewSequence { content, .. } = &self.modal { + let content = content.clone(); + if *button == 0 { + // OK: create the sequence; a rejected create + // keeps the dialog open with the error shown. + match content.update(cx, |dialog, cx| dialog.commit(cx)) { + Ok(()) => self.close_modal(cx), + Err(err) => { + content + .update(cx, |dialog, cx| dialog.set_error(Some(err), cx)); + } + } + } else { + self.close_modal(cx); + } + } + } + modal_ids::SEQUENCE_PROPERTIES => { + if let ModalState::SequenceProperties { content, .. } = &self.modal { + let content = content.clone(); + if *button == 0 { + // OK: apply the edits; a rejected update keeps + // the dialog open with the error shown. + match content.update(cx, |dialog, cx| dialog.commit(cx)) { + Ok(()) => self.close_modal(cx), + Err(err) => { + content + .update(cx, |dialog, cx| dialog.set_error(Some(err), cx)); + } + } + } else { + self.close_modal(cx); + } + } + } _ => {} }, ModalEvent::Dismissed { control } => match *control { diff --git a/crates/oak-app/src/dialogs.rs b/crates/oak-app/src/dialogs.rs index 904519d01..417cee1ac 100644 --- a/crates/oak-app/src/dialogs.rs +++ b/crates/oak-app/src/dialogs.rs @@ -26,6 +26,7 @@ //! shell chrome immediately. use crate::oakui::component::controls::SliderModel; +use crate::oakui::component::controls::SliderValue; use crate::oakui::component::controls::ValueKind; use crate::oakui::component::controls::{CheckBox, CheckBoxEvent, CheckState}; use crate::oakui::component::controls::{ComboBox, ComboBoxEvent, ComboBoxOption}; @@ -33,6 +34,7 @@ use crate::oakui::component::controls::{SpinBox, SpinBoxEvent}; use crate::oakui::component::text_input; use gpui::colors::DefaultColors; use gpui::prelude::*; +use gpui::timeline::FrameRate; use gpui::{ div, px, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Keystroke, PathPromptOptions, Render, SharedString, Window, @@ -2760,6 +2762,576 @@ impl Render for AboutContent { } } +// --------------------------------------------------------------------------- +// Sequence: 新建序列 (New Sequence) + 序列属性 (Sequence Properties) +// --------------------------------------------------------------------------- + +/// The frame-rate choices offered for a sequence, as rational pairs in +/// dropdown order (matching [`SEQUENCE_RATE_OPTIONS`]). +const SEQUENCE_RATES: &[(u32, u32)] = &[ + (24000, 1001), // 23.98 + (24, 1), // 24 + (25, 1), // 25 + (30000, 1001), // 29.97 + (30, 1), // 30 + (50, 1), // 50 + (60000, 1001), // 59.94 + (60, 1), // 60 +]; + +/// The frame-rate labels, in the same order as [`SEQUENCE_RATES`]. +const SEQUENCE_RATE_OPTIONS: &[&str] = &["23.98", "24", "25", "29.97", "30", "50", "59.94", "60"]; + +/// The preset formats offered for a sequence (0 = custom, which leaves the +/// width / height / frame-rate fields free). +fn sequence_preset_options() -> Vec { + vec![ + ComboBoxOption::new(0, i18n::tr("seqprops.preset.custom")), + ComboBoxOption::new(1, i18n::tr("seqprops.preset.pal")), + ComboBoxOption::new(2, i18n::tr("seqprops.preset.ntsc")), + ComboBoxOption::new(3, i18n::tr("seqprops.preset.hd_1080_25")), + ComboBoxOption::new(4, i18n::tr("seqprops.preset.hd_1080_30")), + ] +} + +/// The `(width, height, rate-num, rate-den)` of a preset entry, or `None` +/// for the custom entry. +fn sequence_preset_format(index: usize) -> Option<(u32, u32, u32, u32)> { + match index { + 1 => Some((720, 576, 25, 1)), // PAL + 2 => Some((720, 480, 30000, 1001)), // NTSC + 3 => Some((1920, 1080, 25, 1)), // HD 1080p25 + 4 => Some((1920, 1080, 30, 1)), // HD 1080p30 + _ => None, + } +} + +/// The frame-rate choices for the sequence dialogs. +fn sequence_rate_options() -> Vec { + SEQUENCE_RATE_OPTIONS + .iter() + .enumerate() + .map(|(i, label)| ComboBoxOption::new(i, *label)) + .collect() +} + +/// A text field for the sequence name, shaped like the export path field +/// (its own editor so the name can be replaced without retyping). +pub struct TextValue { + editor: Entity, +} + +impl TextValue { + /// The name currently entered. + pub fn value(&self, app: &App) -> SharedString { + self.editor.read(app).as_str().into() + } + + /// Replaces the name shown in the field. + pub fn set_value(&mut self, value: impl Into, cx: &mut Context) { + let value = value.into(); + self.editor.update(cx, |editor, cx| { + editor.emplace(value.as_ref(), cx); + }); + cx.notify(); + } +} + +impl Render for TextValue { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let weak = self.editor.downgrade(); + div() + .rounded_md() + .border_1() + .border_color(colors.border) + .bg(colors.background) + .px_2() + .py_1() + .child(text_input("oak-seq-name", cx).state(weak).accepts_input(true)) + } +} + +/// The initial state of the sequence format fields. +pub struct SequenceFormatSeed { + /// The selected preset index (0 = custom). + pub preset: usize, + /// The width shown in the spin box (presets override it). + pub width: u32, + /// The height shown in the spin box. + pub height: u32, + /// The selected frame-rate index, or `None` for the custom default. + pub rate: Option, + /// Whether the interlaced checkbox is checked. + pub interlaced: bool, +} + +/// The format controls shared by the new-sequence and sequence-properties +/// dialogs: a preset combo that fills the numeric fields, width / height +/// spin boxes, a frame-rate combo and an interlaced checkbox. Picking a +/// preset overwrites the numeric fields; editing any of them snaps the +/// preset back to *custom*. +pub struct SequenceFormatFields { + preset: Entity, + width: Entity, + height: Entity, + rate: Entity, + interlaced: Entity, +} + +impl SequenceFormatFields { + /// Builds the fields and wires the preset / field cross-updates. + pub fn build( + seed: SequenceFormatSeed, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let preset = cx.new(|cx| ComboBox::new(40, sequence_preset_options(), window, cx)); + preset.update(cx, |combo, cx| { + combo.set_selected(Some(seed.preset), cx) + }); + + let width = cx.new(|cx| { + SpinBox::new( + 41, + SliderModel::new( + ValueKind::Integer, + 16.0, + 8192.0, + 2.0, + f64::from(seed.width), + ), + window, + cx, + ) + }); + let height = cx.new(|cx| { + SpinBox::new( + 42, + SliderModel::new( + ValueKind::Integer, + 16.0, + 8192.0, + 2.0, + f64::from(seed.height), + ), + window, + cx, + ) + }); + + let rate = cx.new(|cx| ComboBox::new(43, sequence_rate_options(), window, cx)); + rate.update(cx, |combo, cx| combo.set_selected(seed.rate, cx)); + + let interlaced = cx.new(|cx| { + CheckBox::new( + 44, + if seed.interlaced { + CheckState::Checked + } else { + CheckState::Unchecked + }, + window, + cx, + ) + .with_label(i18n::tr("seqprops.interlaced")) + }); + + // Picking a preset fills the numeric fields. The programmatic + // set_value/set_selected calls below emit no events, so this never + // loops back into itself. + cx.subscribe(&preset, |this, _preset, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { value } = event { + if let Some((w, h, num, den)) = sequence_preset_format(*value) { + this.width.update(cx, |spin, cx| { + spin.set_value(SliderValue::Integer(i64::from(w)), cx) + }); + this.height.update(cx, |spin, cx| { + spin.set_value(SliderValue::Integer(i64::from(h)), cx) + }); + if let Some(index) = SEQUENCE_RATES.iter().position(|r| *r == (num, den)) { + this.rate + .update(cx, |combo, cx| combo.set_selected(Some(index), cx)); + } + cx.notify(); + } + } + }) + .detach(); + + // Editing a dimension or the frame rate reverts to the custom entry. + cx.subscribe(&width, |this, _spin, event: &SpinBoxEvent, cx| { + if let SpinBoxEvent::ValueChanged { .. } = event { + this.preset.update(cx, |combo, cx| { + combo.set_selected(Some(0), cx) + }); + cx.notify(); + } + }) + .detach(); + cx.subscribe(&height, |this, _spin, event: &SpinBoxEvent, cx| { + if let SpinBoxEvent::ValueChanged { .. } = event { + this.preset.update(cx, |combo, cx| { + combo.set_selected(Some(0), cx) + }); + cx.notify(); + } + }) + .detach(); + cx.subscribe(&rate, |this, _combo, event: &ComboBoxEvent, cx| { + if let ComboBoxEvent::Selected { .. } = event { + this.preset.update(cx, |combo, cx| { + combo.set_selected(Some(0), cx) + }); + cx.notify(); + } + }) + .detach(); + + // The interlaced checkbox is request-only: the host accepts the + // toggled state back (the standard checkbox pattern). + cx.subscribe(&interlaced, |_this, check, event: &CheckBoxEvent, cx| { + if let CheckBoxEvent::Toggled { state, .. } = event { + check.update(cx, |check, cx| check.set_state(*state, cx)); + } + }) + .detach(); + + Self { + preset, + width, + height, + rate, + interlaced, + } + } + + /// The video format currently selected in the fields. + pub fn format(&self, cx: &App) -> crate::oakui::engine::VideoFormat { + let width = self.width.read(cx).value().to_f64().max(1.0) as u32; + let height = self.height.read(cx).value().to_f64().max(1.0) as u32; + let (num, den) = self + .rate + .read(cx) + .selected() + .and_then(|i| SEQUENCE_RATES.get(i)) + .copied() + .unwrap_or((25, 1)); + crate::oakui::engine::VideoFormat { + width, + height, + rate: FrameRate::new(num.max(1), den.max(1)), + } + } + + /// Whether the interlaced checkbox is checked. + pub fn interlaced(&self, cx: &App) -> bool { + self.interlaced.read(cx).state() == CheckState::Checked + } + + /// The labeled form rows (preset, width/height, frame rate, interlaced). + pub fn rows(&self, colors: &gpui::colors::Colors) -> gpui::Div { + let size_row = div() + .flex() + .gap_3() + .child(form_row( + colors, + i18n::tr("seqprops.width").into(), + self.width.clone(), + )) + .child(form_row( + colors, + i18n::tr("seqprops.height").into(), + self.height.clone(), + )); + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(form_row( + colors, + i18n::tr("seqprops.preset").into(), + self.preset.clone(), + )) + .child(size_row) + .child(form_row( + colors, + i18n::tr("seqprops.frame_rate").into(), + self.rate.clone(), + )) + .child(form_row( + colors, + i18n::tr("seqprops.interlaced").into(), + self.interlaced.clone(), + )) + } +} + +/// The new-sequence dialog content: the sequence name plus the format +/// fields. The host reads the name / format when the OK button is clicked. +pub struct NewSequenceContent { + engine: Entity, + name: Entity, + format: Entity, + /// The commit error shown under the form (a rejected create keeps the + /// dialog open). + error: Option, +} + +impl NewSequenceContent { + /// Builds the content seeded with the default name and the HD 1080p25 + /// preset. + pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> 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("seqprops.default_name"), cx) + }); + + let format = cx.new(|cx| { + SequenceFormatFields::build( + SequenceFormatSeed { + preset: 3, + width: 1920, + height: 1080, + rate: Some(2), + interlaced: false, + }, + window, + cx, + ) + }); + + Self { + engine, + name, + format, + error: None, + } + } + + /// The name currently entered. + pub fn name(&self, cx: &App) -> SharedString { + self.name.read(cx).value(cx) + } + + /// The format currently selected. + pub fn format(&self, cx: &App) -> crate::oakui::engine::VideoFormat { + self.format.read(cx).format(cx) + } + + /// Whether the interlaced checkbox is checked. + pub fn interlaced(&self, cx: &App) -> bool { + self.format.read(cx).interlaced(cx) + } + + /// Applies the dialog (the C++ `accept()`): creates the sequence with the + /// entered name / format and clears the error row. + pub fn commit(&mut self, cx: &mut Context) -> Result<(), String> { + let name = self.name(cx).to_string(); + let format = self.format(cx); + let interlaced = self.interlaced(cx); + self.engine.update(cx, |engine, cx| { + engine.create_sequence_with_params(name, format, interlaced, cx) + })?; + self.set_error(None, cx); + Ok(()) + } + + /// The error shown under the form after a rejected commit. + pub fn set_error(&mut self, msg: Option, cx: &mut Context) { + self.error = msg; + cx.notify(); + } + + /// The commit error currently shown (`None` while the last commit + /// applied cleanly). + pub fn error(&self) -> Option<&String> { + self.error.as_ref() + } +} + +impl Render for NewSequenceContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let format_rows = self.format.read(cx).rows(&colors); + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(form_row( + &colors, + i18n::tr("seqprops.name").into(), + self.name.clone(), + )) + .child(format_rows) + .child( + if let Some(error) = &self.error { + div() + .debug_selector(|| "seqprops-error".into()) + .text_color(gpui::rgb(0xe5484d)) + .text_xs() + .child(error.clone()) + } else { + div() + }, + ) + } +} + +/// The sequence-properties dialog content: the sequence name plus the +/// format fields, seeded from the sequence's current parameters. +pub struct SequencePropertiesContent { + engine: Entity, + sequence_id: u64, + name: Entity, + format: Entity, + /// The commit error shown under the form. + error: Option, +} + +impl SequencePropertiesContent { + /// Builds the content seeded from the sequence's current parameters (the + /// C++ `SequencePropertiesDialog` initializers); a missing sequence + /// falls back to the HD 1080p25 defaults with a blank name. + pub fn new( + engine: Entity, + sequence_id: u64, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let current = engine.read(cx).sequence_parameters(sequence_id); + + let name = cx.new(|cx| { + let editor = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + TextValue { editor } + }); + let current_name = current.as_ref().map(|p| p.name.as_str()).unwrap_or(""); + name.update(cx, |field, cx| field.set_value(current_name, cx)); + + let seed = match ¤t { + Some(params) => { + let preset = (1..=4) + .find(|i| { + sequence_preset_format(*i) + == Some(( + params.format.width, + params.format.height, + params.format.rate.num, + params.format.rate.den, + )) + }) + .unwrap_or(0); + let rate = SEQUENCE_RATES + .iter() + .position(|r| *r == (params.format.rate.num, params.format.rate.den)); + SequenceFormatSeed { + preset, + width: params.format.width, + height: params.format.height, + rate, + interlaced: params.interlaced, + } + } + None => SequenceFormatSeed { + preset: 0, + width: 1920, + height: 1080, + rate: None, + interlaced: false, + }, + }; + let format = cx.new(|cx| SequenceFormatFields::build(seed, window, cx)); + + Self { + engine, + sequence_id, + name, + format, + error: None, + } + } + + /// The name currently entered. + pub fn name(&self, cx: &App) -> SharedString { + self.name.read(cx).value(cx) + } + + /// The format currently selected. + pub fn format(&self, cx: &App) -> crate::oakui::engine::VideoFormat { + self.format.read(cx).format(cx) + } + + /// Whether the interlaced checkbox is checked. + pub fn interlaced(&self, cx: &App) -> bool { + self.format.read(cx).interlaced(cx) + } + + /// Applies the edits (the C++ `accept()`): updates the sequence's name / + /// format and clears the error row. + pub fn commit(&mut self, cx: &mut Context) -> Result<(), String> { + let name = self.name(cx).to_string(); + let format = self.format(cx); + let interlaced = self.interlaced(cx); + self.engine.update(cx, |engine, cx| { + engine.update_sequence_parameters( + self.sequence_id, + name, + format, + interlaced, + cx, + ) + })?; + self.set_error(None, cx); + Ok(()) + } + + /// The error shown under the form after a rejected commit. + pub fn set_error(&mut self, msg: Option, cx: &mut Context) { + self.error = msg; + cx.notify(); + } + + /// The commit error currently shown (`None` while the last commit + /// applied cleanly). + pub fn error(&self) -> Option<&String> { + self.error.as_ref() + } +} + +impl Render for SequencePropertiesContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let format_rows = self.format.read(cx).rows(&colors); + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .child(form_row( + &colors, + i18n::tr("seqprops.name").into(), + self.name.clone(), + )) + .child(format_rows) + .child( + if let Some(error) = &self.error { + div() + .debug_selector(|| "seqprops-error".into()) + .text_color(gpui::rgb(0xe5484d)) + .text_xs() + .child(error.clone()) + } else { + div() + }, + ) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index 37354811f..ca86d22da 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -82,6 +82,18 @@ impl VideoFormat { } } +/// The editable parameters of a sequence, as the sequence-properties +/// dialog reads and writes them. +#[derive(Debug, Clone, PartialEq)] +pub struct SequenceParameters { + /// The sequence's display name. + pub name: String, + /// The sequence's video format. + pub format: VideoFormat, + /// Whether the sequence's video stream is interlaced. + pub interlaced: bool, +} + /// A project open in the engine. #[derive(Debug, Clone, PartialEq)] pub struct Project { @@ -828,6 +840,59 @@ pub trait AppEngine: let _ = (setting, custom_path, cx); } + // ------------------------------------------------------------------- + // Sequence management (the C++ File > New Sequence / New Folder and + // the project-explorer sequence context menu): creating, querying and + // updating sequences and folders in the open project. Defaults cover + // engines without a project surface. + // ------------------------------------------------------------------- + + /// Whether the project-explorer entry `id` is a sequence node. + fn entry_is_sequence(&self, id: u64) -> bool { + let _ = id; + false + } + + /// The sequence parameters of `id` (the sequence-properties dialog + /// seed), or `None` when the entry is not a sequence. + fn sequence_parameters(&self, id: u64) -> Option { + let _ = id; + None + } + + /// Creates a sequence in the open project with the given parameters + /// and opens it. `Err` keeps the new-sequence dialog open. + fn create_sequence_with_params( + &mut self, + name: String, + format: VideoFormat, + interlaced: bool, + cx: &mut Context, + ) -> Result { + let _ = (name, format, interlaced, cx); + Err("no project open".to_string()) + } + + /// Creates a folder in the open project. `Err` keeps the caller's + /// action from completing. + fn create_folder(&mut self, name: String, cx: &mut Context) -> Result { + let _ = (name, cx); + Err("no project open".to_string()) + } + + /// Applies new name/format/interlaced parameters to the sequence `id`. + fn update_sequence_parameters( + &mut self, + id: u64, + name: String, + format: VideoFormat, + interlaced: bool, + cx: &mut Context, + ) -> Result<(), String> { + let _ = (id, name, format, interlaced, cx); + Err("no project open".to_string()) + } + /// The footage rows the proxy dialog's footage mode lists (every /// footage node in the open project). fn proxy_rows(&self) -> Vec { diff --git a/crates/oak-app/src/oakui/graphops.rs b/crates/oak-app/src/oakui/graphops.rs index 8b80a0fa0..c64189ec8 100644 --- a/crates/oak-app/src/oakui/graphops.rs +++ b/crates/oak-app/src/oakui/graphops.rs @@ -34,12 +34,14 @@ use std::sync::{Arc, Mutex, MutexGuard}; use oak_core::{Rational, TimeRange}; use oak_node::block::ClipBlockBehavior; +use oak_node::folder::FolderBehavior; use oak_node::footage::FootageBehavior; use oak_node::graph::Graph; use oak_node::id::NodeId; use oak_node::project::Project; use oak_node::sequence::SequenceBehavior; use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType}; +use oak_node::value::VideoParams; use oak_timeline::handle::CHandle; use oak_timeline::util::NodeRef; @@ -201,13 +203,58 @@ pub fn footage_ids(p: &Project) -> Vec { /// Create a sequence node named `name` directly in the project's graph /// (unlike the facade's `oakengine_sequence_new`, which kept the sequence /// in a scratch project, the direct-rlib app keeps it in the project so -/// saves and the write-through library cover it). +/// saves and the write-through library cover it). The sequence is attached +/// to the project's root folder so the project explorer shows it. pub fn create_sequence(project: &ProjectRef, name: &str) -> NodeId { + create_sequence_with_params(project, name, None) +} + +/// Create a sequence with an explicit first-video-stream format. `params` +/// `(width, height, rate, interlaced)` overrides the sequence's default +/// video parameters; `None` keeps the defaults. The sequence is attached +/// to the root folder. +pub fn create_sequence_with_params( + project: &ProjectRef, + name: &str, + params: Option<(i32, i32, Rational, bool)>, +) -> NodeId { let mut guard = lock(project); - let (mut core, behavior) = SequenceBehavior::create(); + let (mut core, mut behavior) = SequenceBehavior::create(); core.label = name.to_string(); + if let Some((width, height, rate, interlaced)) = params { + if let Some(seq) = behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + { + if seq.video_params.is_empty() { + seq.video_params.push(VideoParams { + width, + height, + frame_rate: rate, + pixel_format: 4, // f32 + channels: 4, + interlaced, + }); + } else { + let v = &mut seq.video_params[0]; + v.width = width; + v.height = height; + v.frame_rate = rate; + v.interlaced = interlaced; + } + } + } + let root = guard.root; let seq = guard.graph.add_node(core, behavior); drop(guard); + // Mount the sequence under the root folder. Not pushed to the undo + // stack: like the default track layout below, it is part of the + // sequence's creation, not an undoable edit. + oak_task::nodeops::folder_add_child_command( + (project.clone(), root), + (project.clone(), seq), + ) + .redo_now(); // A new sequence starts with the default 2 video + 2 audio track // layout (user-mandated NLE default: V1, V2 on top, A1, A2 below). // Driven directly through the add-track commands' redo — NOT pushed @@ -222,6 +269,114 @@ pub fn create_sequence(project: &ProjectRef, name: &str) -> NodeId { seq } +/// Create a folder under the project's root (the C++ File > New Folder +/// action). The "New Folder" command is pushed to the undo stack — an +/// explicit user action, unlike sequence creation. +pub fn create_folder(project: &ProjectRef, name: &str) -> Result { + let root = { + let guard = lock(project); + if !guard.root.valid() { + return Err("the project has no root folder".to_string()); + } + guard.root + }; + let (core, behavior) = oak_node::folder::create(name); + let id = { + let mut guard = lock(project); + guard.graph.add_node(core, behavior) + }; + let cmd = oak_task::nodeops::folder_add_child_command( + (project.clone(), root), + (project.clone(), id), + ); + oak_undo::global::push(cmd, "New Folder").map_err(|e| e.to_string())?; + Ok(id) +} + +/// Every folder node in the graph, in arena order. +pub fn folder_ids(p: &Project) -> Vec { + p.graph + .node_ids() + .into_iter() + .filter(|&id| is_folder(&p.graph, id)) + .collect() +} + +/// Attach every orphaned sequence to the root folder with a non-undoable +/// command. Projects saved before sequences mounted under the root load +/// with their sequences free-floating; the open path runs this once to +/// migrate them. +pub fn ensure_sequences_mounted(project: &ProjectRef) { + let (root, orphans) = { + let guard = lock(project); + let root = guard.root; + let children = guard + .graph + .get(root) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + .map(|f| f.children.clone()) + .unwrap_or_default(); + let orphans = sequence_ids(&guard) + .into_iter() + .filter(|&id| !children.contains(&id)) + .collect::>(); + (root, orphans) + }; + for id in orphans { + oak_task::nodeops::folder_add_child_command( + (project.clone(), root), + (project.clone(), id), + ) + .redo_now(); + } +} + +/// Apply new display name and video parameters to a sequence (the +/// sequence-properties dialog's commit; mirrors the CLI's +/// `set_sequence_video_params`, plus the label). Not undoable, like the +/// CLI setter. +pub fn set_sequence_parameters( + project: &ProjectRef, + seq: NodeId, + name: &str, + width: i32, + height: i32, + rate: Rational, + interlaced: bool, +) -> Result<(), String> { + let mut guard = lock(project); + let entry = guard + .graph + .get_mut(seq) + .ok_or_else(|| "sequence no longer exists".to_string())?; + entry.core.label = name.to_string(); + let Some(s) = entry + .behavior + .as_any_mut() + .and_then(|a| a.downcast_mut::()) + else { + return Err("entry is not a sequence".to_string()); + }; + if s.video_params.is_empty() { + s.video_params.push(VideoParams { + width, + height, + frame_rate: rate, + pixel_format: 4, // f32 + channels: 4, + interlaced, + }); + } else { + let v = &mut s.video_params[0]; + v.width = width; + v.height = height; + v.frame_rate = rate; + v.interlaced = interlaced; + } + Ok(()) +} + /// The label of a node (`NodeCore::label`). pub fn node_label(g: &Graph, id: NodeId) -> String { g.get(id).map(|e| e.core.label.clone()).unwrap_or_default() @@ -2706,4 +2861,144 @@ mod undo_cycle_ops_tests { let _ = std::fs::remove_file(&media); } + + // ---- sequence folder mounting ------------------------------------------ + + /// The root folder's direct children. + fn root_children(p: &ProjectRef) -> Vec { + let guard = lock(p); + guard + .graph + .get(guard.root) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + .map(|f| f.children.clone()) + .unwrap_or_default() + } + + /// Folders and sequences created through the module helpers mount under + /// the root folder, so the project explorer lists them. + #[test] + fn create_folder_and_sequence_mount_under_root() { + let _g = test_lock(); + oak_undo::global::clear().unwrap(); + let project = create_project(); + let folder = create_folder(&project, "Folder 1").expect("create folder"); + let seq = create_sequence_with_params( + &project, + "Seq 4K", + Some((3840, 2160, Rational::new(24000, 1001), true)), + ); + let children = root_children(&project); + assert!(children.contains(&folder), "the folder mounts under root"); + assert!(children.contains(&seq), "the sequence mounts under root"); + assert_eq!( + folder_ids(&lock(&project)).len(), + 2, + "the root folder itself plus the new folder" + ); + oak_undo::global::clear().unwrap(); + } + + /// `create_sequence_with_params` writes the format into the sequence's + /// first video stream, including the new `interlaced` flag. + #[test] + fn create_sequence_with_params_sets_first_stream_format() { + let _g = test_lock(); + let project = create_project(); + let seq = create_sequence_with_params( + &project, + "Interlaced", + Some((1280, 720, Rational::new(30000, 1001), true)), + ); + let guard = lock(&project); + let (width, height, rate) = sequence_video_params(&guard.graph, seq).expect("video params"); + assert_eq!((width, height), (1280, 720)); + assert_eq!((rate.numerator(), rate.denominator()), (30000, 1001)); + let interlaced = guard + .graph + .get(seq) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + .expect("sequence behavior") + .video_params + .first() + .expect("a video stream") + .interlaced; + assert!(interlaced, "the interlaced flag lands in the first stream"); + oak_undo::global::clear().unwrap(); + } + + /// Sequences saved before they mounted under the root load free-floating; + /// `ensure_sequences_mounted` reattaches them (the open path's migration). + #[test] + fn ensure_sequences_mounted_reattaches_orphans() { + let _g = test_lock(); + oak_undo::global::clear().unwrap(); + let project = create_project(); + let seq = create_sequence(&project, "Legacy"); + // Detach the sequence from the root: a legacy project loaded without + // the mount migration. + { + let mut guard = lock(&project); + let root = guard.root; + let folder = guard + .graph + .get_mut(root) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + .expect("root folder behavior"); + folder.children.retain(|&c| c != seq); + } + assert!( + !root_children(&project).contains(&seq), + "the sequence floats free after the detach" + ); + ensure_sequences_mounted(&project); + assert!( + root_children(&project).contains(&seq), + "the orphan sequence reattaches under root" + ); + oak_undo::global::clear().unwrap(); + } + + /// `set_sequence_parameters` rewrites the display name and the first video + /// stream's format in one call (the properties dialog's commit path). + #[test] + fn set_sequence_parameters_updates_name_and_format() { + let _g = test_lock(); + let project = create_project(); + let seq = create_sequence_with_params( + &project, + "Before", + Some((1280, 720, Rational::new(25, 1), false)), + ); + set_sequence_parameters( + &project, + seq, + "After", + 1920, + 1080, + Rational::new(30000, 1001), + true, + ) + .expect("update parameters"); + let guard = lock(&project); + assert_eq!(node_label(&guard.graph, seq), "After"); + let (width, height, rate) = sequence_video_params(&guard.graph, seq).expect("video params"); + assert_eq!((width, height), (1920, 1080)); + assert_eq!((rate.numerator(), rate.denominator()), (30000, 1001)); + let interlaced = guard + .graph + .get(seq) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + .expect("sequence behavior") + .video_params + .first() + .expect("a video stream") + .interlaced; + assert!(interlaced, "the interlaced flag updates too"); + oak_undo::global::clear().unwrap(); + } } diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 3ec63ead3..96b7dd418 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -87,7 +87,7 @@ use oak_timeline::util::NodeRef; use super::engine::{ AppEngine, EngineGateway, ExportSession, LibraryProject, Monitor, MulticamState, Project, - ScopeData, Sequence, VideoFormat, + ScopeData, Sequence, SequenceParameters, VideoFormat, }; use super::frames::{bgra_bytes_to_render_image, f32_rgba_to_bgra_image, synthetic_frame_samples}; use super::graphops::{self, ProjectRef}; @@ -2673,6 +2673,10 @@ impl RealEngine { // probe cascade). graphops::reprobe_unprobed_footage(&project); + // Sequences saved before they mounted under the root folder load + // free-floating; reattach them so the project explorer lists them. + graphops::ensure_sequences_mounted(&project); + // The sequence: the project's first, or a blank default. let seq = first_sequence .unwrap_or_else(|| graphops::create_sequence(&project, "Sequence 1")); @@ -4353,7 +4357,7 @@ impl AppEngine for RealEngine { time: Frame, cx: &mut Context, ) { - let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else { + let Some(project) = self.project.clone() else { return; }; // The explorer's entry id IS the footage node's stable identity @@ -4375,6 +4379,18 @@ impl AppEngine for RealEngine { graphops::footage_duration_seconds(&guard.graph, footage), ) }; + // Empty timeline: dropping footage auto-creates a sequence sized to + // the footage's first video stream (the NLE convention); pure-audio + // footage falls back to the default sequence format. + let seq = match self.sequence { + Some(seq) => seq, + None => { + let Some(seq) = self.create_sequence_for_drop(&project, footage, cx) else { + return; + }; + seq + } + }; // Media type from the probed stream list (the probe is real since // the import fills it); fall back to the extension only when no // streams were recorded (legacy projects loaded without a probe). @@ -4644,6 +4660,120 @@ impl AppEngine for RealEngine { cx.notify(); } + fn entry_is_sequence(&self, id: u64) -> bool { + let Some(project) = self.project_ref() else { + return false; + }; + let Some(node) = graphops::id_of(id) else { + return false; + }; + let guard = graphops::lock(project); + graphops::sequence_behavior(&guard.graph, node).is_some() + } + + fn sequence_parameters(&self, id: u64) -> Option { + let project = self.project_ref()?; + let node = graphops::id_of(id)?; + let guard = graphops::lock(project); + let (_, _, rate) = graphops::sequence_video_params(&guard.graph, node)?; + let params = guard + .graph + .get(node) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::())? + .video_params + .first() + .copied(); + let (width, height, interlaced) = match params { + Some(v) => (v.width.max(1) as u32, v.height.max(1) as u32, v.interlaced), + None => return None, + }; + Some(SequenceParameters { + name: graphops::node_label(&guard.graph, node), + format: VideoFormat { + width, + height, + rate: FrameRate::new(rate.numerator().max(1) as u32, rate.denominator().max(1) as u32), + }, + interlaced, + }) + } + + fn create_sequence_with_params( + &mut self, + name: String, + format: VideoFormat, + interlaced: bool, + cx: &mut Context, + ) -> Result { + let (Some(project), _) = (self.project.clone(), self.sequence) else { + return Err(crate::i18n::tr("seqprops.error.no_project").to_string()); + }; + let name = if name.trim().is_empty() { + "Sequence 1".to_string() + } else { + name.trim().to_string() + }; + let seq = graphops::create_sequence_with_params( + &project, + &name, + Some(( + format.width as i32, + format.height as i32, + oak_core::Rational::new(i64::from(format.rate.num), i64::from(format.rate.den)), + interlaced, + )), + ); + self.sequence = Some(seq); + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.push_graph_snapshot(); + cx.notify(); + Ok(seq.identity()) + } + + fn create_folder(&mut self, name: String, cx: &mut Context) -> Result { + let Some(project) = self.project.clone() else { + return Err(crate::i18n::tr("seqprops.error.no_project").to_string()); + }; + let name = if name.trim().is_empty() { + let count = graphops::folder_ids(&graphops::lock(&project)).len(); + format!("Folder {}", count + 1) + } else { + name.trim().to_string() + }; + let id = graphops::create_folder(&project, &name)?; + self.apply_edit(Ok(()), "new folder", cx); + Ok(id.identity()) + } + + fn update_sequence_parameters( + &mut self, + id: u64, + name: String, + format: VideoFormat, + interlaced: bool, + cx: &mut Context, + ) -> Result<(), String> { + let Some(project) = self.project.clone() else { + return Err(crate::i18n::tr("seqprops.error.no_project").to_string()); + }; + let Some(node) = graphops::id_of(id) else { + return Err(crate::i18n::tr("seqprops.error.no_sequence").to_string()); + }; + graphops::set_sequence_parameters( + &project, + node, + name.trim(), + format.width as i32, + format.height as i32, + oak_core::Rational::new(i64::from(format.rate.num), i64::from(format.rate.den)), + interlaced, + )?; + self.apply_edit(Ok(()), "set sequence parameters", cx); + Ok(()) + } + fn proxy_rows(&self) -> Vec { let Some(project) = self.project.as_ref() else { return Vec::new(); @@ -5262,6 +5392,55 @@ impl ProjectFormat { } impl RealEngine { + /// Auto-creates a sequence for a footage drop onto an empty timeline (see + /// `drop_footage`): the format mirrors the footage's first video stream; + /// pure-audio footage uses the default sequence format. Returns the new + /// sequence's node, or `None` when creation failed (errors are logged, not + /// propagated — the drop itself is a no-op in that case). + fn create_sequence_for_drop( + &mut self, + project: &ProjectRef, + footage: NodeId, + cx: &mut Context, + ) -> Option { + let params = { + let guard = graphops::lock(project); + graphops::footage_behavior(&guard.graph, footage).and_then(|f| f.video_params(0)) + }; + match params { + Some(vp) => match self.create_sequence_with_params( + "Sequence 1".to_string(), + VideoFormat { + width: vp.width.max(1) as u32, + height: vp.height.max(1) as u32, + rate: FrameRate::new( + vp.frame_rate.numerator().max(1) as u32, + vp.frame_rate.denominator().max(1) as u32, + ), + }, + vp.interlaced, + cx, + ) { + // `create_sequence_with_params` sets `self.sequence` and + // refreshes; nothing else to do here. + Ok(_) => self.sequence, + Err(err) => { + println!("[real engine] drop footage: auto-create sequence failed: {err}"); + None + } + }, + None => { + let seq = graphops::create_sequence(project, "Sequence 1"); + self.sequence = Some(seq); + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.push_graph_snapshot(); + cx.notify(); + Some(seq) + } + } + } + /// Opens a `.ove` / `.ovexml` project through the module serializer. fn open_ove(&mut self, path: &PathBuf, cx: &mut Context) -> Result<(), String> { let project = graphops::load_ove(path) @@ -7885,4 +8064,199 @@ mod tests { st.insert(10, audio_chunk(10).1); assert_eq!(st.buffered.len(), 1, "duplicates dropped"); } + + // ---- sequence management (engine facade) ------------------------------ + + /// The engine's create-folder / create-sequence-with-params commands + /// mount their nodes under the root folder (so the project explorer + /// lists them) and the parameters round-trip through + /// `sequence_parameters`. + #[gpui::test] + async fn engine_creates_folder_and_sequence_with_params(cx: &mut gpui::TestAppContext) { + let _media = media_lock(); + let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); + cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); + + let folder = cx.update(|app| { + engine + .update(app, |engine, cx| engine.create_folder("Folder 1".to_string(), cx)) + .expect("create folder") + }); + let seq = cx.update(|app| { + engine + .update(app, |engine, cx| { + engine + .create_sequence_with_params( + "Seq 4K".to_string(), + VideoFormat { + width: 3840, + height: 2160, + rate: FrameRate::new(24000, 1001), + }, + true, + cx, + ) + .expect("create sequence") + }) + }); + + // Both mount under the root folder, like the explorer expects. + let project = cx.read(|app| engine.read(app).project.clone().expect("project")); + let (folder_node, seq_node) = ( + graphops::id_of(folder).expect("folder node"), + graphops::id_of(seq).expect("sequence node"), + ); + let children = { + let guard = graphops::lock(&project); + guard + .graph + .get(guard.root) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + .map(|f| f.children.clone()) + .unwrap_or_default() + }; + assert!(children.contains(&folder_node), "the folder mounts under root"); + assert!(children.contains(&seq_node), "the sequence mounts under root"); + + let is_seq = cx.read(|app| engine.read(app).entry_is_sequence(seq)); + assert!(is_seq, "the created entry is a sequence"); + let is_folder_seq = cx.read(|app| engine.read(app).entry_is_sequence(folder)); + assert!(!is_folder_seq, "a folder is not a sequence"); + + // The parameters round-trip (name, size, rate, interlace). + let params = cx + .read(|app| engine.read(app).sequence_parameters(seq)) + .expect("sequence parameters"); + assert_eq!(params.name, "Seq 4K"); + assert_eq!((params.format.width, params.format.height), (3840, 2160)); + assert_eq!((params.format.rate.num, params.format.rate.den), (24000, 1001)); + assert!(params.interlaced, "the interlaced flag round-trips"); + } + + /// `update_sequence_parameters` rewrites the name, the format and the + /// interlace flag in one call; `sequence_parameters` reads the new + /// state back (the properties dialog's commit path). + #[gpui::test] + async fn engine_update_sequence_parameters_round_trips(cx: &mut gpui::TestAppContext) { + let _media = media_lock(); + let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); + cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); + + let seq = cx.update(|app| { + engine + .update(app, |engine, cx| { + engine + .create_sequence_with_params( + "Before".to_string(), + VideoFormat { + width: 1280, + height: 720, + rate: FrameRate::new(25, 1), + }, + false, + cx, + ) + .expect("create sequence") + }) + }); + cx.update(|app| { + engine + .update(app, |engine, cx| { + engine + .update_sequence_parameters( + seq, + "After".to_string(), + VideoFormat { + width: 1920, + height: 1080, + rate: FrameRate::new(30000, 1001), + }, + true, + cx, + ) + .expect("update parameters") + }) + }); + + let params = cx + .read(|app| engine.read(app).sequence_parameters(seq)) + .expect("sequence parameters"); + assert_eq!(params.name, "After"); + assert_eq!((params.format.width, params.format.height), (1920, 1080)); + assert_eq!((params.format.rate.num, params.format.rate.den), (30000, 1001)); + assert!(params.interlaced, "the interlace flag updates too"); + } + + /// Empty timeline + footage drop = the NLE auto-create path: a new + /// sequence sized to the footage's first video stream appears and the + /// clip lands on its default tracks. + #[gpui::test] + async fn drop_footage_on_empty_timeline_auto_creates_sequence(cx: &mut gpui::TestAppContext) { + let _media = media_lock(); + let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); + cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); + + let media = std::env::temp_dir().join(format!("oak_seq_auto_{}.mp4", std::process::id())); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate"); + cx.update(|app| { + engine + .update(app, |engine, cx| engine.import_footage(media.clone(), cx)) + .expect("import") + }); + let name = media.file_name().unwrap().to_string_lossy().into_owned(); + let entry = cx + .read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }) + .expect("imported footage is listed"); + + // A blank timeline: no sequence open yet. + cx.update(|app| engine.update(app, |engine, _cx| engine.sequence = None)); + + cx.update(|app| { + engine.update(app, |engine, cx| { + engine.drop_footage(entry.id, TrackKind::Video, 0, Frame(0), cx) + }) + }); + + let seq = cx + .read(|app| engine.read(app).sequence.expect("a sequence was auto-created")) + .identity(); + let params = cx + .read(|app| engine.read(app).sequence_parameters(seq)) + .expect("sequence parameters"); + assert_eq!(params.name, "Sequence 1"); + + // The auto-created sequence sizes to the footage's first video stream + // (read the expected values from the probe, not hard-coded). + let (width, height, rate_num, rate_den, interlaced) = { + let project = cx.read(|app| engine.read(app).project.clone().expect("project")); + let guard = graphops::lock(&project); + let footage = graphops::id_of(entry.id).expect("footage node"); + let vp = graphops::footage_behavior(&guard.graph, footage) + .and_then(|f| f.video_params(0)) + .expect("a video stream"); + ( + vp.width.max(1) as u32, + vp.height.max(1) as u32, + vp.frame_rate.numerator().max(1) as u32, + vp.frame_rate.denominator().max(1) as u32, + vp.interlaced, + ) + }; + assert_eq!((params.format.width, params.format.height), (width, height)); + assert_eq!((params.format.rate.num, params.format.rate.den), (rate_num, rate_den)); + assert_eq!(params.interlaced, interlaced); + + // The drop placed a clip on the auto-created sequence's timeline. + let placed = cx.read(|app| engine.read(app).tracks.iter().any(|t| !t.clips.is_empty())); + assert!(placed, "the footage landed on the auto-created timeline"); + + let _ = std::fs::remove_file(&media); + } } diff --git a/crates/oak-app/src/panels/project_explorer.rs b/crates/oak-app/src/panels/project_explorer.rs index 5d0cd4ccd..f78d60935 100644 --- a/crates/oak-app/src/panels/project_explorer.rs +++ b/crates/oak-app/src/panels/project_explorer.rs @@ -151,9 +151,19 @@ impl ProjectExplorerPanel { }) .detach(); } - LOCAL_RENAME | LOCAL_DELETE | LOCAL_PROPERTIES | LOCAL_OPEN_IN_NEW_TAB => { + LOCAL_RENAME | LOCAL_DELETE | LOCAL_OPEN_IN_NEW_TAB => { println!("[project explorer] menu action {item} (not implemented yet)"); } + LOCAL_PROPERTIES => { + let Some(id) = self.context_entry else { + return; + }; + if self.engine.read(cx).entry_is_sequence(id) { + cx.emit(SequencePropertiesRequested(id)); + } else { + println!("[project explorer] properties for non-sequence entry {id} (not implemented yet)"); + } + } LOCAL_PROXY_GENERATE | LOCAL_PROXY_USE | LOCAL_PROXY_REVEAL | LOCAL_PROXY_DELETE => { let Some(id) = self.context_entry else { return; @@ -243,6 +253,13 @@ impl EventEmitter for ProjectExplorerPanel {} impl EventEmitter for ProjectExplorerPanel {} +/// The project explorer asked the shell to open the sequence properties +/// dialog for the given sequence entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SequencePropertiesRequested(pub u64); + +impl EventEmitter for ProjectExplorerPanel {} + impl DockPanel for ProjectExplorerPanel { fn panel_id(&self) -> gpui::dock::PanelId { PROJECT diff --git a/crates/oak-cli/src/engine.rs b/crates/oak-cli/src/engine.rs index cb2698579..a4022fcb3 100644 --- a/crates/oak-cli/src/engine.rs +++ b/crates/oak-cli/src/engine.rs @@ -255,6 +255,7 @@ pub fn set_sequence_video_params( frame_rate: Rational::new(i64::from(fr_num), i64::from(fr_den)), pixel_format: 4, // f32 channels: 4, + interlaced: false, }); } else { let v = &mut s.video_params[0]; diff --git a/crates/oak-node/src/footage.rs b/crates/oak-node/src/footage.rs index c7abf87c7..6b2971f83 100644 --- a/crates/oak-node/src/footage.rs +++ b/crates/oak-node/src/footage.rs @@ -438,6 +438,10 @@ impl NodeBehavior for FootageBehavior { writer.attribute("framerate", &v.frame_rate.to_display_string()); writer.attribute("pixelformat", &v.pixel_format.to_string()); writer.attribute("channels", &v.channels.to_string()); + writer.attribute( + "interlaced", + if v.interlaced { "1" } else { "0" }, + ); writer.end_element(); // video } if let Some(a) = s.audio { @@ -586,6 +590,10 @@ impl NodeBehavior for FootageBehavior { .attribute("channels") .and_then(|v| v.parse().ok()) .unwrap_or(0), + interlaced: reader + .attribute("interlaced") + .map(|v| v == "1") + .unwrap_or(false), }); // Consume the (self-closing) element. let _ = reader.read_element_text(); @@ -673,6 +681,7 @@ fn streams_from_description( frame_rate: oak_core::Rational::new(num as i64, den as i64), pixel_format: vp.format().code(), channels: vp.channel_count(), + interlaced: false, }), audio: None, duration: stream_duration_seconds(vp.duration(), vp.time_base()), diff --git a/crates/oak-node/src/sequence.rs b/crates/oak-node/src/sequence.rs index e3a358cc9..219f0120f 100644 --- a/crates/oak-node/src/sequence.rs +++ b/crates/oak-node/src/sequence.rs @@ -138,6 +138,7 @@ impl SequenceBehavior { frame_rate: oak_core::Rational::new(fps_num as i64, fps_den as i64), pixel_format: 4, // f32 channels: 4, + interlaced: false, }]; self.audio_params = vec![AudioParams { sample_rate, diff --git a/crates/oak-node/src/value.rs b/crates/oak-node/src/value.rs index 3c4179c79..c6a1220ba 100644 --- a/crates/oak-node/src/value.rs +++ b/crates/oak-node/src/value.rs @@ -580,6 +580,8 @@ pub struct VideoParams { pub pixel_format: i32, /// Channel count. pub channels: i32, + /// Whether the stream is interlaced. + pub interlaced: bool, } /// Audio parameters (plain data). diff --git a/crates/oak-node/tests/serializer_test.rs b/crates/oak-node/tests/serializer_test.rs index 5f2f68711..e6b55f542 100644 --- a/crates/oak-node/tests/serializer_test.rs +++ b/crates/oak-node/tests/serializer_test.rs @@ -270,6 +270,7 @@ fn build_full_project() -> std::sync::Arc oak_node::value::VideoParams frame_rate: Rational::new(v.frame_rate().0 as i64, v.frame_rate().1 as i64), pixel_format: v.format().code(), channels: v.channel_count(), + interlaced: false, } }