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
+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::*;