app: sequence management -- new-sequence dialog, folders, sequences in the project bin

- File > New > Sequence opens a real dialog (name, PAL/NTSC/HD presets,
  width/height/frame rate, progressive/interlaced); VideoParams gains an
  interlaced flag
- File > New > Folder creates a folder in the project root
- sequences are mounted under the root folder so they appear in the
  project explorer (including the auto-created Sequence 1 and orphans
  from older projects); right-click > Sequence Properties edits the
  parameters afterwards
- dropping footage onto an empty, sequence-less timeline auto-creates a
  sequence from the footage's first video stream
This commit is contained in:
2026-08-27 17:38:30 +08:00
parent d65b84a6ca
commit bad2f9552e
21 changed files with 1614 additions and 6 deletions
+16
View File
@@ -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."
+16
View File
@@ -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."
+16
View File
@@ -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."
+16
View File
@@ -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."
+16
View File
@@ -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 に心より感謝します。"
+16
View File
@@ -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."
+16
View File
@@ -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, за щедрую поддержку в распространении этого проекта в первые дни."
+16
View File
@@ -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": "PAL720×57625 fps"
"seqprops.preset.ntsc": "NTSC720×48029.97 fps"
"seqprops.preset.hd_1080_25": "HD 1080p251920×108025 fps"
"seqprops.preset.hd_1080_30": "HD 1080p301920×108030 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,感谢他在项目早期为传播本项目所给予的慷慨支持。"
+142 -1
View File
@@ -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<E: AppEngine> {
},
/// The about dialog (Help > About Oak…; static content).
About { modal: Entity<Modal> },
/// The new-sequence dialog (File > New > Sequence…).
NewSequence {
modal: Entity<Modal>,
content: Entity<crate::dialogs::NewSequenceContent<E>>,
},
/// The sequence properties dialog (project-explorer context menu >
/// Sequence Properties).
SequenceProperties {
modal: Entity<Modal>,
content: Entity<crate::dialogs::SequencePropertiesContent<E>>,
},
}
/// A running export: the session the tick loop drains for progress.
@@ -212,7 +228,9 @@ impl<E: AppEngine> ModalState<E> {
| 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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
}
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<E: AppEngine> OakApp<E> {
});
}
/// 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<Self>) {
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<Self>) {
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<Self>) {
if self.engine.read(cx).current_sequence().is_none() {
@@ -2277,6 +2382,42 @@ impl<E: AppEngine> OakApp<E> {
}
}
}
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 {
+572
View File
@@ -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<ComboBoxOption> {
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<ComboBoxOption> {
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<EditableTextState>,
}
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<SharedString>, cx: &mut Context<Self>) {
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<Self>) -> 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<usize>,
/// 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<ComboBox>,
width: Entity<SpinBox>,
height: Entity<SpinBox>,
rate: Entity<ComboBox>,
interlaced: Entity<CheckBox>,
}
impl SequenceFormatFields {
/// Builds the fields and wires the preset / field cross-updates.
pub fn build(
seed: SequenceFormatSeed,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<E: crate::oakui::engine::AppEngine> {
engine: Entity<E>,
name: Entity<TextValue>,
format: Entity<SequenceFormatFields>,
/// The commit error shown under the form (a rejected create keeps the
/// dialog open).
error: Option<String>,
}
impl<E: crate::oakui::engine::AppEngine> NewSequenceContent<E> {
/// Builds the content seeded with the default name and the HD 1080p25
/// preset.
pub fn new(engine: Entity<E>, 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("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<Self>) -> 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<String>, cx: &mut Context<Self>) {
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<E: crate::oakui::engine::AppEngine> Render for NewSequenceContent<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<E: crate::oakui::engine::AppEngine> {
engine: Entity<E>,
sequence_id: u64,
name: Entity<TextValue>,
format: Entity<SequenceFormatFields>,
/// The commit error shown under the form.
error: Option<String>,
}
impl<E: crate::oakui::engine::AppEngine> SequencePropertiesContent<E> {
/// 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<E>,
sequence_id: u64,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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 &current {
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<Self>) -> 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<String>, cx: &mut Context<Self>) {
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<E: crate::oakui::engine::AppEngine> Render for SequencePropertiesContent<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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::*;
+65
View File
@@ -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<SequenceParameters> {
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<Self>,
) -> Result<u64, String> {
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<Self>) -> Result<u64, String> {
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<Self>,
) -> 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<ProxyFootageRow> {
+297 -2
View File
@@ -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<NodeId> {
/// 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::<SequenceBehavior>())
{
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<NodeId, String> {
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<NodeId> {
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::<FolderBehavior>())
.map(|f| f.children.clone())
.unwrap_or_default();
let orphans = sequence_ids(&guard)
.into_iter()
.filter(|&id| !children.contains(&id))
.collect::<Vec<_>>();
(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::<SequenceBehavior>())
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<NodeId> {
let guard = lock(p);
guard
.graph
.get(guard.root)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<FolderBehavior>())
.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::<SequenceBehavior>())
.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::<FolderBehavior>())
.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::<SequenceBehavior>())
.expect("sequence behavior")
.video_params
.first()
.expect("a video stream")
.interlaced;
assert!(interlaced, "the interlaced flag updates too");
oak_undo::global::clear().unwrap();
}
}
+376 -2
View File
@@ -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<Self>,
) {
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<SequenceParameters> {
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::<oak_node::sequence::SequenceBehavior>())?
.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<Self>,
) -> Result<u64, String> {
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<Self>) -> Result<u64, String> {
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<Self>,
) -> 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<super::engine::ProxyFootageRow> {
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<Self>,
) -> Option<NodeId> {
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<Self>) -> 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::<oak_node::folder::FolderBehavior>())
.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);
}
}
+18 -1
View File
@@ -151,9 +151,19 @@ impl<E: AppEngine> ProjectExplorerPanel<E> {
})
.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<E: AppEngine> EventEmitter<PanelEvent> for ProjectExplorerPanel<E> {}
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for ProjectExplorerPanel<E> {}
/// 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<E: AppEngine> EventEmitter<SequencePropertiesRequested> for ProjectExplorerPanel<E> {}
impl<E: AppEngine> DockPanel for ProjectExplorerPanel<E> {
fn panel_id(&self) -> gpui::dock::PanelId {
PROJECT
+1
View File
@@ -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];
+9
View File
@@ -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()),
+1
View File
@@ -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,
+2
View File
@@ -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).
+1
View File
@@ -270,6 +270,7 @@ fn build_full_project() -> std::sync::Arc<std::sync::Mutex<oak_node::project::Pr
frame_rate: Rational::new(25, 1),
pixel_format: 4,
channels: 4,
interlaced: false,
}),
audio: None,
duration: Rational::new(217600, 12800),
+1
View File
@@ -348,6 +348,7 @@ fn footage_probe() {
frame_rate: Rational::new(30, 1),
pixel_format: 4,
channels: 4,
interlaced: false,
}),
audio: None,
duration: Rational::new(600, 1),
+1
View File
@@ -316,6 +316,7 @@ fn node_video_from_common(v: &CommonVideoParams) -> 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,
}
}