Merge pull request #117 from justjohn12345/pr/add-tracks-dialog

Sequence: Add Tracks… opens Premiere's dialog (amount, placement, track type)
This commit is contained in:
Brandon Thomas 2026-10-07 07:08:21 -04:00 committed by GitHub
commit b4caf39f5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 550 additions and 19 deletions

View File

@ -1374,22 +1374,15 @@ fn build() -> Vec<CommandSpec> {
s.state.linked_selection = bool_p(p, "on").unwrap_or(!s.state.linked_selection);
Ok(json!({"linkedSelection": s.state.linked_selection}))
}),
cmd!("sequence.addTracks", "Add Tracks…", ["Sequence"], None, r#"{"video":n=1,"audio":n=0}"#, has_seq, |s, p| {
let nv = u64_p(p, "video").unwrap_or(1) as usize;
let na = u64_p(p, "audio").unwrap_or(0) as usize;
s.edit_sequence("Add Tracks", |q, ctx, _| {
for _ in 0..nv {
let n = q.video_tracks.len() + 1;
q.video_tracks.push(filmcraft_project::Track::new(TrackId(ctx.alloc()), TrackKind::Video, format!("Video {n}")));
}
for _ in 0..na {
let n = q.audio_tracks.len() + 1;
q.audio_tracks.push(filmcraft_project::Track::new(TrackId(ctx.alloc()), TrackKind::Audio, format!("Audio {n}")));
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"sequence.addTracks",
"Add Tracks…",
["Sequence"],
None,
r#"{"video":n=1,"audio":n=0,"submix":n=0,"videoAfter":"first"|"V2"|n?,"audioAfter":"first"|"A2"|n?,"submixAfter":"first"|"S1"|n?,"audioType":"standard|5.1|adaptive|mono"?,"submixType":"stereo|5.1|adaptive|mono"?}"#,
has_seq,
crate::sequence_tools::add_tracks
),
cmd!("sequence.deleteTrack", "Delete Track", [], None, r#"{"track":"V3"|id}"#, has_seq, |s, p| {
let tr = track_p(s, p, "track", "sequence.deleteTrack")?.ok_or_else(|| bad("sequence.deleteTrack", "need `track`"))?;
s.edit_sequence("Delete Track", |q, _, _| {

View File

@ -27,7 +27,7 @@
//! takes its own range and lands offset by the difference of the In points (J- and L-cuts).
use filmcraft_edit as edit;
use filmcraft_project::{ClipId, ItemId, Label, Marker, MarkerId, MarkerKind, SplitMarks, TrackId, TrackItem, TrackKind};
use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, MarkerKind, SplitMarks, TrackId, TrackItem, TrackKind};
use filmcraft_time::{Tick, TimeRange};
use serde_json::{Value, json};
@ -448,6 +448,114 @@ fn make_subsequence(s: &mut Session, p: &Value) -> Result<Value> {
/// Sequence ▸ Delete Tracks…: delete all empty video / audio tracks (`"empty"`) or one track per
/// kind. A sequence always keeps at least one video and one audio track.
/// The most tracks one `sequence.addTracks` adds of a kind, and the most a sequence may then
/// have of it. (Amounts come from dialogs, scripts and the control channel: never trusted.)
const MAX_ADDED_TRACKS: u64 = 99;
const MAX_TRACKS: usize = 999;
/// Where `sequence.addTracks` puts new tracks among the `len` tracks of their kind: the number of
/// tracks before them. `key` holds `"first"` (Before First Track), a track of that kind by name
/// (`"V2"`: after Video 2; `letter` is V, A or S) or a number (after that many tracks); without it
/// they go after the last track.
fn placement(p: &Value, key: &str, letter: char, len: usize) -> Result<usize> {
let cmd = "sequence.addTracks";
let after = match p.get(key) {
None | Some(Value::Null) => return Ok(len),
Some(v) => match (v.as_u64(), v.as_str()) {
(Some(n), _) => usize::try_from(n).ok(),
(None, Some(x)) if matches!(x.to_ascii_lowercase().as_str(), "first" | "before" | "beforefirst") => Some(0),
(None, Some(x)) if matches!(x.to_ascii_lowercase().as_str(), "last" | "end") => Some(len),
(None, Some(x)) => {
let mut rest = x.chars();
rest.next().filter(|c| c.eq_ignore_ascii_case(&letter)).and_then(|_| rest.as_str().parse::<usize>().ok())
}
(None, None) => None,
},
};
after.filter(|n| *n <= len).ok_or_else(|| bad(cmd, format!("`{key}` must be \"first\", a track ({letter}1–{letter}{len}) or how many tracks come before")))
}
/// Give the tracks that still carry a default name (`<prefix> <number>`) the number of their
/// place, as Premiere numbers its tracks; a track the user named keeps its name.
fn renumber(tracks: &mut [filmcraft_project::Track], prefix: &str) {
for (i, t) in tracks.iter_mut().enumerate() {
let default = t.name.strip_prefix(prefix).and_then(|r| r.strip_prefix(' ')).is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()));
if default {
t.name = format!("{prefix} {}", i + 1);
}
}
}
/// Sequence ▸ Add Tracks…: `video` / `audio` / `submix` tracks, each kind at its own place
/// (`videoAfter`, `audioAfter`, `submixAfter`; after the last track when not given), audio tracks
/// of `audioType` (Standard unless given) and submix tracks of `submixType` (Stereo). One undo
/// step. Tracks are numbered by their place, so default names after the new tracks move up.
pub(crate) fn add_tracks(s: &mut Session, p: &Value) -> Result<Value> {
use crate::commands::u64_p;
let cmd = "sequence.addTracks";
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let amount = |key: &str, default: u64, have: usize| -> Result<usize> {
let n = match p.get(key) {
None | Some(Value::Null) => default,
Some(_) => u64_p(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a number of tracks")))?,
};
if n > MAX_ADDED_TRACKS {
return Err(bad(cmd, format!("`{key}`: at most {MAX_ADDED_TRACKS} tracks at a time")));
}
if have.saturating_add(n as usize) > MAX_TRACKS {
return Err(bad(cmd, format!("`{key}`: a sequence has at most {MAX_TRACKS} tracks of a kind")));
}
Ok(n as usize)
};
let (nv, na, ns) = (amount("video", 1, q.video_tracks.len())?, amount("audio", 0, q.audio_tracks.len())?, amount("submix", 0, q.submix_tracks.len())?);
let (at_v, at_a, at_s) = (
placement(p, "videoAfter", 'V', q.video_tracks.len())?,
placement(p, "audioAfter", 'A', q.audio_tracks.len())?,
placement(p, "submixAfter", 'S', q.submix_tracks.len())?,
);
let channels = |key: &str, default: AudioChannels| match p.get(key).and_then(Value::as_str) {
None => Ok(default),
Some(x) => crate::mixer::channels_from(x).ok_or_else(|| bad(cmd, format!("`{key}`: unknown track type {x:?} (standard, stereo, 5.1, adaptive, mono)"))),
};
let (audio_type, submix_type) = (channels("audioType", AudioChannels::Stereo)?, channels("submixType", AudioChannels::Stereo)?);
if nv + na + ns == 0 {
return Err(bad(cmd, "no tracks to add"));
}
let added = s.edit_sequence("Add Tracks", |q, ctx, _| {
let mut new = |kind: TrackKind, prefix: &str, channels: Option<AudioChannels>| {
let mut t = filmcraft_project::Track::new(TrackId(ctx.alloc()), kind, format!("{prefix} 0"));
if let Some(c) = channels {
t.channels = c;
}
t
};
let mut ids: [Vec<u64>; 3] = Default::default();
for (k, (tracks, n, at, kind, prefix, ch)) in [
(&mut q.video_tracks, nv, at_v, TrackKind::Video, "Video", None),
(&mut q.audio_tracks, na, at_a, TrackKind::Audio, "Audio", Some(audio_type)),
(&mut q.submix_tracks, ns, at_s, TrackKind::Audio, "Submix", Some(submix_type)),
]
.into_iter()
.enumerate()
{
let at = at.min(tracks.len());
for i in 0..n {
let t = new(kind, prefix, ch);
if let Some(slot) = ids.get_mut(k) {
slot.push(t.id.0);
}
tracks.insert(at + i, t);
}
if n > 0 {
renumber(tracks, prefix);
}
}
Ok(ids)
})?;
let [video, audio, submix] = added;
Ok(json!({"video": video, "audio": audio, "submix": submix}))
}
fn delete_tracks(s: &mut Session, p: &Value) -> Result<Value> {
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;

View File

@ -1,7 +1,7 @@
//! Sequence / Markers menu commands (`sequence_tools`).
use super::*;
use filmcraft_project::{Label, MarkerKind};
use filmcraft_project::{AudioChannels, Label, MarkerKind, TrackKind};
use serde_json::json;
fn demo() -> Session {
@ -189,6 +189,108 @@ fn make_subsequence_from_in_out_and_from_selection() {
assert!(s.project.items.values().all(|i| i.name != "Shot 3"));
}
/// Sequence ▸ Add Tracks…: where the new tracks go, what they are, and what the amounts may be.
/// Premiere Pro 26.5.2: Placement is "Before First Track" or after one of the tracks (after the
/// last by default); a track added after Video 1 is the new Video 2, and what was on Video 2 is
/// then on Video 3; audio tracks are Standard, 5.1, Adaptive or Mono, submix tracks Stereo, 5.1,
/// Adaptive or Mono.
#[test]
fn add_tracks_places_and_types_them() {
let mut s = demo();
let names = |s: &Session, kind: TrackKind| s.active_sequence().unwrap().tracks(kind).iter().map(|t| t.name.clone()).collect::<Vec<_>>();
let before = s.active_sequence().unwrap().clone();
assert_eq!(names(&s, TrackKind::Video), ["Video 1", "Video 2", "Video 3"]);
let on_v2 = before.video_tracks[1].items[0].id;
// without a place: after the last track (and one video track when nothing is said, as before)
let r = s.execute("sequence.addTracks", json!({})).unwrap();
assert_eq!((r["video"].as_array().unwrap().len(), r["audio"].as_array().unwrap().len(), r["submix"].as_array().unwrap().len()), (1, 0, 0));
assert_eq!(names(&s, TrackKind::Video), ["Video 1", "Video 2", "Video 3", "Video 4"]);
assert_eq!(s.active_sequence().unwrap().video_tracks[3].id.0, r["video"][0].as_u64().unwrap());
s.execute("edit.undo", json!({})).unwrap();
// after Video 1: two new empty tracks there, the tracks above move up and are numbered again
s.execute("sequence.addTracks", json!({"video": 2, "videoAfter": "V1"})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!(names(&s, TrackKind::Video), ["Video 1", "Video 2", "Video 3", "Video 4", "Video 5"]);
assert!(q.video_tracks[1].items.is_empty() && q.video_tracks[2].items.is_empty());
assert_eq!(q.video_tracks[3].id, before.video_tracks[1].id);
assert_eq!(q.video_tracks[3].items[0].id, on_v2, "what was on Video 2 is on Video 4");
assert_eq!(q.audio_tracks.len(), before.audio_tracks.len(), "no audio track was asked for");
// one undo step
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.active_sequence().unwrap(), before);
// before the first track; a name the user gave is kept, default names follow their place
s.edit_sequence("Rename Track", |q, _, _| {
q.audio_tracks[1].name = "Music".into();
Ok(())
})
.unwrap();
s.execute("sequence.addTracks", json!({"video": 0, "audio": 1, "audioAfter": "first", "audioType": "mono"})).unwrap();
assert_eq!(names(&s, TrackKind::Audio), ["Audio 1", "Audio 2", "Music", "Audio 4"]);
let q = s.active_sequence().unwrap();
assert_eq!(q.audio_tracks[0].channels, AudioChannels::Mono);
assert_eq!(q.audio_tracks[1].id, before.audio_tracks[0].id);
// a number counts the tracks before the new ones; "Standard" is a stereo track
for (ty, want) in [("standard", AudioChannels::Stereo), ("5.1", AudioChannels::Surround51), ("adaptive", AudioChannels::Adaptive)] {
let r = s.execute("sequence.addTracks", json!({"video": 0, "audio": 1, "audioAfter": 2, "audioType": ty})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!((q.audio_tracks[2].id.0, q.audio_tracks[2].channels), (r["audio"][0].as_u64().unwrap(), want), "{ty}");
}
// submix tracks: stereo unless said, placed among the submix tracks
assert!(before.submix_tracks.is_empty());
s.execute("sequence.addTracks", json!({"video": 0, "submix": 2})).unwrap();
s.execute("sequence.addTracks", json!({"video": 0, "submix": 1, "submixAfter": "S1", "submixType": "5.1"})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!(
q.submix_tracks.iter().map(|t| (t.name.as_str(), t.channels)).collect::<Vec<_>>(),
[("Submix 1", AudioChannels::Stereo), ("Submix 2", AudioChannels::Surround51), ("Submix 3", AudioChannels::Stereo)]
);
// all three kinds at once are one undo step
let before = s.active_sequence().unwrap().clone();
let r = s.execute("sequence.addTracks", json!({"video": 1, "audio": 1, "submix": 1})).unwrap();
assert_eq!((r["video"].as_array().unwrap().len(), r["audio"].as_array().unwrap().len(), r["submix"].as_array().unwrap().len()), (1, 1, 1));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.active_sequence().unwrap(), before);
}
/// Amounts and places come from a dialog, a script or the control channel: a wrong one is an
/// error that changes nothing, and no amount can make the sequence grow without bound.
#[test]
fn add_tracks_refuses_what_it_cannot_do() {
let mut s = demo();
let before = s.active_sequence().unwrap().clone();
for bad in [
json!({"video": 0}),
json!({"video": 100}),
json!({"video": u64::MAX}),
json!({"video": 0, "audio": 1_000_000}),
json!({"video": "many"}),
json!({"video": 1, "videoAfter": "V9"}),
json!({"video": 1, "videoAfter": "A1"}),
json!({"video": 1, "videoAfter": 4}),
json!({"video": 1, "videoAfter": "top"}),
json!({"video": 0, "audio": 1, "audioAfter": "V1"}),
json!({"video": 0, "audio": 1, "audioType": "quad"}),
json!({"video": 0, "submix": 1, "submixAfter": "S1"}),
json!({"video": 0, "submix": 1, "submixType": "standard-ish"}),
] {
let r = s.execute("sequence.addTracks", bad.clone());
assert!(r.is_err(), "{bad} was accepted: {r:?}");
assert_eq!(*s.active_sequence().unwrap(), before, "{bad} changed the sequence");
}
// 99 at a time, and a sequence stops at 999 tracks of a kind
for _ in 0..10 {
s.execute("sequence.addTracks", json!({"video": 99})).unwrap();
}
assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 3 + 990);
assert!(s.execute("sequence.addTracks", json!({"video": 7})).is_err());
s.execute("sequence.addTracks", json!({"video": 6})).unwrap();
assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 999);
}
#[test]
fn delete_tracks_empty_and_specific() {
let mut s = demo();

View File

@ -106,6 +106,8 @@ pub enum Dialog {
AudioGain,
/// Sequence ▸ Delete Tracks….
DeleteTracks,
/// Sequence ▸ Add Tracks….
AddTracks,
}
#[derive(Default)]

View File

@ -327,6 +327,21 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
return crate::panels::remix::open(app, ctx);
}
// Add Tracks… from the menu opens the dialog (new tracks after the last ones, as Premiere
// offers); with params it adds the tracks directly.
"sequence.addTracks" if params.as_object().is_none_or(|m| m.is_empty()) => {
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
let seq = app.session.active_sequence();
let count = |f: fn(&filmcraft_engine::project::Sequence) -> usize| seq.map_or(0, f);
app.ui.add_tracks = crate::state::AddTracksDraft {
video_after: count(|q| q.video_tracks.len()),
audio_after: count(|q| q.audio_tracks.len()),
submix_after: count(|q| q.submix_tracks.len()),
..Default::default()
};
app.dialog = Some(crate::Dialog::AddTracks);
return Ok(json!({"dialog": "addTracks"}));
}
"sequence.deleteTracks" if params.as_object().is_none_or(|m| m.is_empty()) => {
filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&app.session))?;
app.ui.delete_tracks = Default::default();

View File

@ -58,6 +58,12 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
}
return;
}
Dialog::AddTracks => {
if !add_tracks(app, ctx) {
app.dialog = None;
}
return;
}
Dialog::NewSequence | Dialog::Preferences | Dialog::Recovery | Dialog::RevertConfirm => {}
}
if !open {
@ -146,6 +152,134 @@ fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
keep
}
/// Sequence ▸ Add Tracks…, laid out as Premiere Pro's dialog: "Add video tracks" (Amount,
/// Placement), "Add audio tracks" (Amount, Placement, Track type: Standard, 5.1, Adaptive, Mono)
/// and "Add audio submix tracks" (Amount, Placement, Track type: Stereo, 5.1, Adaptive, Mono).
/// Placement is "Before First Track" or after one of the tracks; the submix placement is off
/// while the sequence has no submix track. Returns whether the dialog stays open.
///
/// Automation ids, with `<kind>` = `video`, `audio` or `submix`: `addTracks.<kind>.amount`,
/// `addTracks.<kind>.placement` and, while its list is open, `addTracks.<kind>.placement.option.<n>`
/// (n tracks before the new ones; 0 = Before First Track); `addTracks.<audio|submix>.type` and
/// `addTracks.<audio|submix>.type.option.<standard|stereo|5.1|adaptive|mono>`; `addTracks.ok`,
/// `addTracks.cancel`.
fn add_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
let Some(seq) = app.session.active_sequence() else { return false };
let names = |tracks: &[filmcraft_engine::project::Track]| tracks.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
let (vnames, anames, snames) = (names(&seq.video_tracks), names(&seq.audio_tracks), names(&seq.submix_tracks));
let mut d = app.ui.add_tracks.clone();
let mut keep = true;
let mut apply = false;
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
const AUDIO_TYPES: [(&str, &str); 4] = [("standard", "Standard"), ("5.1", "5.1"), ("adaptive", "Adaptive"), ("mono", "Mono")];
const SUBMIX_TYPES: [(&str, &str); 4] = [("stereo", "Stereo"), ("5.1", "5.1"), ("adaptive", "Adaptive"), ("mono", "Mono")];
egui::Window::new("Add Tracks").collapsible(false).resizable(false).default_width(320.0).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
// everything spans the width the window has: no empty band beside the fields
ui.set_width(ui.available_width());
// the label column is as wide as its widest label; the lists take the rest of the row,
// up to the group's right edge (as in Premiere's dialog)
let font = egui::TextStyle::Body.resolve(ui.style());
let label_w = ["Amount", "Placement", "Track type"]
.iter()
.map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), egui::Color32::WHITE).size().x)
.fold(0.0, f32::max);
let row_h = ui.spacing().interact_size.y;
let label = |ui: &mut egui::Ui, text: &str| {
ui.allocate_ui_with_layout(egui::vec2(label_w, row_h), egui::Layout::right_to_left(egui::Align::Center), |ui| ui.label(text));
};
let groups: [(&str, &str, &mut u32, &mut usize, &[String], Option<(&mut String, &[(&str, &str); 4])>); 3] = [
("video", "Add video tracks", &mut d.video, &mut d.video_after, &vnames, None),
("audio", "Add audio tracks", &mut d.audio, &mut d.audio_after, &anames, Some((&mut d.audio_type, &AUDIO_TYPES))),
("submix", "Add audio submix tracks", &mut d.submix, &mut d.submix_after, &snames, Some((&mut d.submix_type, &SUBMIX_TYPES))),
];
for (kind, title, amount, after, tracks, track_type) in groups {
ui.add_space(4.0);
ui.label(egui::RichText::new(title).strong());
ui.group(|ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
label(ui, "Amount");
let r = ui.add(egui::DragValue::new(amount).range(0..=99).speed(0.1));
elems.push((format!("addTracks.{kind}.amount"), r.rect, amount.to_string()));
});
ui.horizontal(|ui| {
label(ui, "Placement");
*after = (*after).min(tracks.len());
let place = |n: usize| match n.checked_sub(1).and_then(|i| tracks.get(i)) {
Some(name) => format!("After {name}"),
None => "Before First Track".to_string(),
};
let shown = place(*after);
let list_w = ui.available_width();
// nowhere to choose from while the sequence has no track of the kind
ui.add_enabled_ui(!tracks.is_empty(), |ui| {
let r = egui::ComboBox::from_id_salt(("add-tracks-placement", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| {
for n in 0..=tracks.len() {
let label = place(n);
let o = ui.selectable_value(after, n, &label);
elems.push((format!("addTracks.{kind}.placement.option.{n}"), o.rect, label));
}
});
elems.push((format!("addTracks.{kind}.placement"), r.response.rect, shown.clone()));
});
});
if let Some((chosen, types)) = track_type {
ui.horizontal(|ui| {
label(ui, "Track type");
let shown = types.iter().find(|(id, _)| *id == chosen.as_str()).map_or(types[0].1, |(_, label)| label).to_string();
let list_w = ui.available_width();
let r = egui::ComboBox::from_id_salt(("add-tracks-type", kind)).selected_text(&shown).width(list_w).show_ui(ui, |ui| {
for (id, label) in types {
let o = ui.selectable_value(chosen, id.to_string(), *label);
elems.push((format!("addTracks.{kind}.type.option.{id}"), o.rect, label.to_string()));
}
});
elems.push((format!("addTracks.{kind}.type"), r.response.rect, shown));
});
}
});
}
ui.add_space(10.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let o = ui.add(egui::Button::new(egui::RichText::new("OK").color(egui::Color32::WHITE)).fill(app.tokens.accent));
elems.push(("addTracks.ok".into(), o.rect, "OK".into()));
if o.clicked() {
apply = true;
}
let c = ui.button("Cancel");
elems.push(("addTracks.cancel".into(), c.rect, "Cancel".into()));
if c.clicked() {
keep = false;
}
});
});
for (id, r, l) in elems {
app.auto.add(&id, r, &l);
}
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
keep = false;
}
if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && !ctx.egui_wants_keyboard_input() {
apply = true;
}
if apply {
// nothing asked for: OK closes the dialog and changes nothing
if d.video > 0 || d.audio > 0 || d.submix > 0 {
let params = serde_json::json!({
"video": d.video, "videoAfter": d.video_after,
"audio": d.audio, "audioAfter": d.audio_after, "audioType": d.audio_type,
"submix": d.submix, "submixAfter": d.submix_after, "submixType": d.submix_type,
});
if let Err(e) = app.session.execute("sequence.addTracks", params) {
app.ui.status = e.to_string();
}
}
keep = false;
}
app.ui.add_tracks = d;
keep
}
/// Sequence ▸ Delete Tracks…: "Delete Video Tracks" / "Delete Audio Tracks" checkboxes, each with a
/// track choice (All Empty Tracks or one track). Returns whether the dialog stays open.
///

View File

@ -422,6 +422,9 @@ pub struct UiState {
/// Audio Gain dialog draft (mode, dB values).
#[serde(default)]
pub audio_gain: AudioGainDraft,
/// Add Tracks dialog draft.
#[serde(default)]
pub add_tracks: AddTracksDraft,
/// Delete Tracks dialog draft.
#[serde(default)]
pub delete_tracks: DeleteTracksDraft,
@ -701,6 +704,30 @@ pub struct AudioGainDraft {
pub all_peaks_db: f64,
}
/// The Add Tracks dialog (Sequence ▸ Add Tracks…): for video, audio and audio submix tracks, how
/// many to add and where (`*_after`: how many tracks of the kind come before the new ones; 0 is
/// "Before First Track"), and the type of the audio and submix tracks (`standard` / `stereo`,
/// `5.1`, `adaptive`, `mono`). Premiere's defaults: one video track and one audio track after the
/// last ones, no submix track.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AddTracksDraft {
pub video: u32,
pub video_after: usize,
pub audio: u32,
pub audio_after: usize,
pub audio_type: String,
pub submix: u32,
pub submix_after: usize,
pub submix_type: String,
}
impl Default for AddTracksDraft {
fn default() -> Self {
Self { video: 1, video_after: 0, audio: 1, audio_after: 0, audio_type: "standard".into(), submix: 0, submix_after: 0, submix_type: "stereo".into() }
}
}
/// The Delete Tracks dialog (Sequence ▸ Delete Tracks…): per kind, whether to delete and which
/// track (`"empty"` = All Empty Tracks, or a track name such as `"V2"`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -770,6 +797,7 @@ impl Default for UiState {
mixer_meter_input_only: false,
settings: None,
audio_gain: AudioGainDraft::default(),
add_tracks: AddTracksDraft::default(),
delete_tracks: DeleteTracksDraft::default(),
gfx_edit: None,
pen_points: vec![],

View File

@ -1,4 +1,4 @@
//! Headless UI tests of the Sequence / Markers menu additions: the Delete Tracks dialog, the
//! Headless UI tests of the Sequence / Markers menu additions: the Add Tracks and Delete Tracks dialogs, the
//! through-edit marks on the timeline, the Markers panel colour filter and the Shift+; gap key.
//! The real `FilmcraftApp` under `egui_kittest`, driven over the control channel by automation id.
@ -64,12 +64,161 @@ impl Driver {
self.frames(2);
}
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
/// (video, audio, submix) track names of the active sequence, lowest first.
fn tracks(&mut self) -> [Vec<String>; 3] {
let q = self.app().session.active_sequence().unwrap();
[&q.video_tracks, &q.audio_tracks, &q.submix_tracks].map(|t| t.iter().map(|t| t.name.clone()).collect())
}
fn label(&mut self, id: &str) -> String {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")).clone();
e["label"].as_str().unwrap_or_default().to_string()
}
fn ids(&mut self, prefix: &str) -> Vec<String> {
let v = self.ok("ui.elements", json!({"prefix": prefix}));
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
}
}
/// Sequence ▸ Add Tracks… opens Premiere Pro's dialog instead of adding a video track at once:
/// amounts, a placement and (for audio and submix tracks) a track type for each kind, with
/// Premiere's defaults (one video and one audio track after the last ones, no submix track).
#[test]
fn add_tracks_dialog_offers_amount_placement_and_type() {
let mut d = Driver::demo();
let before = d.tracks();
assert_eq!(
before,
[vec!["Video 1", "Video 2", "Video 3"], vec!["Audio 1", "Audio 2", "Audio 3"], vec![]]
.map(|v: Vec<&str>| v.into_iter().map(String::from).collect::<Vec<_>>())
);
let r = d.ok("ui.menu.invoke", json!({"id": "sequence.addTracks"}));
assert_eq!(r["dialog"], "addTracks", "{r}");
d.frames(3);
assert_eq!(d.tracks(), before, "opening the dialog adds nothing");
let ids = d.ids("addTracks.");
for id in [
"addTracks.video.amount",
"addTracks.video.placement",
"addTracks.audio.amount",
"addTracks.audio.placement",
"addTracks.audio.type",
"addTracks.submix.amount",
"addTracks.submix.placement",
"addTracks.submix.type",
"addTracks.ok",
"addTracks.cancel",
] {
assert!(ids.iter().any(|i| i == id), "{id} missing: {ids:?}");
}
assert!(!ids.iter().any(|i| i == "addTracks.video.type"), "video tracks have no type");
// the defaults, as shown
for (id, shown) in [
("addTracks.video.amount", "1"),
("addTracks.video.placement", "After Video 3"),
("addTracks.audio.amount", "1"),
("addTracks.audio.placement", "After Audio 3"),
("addTracks.audio.type", "Standard"),
("addTracks.submix.amount", "0"),
("addTracks.submix.placement", "Before First Track"),
("addTracks.submix.type", "Stereo"),
] {
assert_eq!(d.label(id), shown, "{id}");
}
// OK with the defaults: one video and one audio track after the last ones
d.click("addTracks.ok");
assert!(d.ids("addTracks.").is_empty(), "dialog closed");
let [v, a, s] = d.tracks();
assert_eq!((v.len(), a.len(), s.len()), (4, 4, 0));
assert_eq!((v[3].as_str(), a[3].as_str()), ("Video 4", "Audio 4"));
assert_eq!(d.app().session.active_sequence().unwrap().audio_tracks[3].channels, filmcraft_engine::project::AudioChannels::Stereo);
// one undo step for the whole dialog
d.exec("edit.undo", json!({}));
assert_eq!(d.tracks(), before);
}
#[test]
fn add_tracks_dialog_places_and_types_the_new_tracks() {
let mut d = Driver::demo();
let on_v2 = d.app().session.active_sequence().unwrap().video_tracks[1].items[0].id;
d.ok("ui.menu.invoke", json!({"id": "sequence.addTracks"}));
d.frames(3);
// Placement lists "Before First Track" and every track; pick "After Video 1"
d.click("addTracks.video.placement");
let options = d.ids("addTracks.video.placement.option.");
assert_eq!(options.len(), 4, "{options:?}");
assert_eq!(
(d.label("addTracks.video.placement.option.0"), d.label("addTracks.video.placement.option.1")),
("Before First Track".into(), "After Video 1".into())
);
d.click("addTracks.video.placement.option.1");
assert_eq!(d.label("addTracks.video.placement"), "After Video 1");
// audio: a mono track before the first one
d.click("addTracks.audio.placement");
d.click("addTracks.audio.placement.option.0");
d.click("addTracks.audio.type");
let types = d.ids("addTracks.audio.type.option.");
assert_eq!(types.len(), 4, "Standard, 5.1, Adaptive, Mono: {types:?}");
d.click("addTracks.audio.type.option.mono");
// submix: 5.1 (the types are Stereo, 5.1, Adaptive, Mono)
d.click("addTracks.submix.type");
assert!(
d.ids("addTracks.submix.type.option.").iter().any(|i| i.ends_with(".stereo"))
&& !d.ids("addTracks.submix.type.option.").iter().any(|i| i.ends_with(".standard"))
);
d.click("addTracks.submix.type.option.5.1");
// amounts (number fields): two video tracks, one audio, one submix
d.app().ui.add_tracks.video = 2;
d.app().ui.add_tracks.submix = 1;
d.frames(2);
assert_eq!((d.label("addTracks.video.amount"), d.label("addTracks.submix.amount")), ("2".into(), "1".into()));
d.click("addTracks.ok");
assert!(d.ids("addTracks.").is_empty(), "dialog closed");
let [v, a, s] = d.tracks();
assert_eq!(v, ["Video 1", "Video 2", "Video 3", "Video 4", "Video 5"]);
assert_eq!(a, ["Audio 1", "Audio 2", "Audio 3", "Audio 4"]);
assert_eq!(s, ["Submix 1"]);
let q = d.app().session.active_sequence().unwrap().clone();
assert!(q.video_tracks[1].items.is_empty() && q.video_tracks[2].items.is_empty(), "the new tracks are after Video 1");
assert_eq!(q.video_tracks[3].items[0].id, on_v2, "what was on Video 2 moved up to Video 4");
use filmcraft_engine::project::AudioChannels;
assert_eq!((q.audio_tracks[0].channels, q.audio_tracks[0].items.len()), (AudioChannels::Mono, 0));
assert_eq!(q.submix_tracks[0].channels, AudioChannels::Surround51);
// with a submix track in the sequence its placement can be chosen too
d.ok("ui.menu.invoke", json!({"id": "sequence.addTracks"}));
d.frames(3);
assert_eq!(d.label("addTracks.submix.placement"), "After Submix 1");
assert_eq!(
(d.label("addTracks.video.amount"), d.label("addTracks.audio.type")),
("1".into(), "Standard".into()),
"the dialog starts from the defaults again"
);
// Cancel and Escape add nothing; neither does OK with every amount at 0
d.click("addTracks.cancel");
assert!(d.ids("addTracks.").is_empty());
d.ok("ui.menu.invoke", json!({"id": "sequence.addTracks"}));
d.frames(3);
d.ok("ui.key", json!({"key": "Escape"}));
d.frames(3);
assert!(d.ids("addTracks.").is_empty());
d.ok("ui.menu.invoke", json!({"id": "sequence.addTracks"}));
d.frames(3);
d.app().ui.add_tracks.video = 0;
d.app().ui.add_tracks.audio = 0;
d.frames(2);
d.click("addTracks.ok");
assert!(d.ids("addTracks.").is_empty());
assert_eq!(d.tracks(), [v, a, s]);
assert!(d.app().ui.status.is_empty(), "{}", d.app().ui.status);
}
#[test]
fn delete_tracks_dialog_deletes_empty_tracks() {
let mut d = Driver::demo();