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:
commit
b4caf39f5f
|
|
@ -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, _, _| {
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ pub enum Dialog {
|
|||
AudioGain,
|
||||
/// Sequence ▸ Delete Tracks….
|
||||
DeleteTracks,
|
||||
/// Sequence ▸ Add Tracks….
|
||||
AddTracks,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue