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:
+142
-1
@@ -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 {
|
||||
|
||||
@@ -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 ¤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<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::*;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user