933 lines
44 KiB
Rust
933 lines
44 KiB
Rust
//! Sequence and Markers menu commands: gaps, reverse match frame, subsequences, track deletion,
|
||
//! through edits, split edit points, range and chapter markers and the marker toggles.
|
||
//!
|
||
//! | Id | Menu | Default key |
|
||
//! |---|---|---|
|
||
//! | `sequence.reverseMatchFrame` | Sequence ▸ Reverse Match Frame | Shift+R |
|
||
//! | `sequence.goToNextGap` / `sequence.goToPrevGap` | Sequence ▸ Go to Gap ▸ Next / Previous in Sequence | Shift+; / Cmd+Shift+; |
|
||
//! | `sequence.goToNextGapInTrack` / `sequence.goToPrevGapInTrack` | Sequence ▸ Go to Gap ▸ Next / Previous in Track | |
|
||
//! | `sequence.selectionFollowsPlayhead` | Sequence ▸ Selection Follows Playhead | |
|
||
//! | `sequence.showThroughEdits` | Sequence ▸ Show Through Edits | |
|
||
//! | `sequence.joinThroughEdits` | (clip context menu) Join Through Edits | |
|
||
//! | `sequence.throughEdits` | query: the through edits of the active sequence | |
|
||
//! | `sequence.makeSubsequence` | Sequence ▸ Make Subsequence | Shift+U |
|
||
//! | `sequence.deleteTracks` | Sequence ▸ Delete Tracks… | |
|
||
//! | `markers.markSplitVideoIn` … `markers.markSplitAudioOut` | Markers ▸ Mark Split ▸ Video In … Audio Out | |
|
||
//! | `markers.goToSplitVideoIn` … `markers.goToSplitAudioOut` | Markers ▸ Go to Split ▸ Video In … Audio Out | |
|
||
//! | `markers.addRange` | Markers ▸ Add Range Marker | Ctrl+Shift+M |
|
||
//! | `markers.addRangeInOut` | Markers ▸ Add Range Marker to In and Out | Ctrl+M |
|
||
//! | `markers.showAllMarkerColors` / `markers.filterColors` | Markers ▸ Show All Marker Colors / Markers panel colour filter | |
|
||
//! | `markers.addChapter` | Markers ▸ Add Chapter Marker… | |
|
||
//! | `markers.rippleSequenceMarkers` | Markers ▸ Ripple Sequence Markers | |
|
||
//! | `markers.copyPasteIncludesSequenceMarkers` | Markers ▸ Copy Paste Includes Sequence Markers | |
|
||
//!
|
||
//! Split points: Mark Split sets a video-only or audio-only In/Out (on the sequence, or with
|
||
//! `"target":"source"` on the Source monitor clip). Ordinary Mark In / Mark Out clear the split of
|
||
//! that side. Insert / Overwrite from the Source monitor honour source split points: each channel
|
||
//! 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::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, MarkerKind, SplitMarks, TrackId, TrackItem, TrackKind};
|
||
use filmcraft_time::{Tick, TimeRange};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, str_p, time_p, track_p, with_links};
|
||
use crate::{EngineError, Result, Session};
|
||
|
||
type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||
type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
||
|
||
fn spec(
|
||
id: &'static str,
|
||
label: &'static str,
|
||
menu: &'static [&'static str],
|
||
shortcut: Option<&'static str>,
|
||
params: &'static str,
|
||
enabled: Enabled,
|
||
run: Run,
|
||
) -> CommandSpec {
|
||
CommandSpec { id, label, menu, shortcut, params, enabled, run, journal: true }
|
||
}
|
||
|
||
fn query(id: &'static str, label: &'static str, params: &'static str, run: Run) -> CommandSpec {
|
||
CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: always, run, journal: false }
|
||
}
|
||
|
||
/// Insert this module's commands into the registry after their anchors, so the menus list them in
|
||
/// Premiere's order.
|
||
pub(crate) fn splice(v: &mut Vec<CommandSpec>) {
|
||
for (anchor, specs) in groups() {
|
||
let at = v.iter().position(|c| c.id == anchor).map(|i| i + 1).unwrap_or(v.len());
|
||
for (k, c) in specs.into_iter().enumerate() {
|
||
v.insert(at + k, c);
|
||
}
|
||
}
|
||
}
|
||
|
||
const SPLIT: &str = r#"{"time":ticks?,"target":"program|source"}"#;
|
||
|
||
fn groups() -> Vec<(&'static str, Vec<CommandSpec>)> {
|
||
vec![
|
||
(
|
||
"sequence.matchFrame",
|
||
vec![spec("sequence.reverseMatchFrame", "Reverse Match Frame", &["Sequence"], Some("Shift+R"), "{}", has_source, |s, _| reverse_match_frame(s))],
|
||
),
|
||
(
|
||
"sequence.closeGap",
|
||
vec![
|
||
spec("sequence.goToNextGap", "Next in Sequence", &["Sequence", "Go to Gap"], Some("Shift+;"), "{}", has_seq, |s, p| {
|
||
go_to_gap(s, p, true, false)
|
||
}),
|
||
spec("sequence.goToPrevGap", "Previous in Sequence", &["Sequence", "Go to Gap"], Some("Cmd+Shift+;"), "{}", has_seq, |s, p| {
|
||
go_to_gap(s, p, false, false)
|
||
}),
|
||
spec("sequence.goToNextGapInTrack", "Next in Track", &["Sequence", "Go to Gap"], None, r#"{"track":"V1"|id?}"#, has_seq, |s, p| {
|
||
go_to_gap(s, p, true, true)
|
||
}),
|
||
spec("sequence.goToPrevGapInTrack", "Previous in Track", &["Sequence", "Go to Gap"], None, r#"{"track":"V1"|id?}"#, has_seq, |s, p| {
|
||
go_to_gap(s, p, false, true)
|
||
}),
|
||
],
|
||
),
|
||
(
|
||
"sequence.linkedSelection",
|
||
vec![
|
||
spec("sequence.selectionFollowsPlayhead", "Selection Follows Playhead", &["Sequence"], None, r#"{"on":bool?}"#, always, |s, p| {
|
||
s.state.selection_follows_playhead = bool_p(p, "on").unwrap_or(!s.state.selection_follows_playhead);
|
||
if s.state.selection_follows_playhead {
|
||
select_under_playhead(s);
|
||
}
|
||
Ok(json!({"selectionFollowsPlayhead": s.state.selection_follows_playhead}))
|
||
}),
|
||
spec("sequence.showThroughEdits", "Show Through Edits", &["Sequence"], None, r#"{"on":bool?}"#, always, |s, p| {
|
||
s.state.show_through_edits = bool_p(p, "on").unwrap_or(!s.state.show_through_edits);
|
||
Ok(json!({"showThroughEdits": s.state.show_through_edits}))
|
||
}),
|
||
spec("sequence.joinThroughEdits", "Join Through Edits", &[], None, r#"{"clips":[id]?,"all":bool?}"#, has_seq, join_through_edits),
|
||
query("sequence.throughEdits", "List Through Edits", "{}", |s, _| {
|
||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||
Ok(Value::Array(
|
||
edit::through::through_edits(q)
|
||
.iter()
|
||
.map(|e| json!({"track": e.track.0, "left": e.left.0, "right": e.right.0, "time": e.time.0}))
|
||
.collect(),
|
||
))
|
||
}),
|
||
spec("sequence.makeSubsequence", "Make Subsequence", &["Sequence"], Some("Shift+U"), r#"{"name":str?}"#, has_seq, make_subsequence),
|
||
],
|
||
),
|
||
(
|
||
"sequence.addTracks",
|
||
vec![spec(
|
||
"sequence.deleteTracks",
|
||
"Delete Tracks…",
|
||
&["Sequence"],
|
||
None,
|
||
r#"{"video":"empty"|"V2"|id?,"audio":"empty"|"A2"|id?}"#,
|
||
has_seq,
|
||
delete_tracks,
|
||
)],
|
||
),
|
||
(
|
||
"markers.markSelection",
|
||
vec![
|
||
spec("markers.markSplitVideoIn", "Video In", &["Markers", "Mark Split"], None, SPLIT, always, |s, p| mark_split(s, p, Side::VideoIn)),
|
||
spec("markers.markSplitVideoOut", "Video Out", &["Markers", "Mark Split"], None, SPLIT, always, |s, p| mark_split(s, p, Side::VideoOut)),
|
||
spec("markers.markSplitAudioIn", "Audio In", &["Markers", "Mark Split"], None, SPLIT, always, |s, p| mark_split(s, p, Side::AudioIn)),
|
||
spec("markers.markSplitAudioOut", "Audio Out", &["Markers", "Mark Split"], None, SPLIT, always, |s, p| mark_split(s, p, Side::AudioOut)),
|
||
],
|
||
),
|
||
(
|
||
"markers.goToOut",
|
||
vec![
|
||
spec("markers.goToSplitVideoIn", "Video In", &["Markers", "Go to Split"], None, r#"{"target":"program|source"}"#, has_split, |s, p| {
|
||
go_to_split(s, p, Side::VideoIn)
|
||
}),
|
||
spec("markers.goToSplitVideoOut", "Video Out", &["Markers", "Go to Split"], None, r#"{"target":"program|source"}"#, has_split, |s, p| {
|
||
go_to_split(s, p, Side::VideoOut)
|
||
}),
|
||
spec("markers.goToSplitAudioIn", "Audio In", &["Markers", "Go to Split"], None, r#"{"target":"program|source"}"#, has_split, |s, p| {
|
||
go_to_split(s, p, Side::AudioIn)
|
||
}),
|
||
spec("markers.goToSplitAudioOut", "Audio Out", &["Markers", "Go to Split"], None, r#"{"target":"program|source"}"#, has_split, |s, p| {
|
||
go_to_split(s, p, Side::AudioOut)
|
||
}),
|
||
],
|
||
),
|
||
(
|
||
"markers.add",
|
||
vec![
|
||
spec(
|
||
"markers.addRange",
|
||
"Add Range Marker",
|
||
&["Markers"],
|
||
Some("Ctrl+Shift+M"),
|
||
r#"{"time":ticks?,"durationFrames":i64=1s,"duration":ticks?,"name":str?,"comment":str?,"color":label?}"#,
|
||
has_seq,
|
||
add_range_marker,
|
||
),
|
||
spec(
|
||
"markers.addRangeInOut",
|
||
"Add Range Marker to In and Out",
|
||
&["Markers"],
|
||
Some("Ctrl+M"),
|
||
r#"{"name":str?,"comment":str?,"color":label?}"#,
|
||
has_in_and_out,
|
||
add_range_marker_in_out,
|
||
),
|
||
],
|
||
),
|
||
(
|
||
"markers.clearAll",
|
||
vec![
|
||
spec("markers.showAllMarkerColors", "Show All Marker Colors", &["Markers"], None, "{}", has_hidden_colors, |s, _| {
|
||
s.state.hidden_marker_colors.clear();
|
||
Ok(json!({"hidden": []}))
|
||
}),
|
||
spec("markers.filterColors", "Marker Colour Filter", &[], None, r#"{"hidden":[label]}|{"color":label,"visible":bool?}"#, always, filter_colors),
|
||
],
|
||
),
|
||
(
|
||
"markers.edit",
|
||
vec![
|
||
spec(
|
||
"markers.addChapter",
|
||
"Add Chapter Marker…",
|
||
&["Markers"],
|
||
None,
|
||
r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?}"#,
|
||
has_seq,
|
||
|s, p| {
|
||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||
let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::Chapter).count()).unwrap_or(0) + 1;
|
||
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Chapter {n}"));
|
||
let id = add_marker(s, "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?;
|
||
Ok(json!({"marker": id.0}))
|
||
},
|
||
),
|
||
spec("markers.rippleSequenceMarkers", "Ripple Sequence Markers", &["Markers"], None, r#"{"on":bool?}"#, always, |s, p| {
|
||
s.state.ripple_sequence_markers = bool_p(p, "on").unwrap_or(!s.state.ripple_sequence_markers);
|
||
Ok(json!({"rippleSequenceMarkers": s.state.ripple_sequence_markers}))
|
||
}),
|
||
spec(
|
||
"markers.copyPasteIncludesSequenceMarkers",
|
||
"Copy Paste Includes Sequence Markers",
|
||
&["Markers"],
|
||
None,
|
||
r#"{"on":bool?}"#,
|
||
always,
|
||
|s, p| {
|
||
s.state.copy_paste_sequence_markers = bool_p(p, "on").unwrap_or(!s.state.copy_paste_sequence_markers);
|
||
Ok(json!({"copyPasteIncludesSequenceMarkers": s.state.copy_paste_sequence_markers}))
|
||
},
|
||
),
|
||
],
|
||
),
|
||
]
|
||
}
|
||
|
||
// ---------- enablement ----------
|
||
|
||
fn has_source(s: &Session) -> std::result::Result<(), String> {
|
||
has_seq(s)?;
|
||
s.state.source_item.map(|_| ()).ok_or_else(|| "no clip in the Source monitor".into())
|
||
}
|
||
fn has_in_and_out(s: &Session) -> std::result::Result<(), String> {
|
||
let q = s.active_sequence().ok_or("no sequence is open")?;
|
||
if q.mark_in.is_some() && q.mark_out.is_some() { Ok(()) } else { Err("mark an In and an Out point first".into()) }
|
||
}
|
||
fn has_split(s: &Session) -> std::result::Result<(), String> {
|
||
let program = s.active_sequence().is_some_and(|q| !q.split.is_empty());
|
||
let source = s.state.source_item.and_then(|i| s.project.item(i)).is_some_and(|i| !i.split.is_empty());
|
||
if program || source { Ok(()) } else { Err("there are no split points".into()) }
|
||
}
|
||
fn has_hidden_colors(s: &Session) -> std::result::Result<(), String> {
|
||
if s.state.hidden_marker_colors.is_empty() { Err("all marker colours are shown".into()) } else { Ok(()) }
|
||
}
|
||
|
||
// ---------- selection follows playhead ----------
|
||
|
||
/// Select the clips under the playhead on targeted tracks (with linked partners).
|
||
pub fn select_under_playhead(s: &mut Session) {
|
||
let t = s.playhead();
|
||
let tg = s.targeting().targeted;
|
||
let Some(q) = s.active_sequence() else { return };
|
||
let ids: Vec<ClipId> = q.all_tracks().filter(|tr| tg.contains(&tr.id)).filter_map(|tr| tr.item_at(t).map(|i| i.id)).collect();
|
||
s.state.selection = with_links(s, &ids);
|
||
}
|
||
|
||
// ---------- match frame ----------
|
||
|
||
/// Timeline time at which `it` shows media time `src` (None when it doesn't).
|
||
pub fn timeline_time_of(it: &TrackItem, src: Tick) -> Option<Tick> {
|
||
if let Some(h) = it.frame_hold {
|
||
return (h == src).then_some(it.start);
|
||
}
|
||
let speed = it.speed.abs();
|
||
if speed <= 0.0 {
|
||
return None;
|
||
}
|
||
let total = it.source_out() - it.source_in;
|
||
let rel_src = if it.reverse { it.source_in + total - Tick(1) - src } else { src - it.source_in };
|
||
if rel_src < Tick::ZERO || rel_src >= total {
|
||
return None;
|
||
}
|
||
let t = it.start + Tick((rel_src.0 as f64 / speed).floor() as i64);
|
||
(t < it.end()).then_some(t)
|
||
}
|
||
|
||
/// Sequence ▸ Reverse Match Frame: find the Source monitor frame in the active sequence (targeted
|
||
/// tracks first, top video track down, then audio) and move the playhead there.
|
||
fn reverse_match_frame(s: &mut Session) -> Result<Value> {
|
||
let item = s.state.source_item.ok_or_else(|| EngineError::Other("no clip in the Source monitor".into()))?;
|
||
let src = s.state.source_playhead;
|
||
let tg = s.targeting().targeted;
|
||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||
let mut hits: Vec<(bool, Tick, ClipId)> = Vec::new();
|
||
for tr in q.video_tracks.iter().rev().chain(&q.audio_tracks) {
|
||
for it in tr.items.iter().filter(|i| i.item == item) {
|
||
if let Some(t) = timeline_time_of(it, src) {
|
||
hits.push((!tg.contains(&tr.id), t, it.id));
|
||
}
|
||
}
|
||
}
|
||
// stable: targeted first, then track order, then time
|
||
hits.sort_by_key(|h| h.0);
|
||
let (_, t, clip) = *hits.first().ok_or_else(|| EngineError::Other("the Source monitor frame is not used in this sequence".into()))?;
|
||
s.set_playhead(t);
|
||
Ok(json!({"clip": clip.0, "time": s.playhead().0}))
|
||
}
|
||
|
||
// ---------- gaps ----------
|
||
|
||
fn go_to_gap(s: &mut Session, p: &Value, next: bool, in_track: bool) -> Result<Value> {
|
||
let t = s.playhead();
|
||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||
let gaps: Vec<TimeRange> = if in_track {
|
||
let tracks: Vec<TrackId> = match track_p(s, p, "track", if next { "sequence.goToNextGapInTrack" } else { "sequence.goToPrevGapInTrack" })? {
|
||
Some(id) => vec![id],
|
||
None => s.targeting().targeted,
|
||
};
|
||
q.all_tracks().filter(|tr| tracks.contains(&tr.id)).flat_map(edit::through::track_gaps).collect()
|
||
} else {
|
||
edit::through::sequence_gaps(q)
|
||
};
|
||
let starts = gaps.iter().map(|g| g.start);
|
||
let hit = if next { starts.filter(|g| *g > t).min() } else { starts.filter(|g| *g < t).max() };
|
||
match hit {
|
||
Some(g) => {
|
||
s.set_playhead(g);
|
||
Ok(json!({"time": s.playhead().0}))
|
||
}
|
||
None => Ok(json!({"time": null})),
|
||
}
|
||
}
|
||
|
||
// ---------- through edits ----------
|
||
|
||
fn join_through_edits(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let all = bool_p(p, "all").unwrap_or(false) || (p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty());
|
||
let only = if all { Vec::new() } else { with_links(s, &clips_p(s, p)) };
|
||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||
let any = edit::through::through_edits(q)
|
||
.iter()
|
||
.any(|e| (only.is_empty() || only.contains(&e.left) || only.contains(&e.right)) && q.track(e.track).is_some_and(|t| !t.locked));
|
||
if !any {
|
||
return Err(EngineError::Other("there are no through edits to join".into()));
|
||
}
|
||
let n = s.edit_sequence("Join Through Edits", |q, _, st| {
|
||
let n = edit::through::join_through_edits(q, &only);
|
||
st.selection.retain(|c| q.find_item(*c).is_some());
|
||
Ok(n)
|
||
})?;
|
||
Ok(json!({"joined": n}))
|
||
}
|
||
|
||
// ---------- subsequence ----------
|
||
|
||
/// Sequence ▸ Make Subsequence: a new sequence (same settings and track layout) holding copies of the
|
||
/// selected clips, or of everything on targeted tracks between In and Out, trimmed to In/Out when
|
||
/// they are set. The new sequence is added to the project and selected; the original is unchanged.
|
||
/// Give a sequence made from part of `from` (Nest…, Make Subsequence) the track names and audio
|
||
/// channel layouts of `from`'s tracks, track for track.
|
||
pub(crate) fn lay_out_tracks_like(new: &mut filmcraft_project::Sequence, from: &filmcraft_project::Sequence) {
|
||
for (tr, src) in new.video_tracks.iter_mut().zip(&from.video_tracks) {
|
||
tr.name = src.name.clone();
|
||
}
|
||
for (tr, src) in new.audio_tracks.iter_mut().zip(&from.audio_tracks) {
|
||
tr.name = src.name.clone();
|
||
tr.channels = src.channels;
|
||
}
|
||
}
|
||
|
||
fn make_subsequence(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)?.clone();
|
||
let seq_name = s.project.item(seq_id).map(|i| i.name.clone()).unwrap_or_default();
|
||
let sel = with_links(s, &s.state.selection);
|
||
let fd = q.settings.frame_rate.frame_duration();
|
||
let range = (q.mark_in.is_some() || q.mark_out.is_some())
|
||
.then(|| TimeRange::from_bounds(q.mark_in.unwrap_or(Tick::ZERO), q.mark_out.map(|o| o + fd).unwrap_or(q.duration())))
|
||
.filter(|r| r.duration > Tick::ZERO);
|
||
if sel.is_empty() && range.is_none() {
|
||
return Err(EngineError::Other("select clips or mark In and Out first".into()));
|
||
}
|
||
let tg = s.targeting().targeted;
|
||
let mut items: Vec<(TrackKind, usize, TrackItem)> = Vec::new();
|
||
for (kind, tracks) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] {
|
||
for (ti, tr) in tracks.iter().enumerate() {
|
||
for it in &tr.items {
|
||
let wanted = if sel.is_empty() { tg.contains(&tr.id) } else { sel.contains(&it.id) };
|
||
if !wanted {
|
||
continue;
|
||
}
|
||
if let Some(c) = edit::through::clip_item_to(it, range.unwrap_or(it.range())) {
|
||
items.push((kind, ti, c));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if items.is_empty() {
|
||
return Err(EngineError::Other("there are no clips between In and Out on the targeted tracks".into()));
|
||
}
|
||
let offset = range.map(|r| r.start).unwrap_or_else(|| items.iter().map(|i| i.2.start).min().unwrap_or_default());
|
||
let name = match str_p(p, "name") {
|
||
Some(n) => n.to_string(),
|
||
None => (1..)
|
||
.map(|k| format!("{seq_name}_Sub_{k:02}"))
|
||
.find(|n| !s.project.items.values().any(|i| &i.name == n))
|
||
.unwrap_or_else(|| format!("{seq_name}_Sub")),
|
||
};
|
||
let id = s.edit("Make Subsequence", |pr, st| {
|
||
let nid = pr.new_sequence(&name, q.settings.clone(), q.video_tracks.len(), q.audio_tracks.len(), None);
|
||
let mut ids = std::collections::HashMap::new();
|
||
let mut links = std::collections::HashMap::new();
|
||
let mut placed: Vec<(TrackKind, usize, TrackItem)> = Vec::new();
|
||
for (kind, ti, mut it) in items.clone() {
|
||
let new_id = ClipId(pr.alloc_id());
|
||
ids.insert(it.id, new_id);
|
||
it.id = new_id;
|
||
it.start -= offset;
|
||
if let Some(l) = it.link {
|
||
it.link = Some(*links.entry(l).or_insert_with(|| pr.alloc_id()));
|
||
}
|
||
placed.push((kind, ti, it));
|
||
}
|
||
let mut transitions = Vec::new();
|
||
for (kind, tracks) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] {
|
||
for (ti, tr) in tracks.iter().enumerate() {
|
||
for trn in &tr.transitions {
|
||
let from = trn.from.map(|c| ids.get(&c).copied());
|
||
let to = trn.to.map(|c| ids.get(&c).copied());
|
||
let inside = range.is_none_or(|r| trn.start >= r.start && trn.end() <= r.end());
|
||
if inside && from != Some(None) && to != Some(None) {
|
||
let mut t = trn.clone();
|
||
t.id = filmcraft_project::TransitionId(pr.alloc_id());
|
||
t.from = from.flatten();
|
||
t.to = to.flatten();
|
||
t.start -= offset;
|
||
transitions.push((kind, ti, t));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let nq = pr.sequence_mut(nid).ok_or(EngineError::NoSequence)?;
|
||
lay_out_tracks_like(nq, &q);
|
||
for (kind, ti, it) in placed {
|
||
nq.tracks_mut(kind)[ti].items.push(it);
|
||
}
|
||
for (kind, ti, t) in transitions {
|
||
nq.tracks_mut(kind)[ti].transitions.push(t);
|
||
}
|
||
for tr in nq.all_tracks_mut() {
|
||
tr.sort();
|
||
edit::remove_orphan_transitions(tr);
|
||
}
|
||
nq.check().map_err(EngineError::Other)?;
|
||
st.project_selection = vec![nid];
|
||
// like Premiere: the subsequence is loaded in the Source Monitor, ready to edit from
|
||
st.source_item = Some(nid);
|
||
st.source_playhead = Tick::ZERO;
|
||
Ok(nid)
|
||
})?;
|
||
s.events.push(crate::Event::OpenSource(id));
|
||
Ok(json!({"sequence": id.0, "name": name}))
|
||
}
|
||
|
||
// ---------- a sequence edited in as its clips ----------
|
||
|
||
/// The nest toggle off ("Insert and overwrite sequences as nests or individual clips"): edit the
|
||
/// clips of sequence `item` that lie in `range` of it into the active sequence at `at`, instead of
|
||
/// one nested clip. As in Premiere, the clips come with their transitions and links; the source's
|
||
/// tracks that hold clips go to consecutive tracks from `vdest` / `adest` up (source V1 and V3
|
||
/// land on the destination track and the one above it), and tracks that are missing are added.
|
||
/// A kind without a destination track is left out.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn place_sequence_clips(
|
||
s: &mut Session,
|
||
item: ItemId,
|
||
range: TimeRange,
|
||
at: Tick,
|
||
vdest: Option<TrackId>,
|
||
adest: Option<TrackId>,
|
||
insert: bool,
|
||
label: &str,
|
||
) -> Result<Vec<ClipId>> {
|
||
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
||
let src = s.project.sequence(item).ok_or_else(|| bad(label, "not a sequence"))?.clone();
|
||
let dst = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||
// (kind, how many tracks above the destination track, the clip trimmed to the range)
|
||
let mut items: Vec<(TrackKind, usize, TrackItem)> = Vec::new();
|
||
// source track index → tracks above the destination track, per kind
|
||
// (TrackKind has no Hash: the maps are keyed by "is audio")
|
||
let audio = |k: TrackKind| k == TrackKind::Audio;
|
||
let mut lanes: std::collections::HashMap<(bool, usize), usize> = std::collections::HashMap::new();
|
||
let mut base: std::collections::HashMap<bool, usize> = std::collections::HashMap::new();
|
||
for (kind, tracks, dest) in [(TrackKind::Video, &src.video_tracks, vdest), (TrackKind::Audio, &src.audio_tracks, adest)] {
|
||
let Some(first) = dest.and_then(|d| dst.tracks(kind).iter().position(|t| t.id == d)) else { continue };
|
||
base.insert(audio(kind), first);
|
||
let mut lane = 0;
|
||
for (ti, tr) in tracks.iter().enumerate() {
|
||
let clips: Vec<TrackItem> = tr.items.iter().filter_map(|it| edit::through::clip_item_to(it, range)).collect();
|
||
if clips.is_empty() {
|
||
continue;
|
||
}
|
||
lanes.insert((audio(kind), ti), lane);
|
||
items.extend(clips.into_iter().map(|c| (kind, lane, c)));
|
||
lane += 1;
|
||
}
|
||
}
|
||
if items.is_empty() {
|
||
return Err(EngineError::Other("no destination track for this sequence's clips (check source patching)".into()));
|
||
}
|
||
let media = s.media.clone();
|
||
s.edit(label, |p, st| {
|
||
// copies with ids, links and transitions of their own, moved to `at`
|
||
let mut ids = std::collections::HashMap::new();
|
||
let mut links = std::collections::HashMap::new();
|
||
let mut placed: Vec<(TrackKind, usize, TrackItem)> = Vec::new();
|
||
for (kind, lane, mut it) in items.clone() {
|
||
let id = ClipId(p.alloc_id());
|
||
ids.insert(it.id, id);
|
||
it.id = id;
|
||
it.start = at + (it.start - range.start);
|
||
if let Some(l) = it.link {
|
||
it.link = Some(*links.entry(l).or_insert_with(|| p.alloc_id()));
|
||
}
|
||
placed.push((kind, lane, it));
|
||
}
|
||
let mut transitions = Vec::new();
|
||
for (kind, tracks) in [(TrackKind::Video, &src.video_tracks), (TrackKind::Audio, &src.audio_tracks)] {
|
||
for (ti, tr) in tracks.iter().enumerate() {
|
||
let Some(lane) = lanes.get(&(audio(kind), ti)).copied() else { continue };
|
||
for trn in &tr.transitions {
|
||
let from = trn.from.map(|c| ids.get(&c).copied());
|
||
let to = trn.to.map(|c| ids.get(&c).copied());
|
||
if trn.start >= range.start && trn.end() <= range.end() && from != Some(None) && to != Some(None) {
|
||
let mut t = trn.clone();
|
||
t.id = filmcraft_project::TransitionId(p.alloc_id());
|
||
(t.from, t.to) = (from.flatten(), to.flatten());
|
||
t.start = at + (t.start - range.start);
|
||
transitions.push((kind, lane, t));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// tracks for every lane, added on top when the sequence has too few
|
||
let mut dest: std::collections::HashMap<(bool, usize), TrackId> = std::collections::HashMap::new();
|
||
for (is_audio, lane) in placed.iter().map(|x| (audio(x.0), x.1)).collect::<std::collections::BTreeSet<_>>() {
|
||
let kind = if is_audio { TrackKind::Audio } else { TrackKind::Video };
|
||
let index = base.get(&is_audio).copied().unwrap_or(0).saturating_add(lane);
|
||
while p.sequence(seq_id).ok_or(EngineError::NoSequence)?.tracks(kind).len() <= index {
|
||
let id = TrackId(p.alloc_id());
|
||
let tracks = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?.tracks_mut(kind);
|
||
let (word, n) = (if kind == TrackKind::Video { "Video" } else { "Audio" }, tracks.len() + 1);
|
||
tracks.push(filmcraft_project::Track::new(id, kind, format!("{word} {n}")));
|
||
}
|
||
let id = p.sequence(seq_id).and_then(|q| q.tracks(kind).get(index)).map(|t| t.id).ok_or(EngineError::NoSequence)?;
|
||
dest.insert((is_audio, lane), id);
|
||
}
|
||
let placements: Vec<(TrackId, TrackItem)> = placed.into_iter().filter_map(|(k, lane, it)| Some((*dest.get(&(audio(k), lane))?, it))).collect();
|
||
let span = placements.iter().map(|x| x.1.start).min().zip(placements.iter().map(|x| x.1.end()).max());
|
||
let snapshot = std::sync::Arc::new(p.clone());
|
||
let snap = snapshot.clone();
|
||
let durations = move |id: ItemId| crate::media_duration(&snapshot, &media, id);
|
||
let starts = move |id: ItemId| crate::media_start(&snap, id);
|
||
let mut next = p.next_id;
|
||
let seq = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
||
let min = seq.settings.frame_rate.frame_duration();
|
||
let mut ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: min };
|
||
let new = if insert { edit::insert(seq, placements, &mut ctx)? } else { edit::overwrite(seq, placements, &mut ctx)? };
|
||
for (kind, lane, t) in transitions {
|
||
if let Some(tr) = dest.get(&(audio(kind), lane)).and_then(|id| seq.track_mut(*id)) {
|
||
tr.transitions.push(t);
|
||
}
|
||
}
|
||
for tr in seq.all_tracks_mut() {
|
||
tr.sort();
|
||
edit::remove_orphan_transitions(tr);
|
||
}
|
||
seq.check().map_err(EngineError::Other)?;
|
||
if insert
|
||
&& st.ripple_sequence_markers
|
||
&& let Some((a, b)) = span
|
||
{
|
||
ripple_markers(&mut seq.markers, a, b - a);
|
||
}
|
||
p.next_id = next;
|
||
st.selection = new.clone();
|
||
Ok(new)
|
||
})
|
||
}
|
||
|
||
// ---------- delete tracks ----------
|
||
|
||
/// 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)?;
|
||
let mut doomed: Vec<TrackId> = Vec::new();
|
||
for (key, kind) in [("video", TrackKind::Video), ("audio", TrackKind::Audio)] {
|
||
let tracks = q.tracks(kind);
|
||
let mut mine: Vec<TrackId> = match p.get(key) {
|
||
None | Some(Value::Null) => continue,
|
||
Some(v) if v.as_str().is_some_and(|x| matches!(x, "empty" | "allEmpty" | "all-empty")) => {
|
||
tracks.iter().filter(|t| t.items.is_empty()).map(|t| t.id).collect()
|
||
}
|
||
Some(_) => {
|
||
let id = track_p(s, p, key, "sequence.deleteTracks")?.ok_or_else(|| bad("sequence.deleteTracks", format!("unknown {key} track")))?;
|
||
if !tracks.iter().any(|t| t.id == id) {
|
||
return Err(bad("sequence.deleteTracks", format!("`{key}` must name a {key} track")));
|
||
}
|
||
vec![id]
|
||
}
|
||
};
|
||
if mine.len() == tracks.len() {
|
||
// keep the first one
|
||
mine.retain(|id| *id != tracks[0].id);
|
||
}
|
||
doomed.extend(mine);
|
||
}
|
||
if doomed.is_empty() {
|
||
return Err(EngineError::Other("no tracks to delete".into()));
|
||
}
|
||
let n = doomed.len();
|
||
s.edit_sequence("Delete Tracks", |q, _, st| {
|
||
q.video_tracks.retain(|t| !doomed.contains(&t.id));
|
||
q.audio_tracks.retain(|t| !doomed.contains(&t.id));
|
||
st.selection.retain(|c| q.find_item(*c).is_some());
|
||
if let Some(tg) = st.targeting.get_mut(&seq_id) {
|
||
tg.targeted.retain(|t| !doomed.contains(t));
|
||
if tg.video_dest.is_some_and(|t| doomed.contains(&t)) {
|
||
tg.video_dest = q.video_tracks.first().map(|t| t.id);
|
||
}
|
||
if tg.audio_dest.is_some_and(|t| doomed.contains(&t)) {
|
||
tg.audio_dest = q.audio_tracks.first().map(|t| t.id);
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"deleted": n}))
|
||
}
|
||
|
||
// ---------- split points ----------
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum Side {
|
||
VideoIn,
|
||
VideoOut,
|
||
AudioIn,
|
||
AudioOut,
|
||
}
|
||
|
||
impl Side {
|
||
fn label(self) -> &'static str {
|
||
match self {
|
||
Side::VideoIn => "Video In",
|
||
Side::VideoOut => "Video Out",
|
||
Side::AudioIn => "Audio In",
|
||
Side::AudioOut => "Audio Out",
|
||
}
|
||
}
|
||
/// Set this split point; a split point of the same channel on the wrong side of it is cleared.
|
||
fn set(self, m: &mut SplitMarks, t: Tick) {
|
||
match self {
|
||
Side::VideoIn => {
|
||
m.video_in = Some(t);
|
||
m.video_out = m.video_out.filter(|o| *o >= t);
|
||
}
|
||
Side::VideoOut => {
|
||
m.video_out = Some(t);
|
||
m.video_in = m.video_in.filter(|i| *i <= t);
|
||
}
|
||
Side::AudioIn => {
|
||
m.audio_in = Some(t);
|
||
m.audio_out = m.audio_out.filter(|o| *o >= t);
|
||
}
|
||
Side::AudioOut => {
|
||
m.audio_out = Some(t);
|
||
m.audio_in = m.audio_in.filter(|i| *i <= t);
|
||
}
|
||
}
|
||
}
|
||
/// The effective point (the split point, or the ordinary In/Out).
|
||
fn get(self, m: &SplitMarks, mark_in: Option<Tick>, mark_out: Option<Tick>) -> Option<Tick> {
|
||
match self {
|
||
Side::VideoIn => m.video_in_or(mark_in),
|
||
Side::VideoOut => m.video_out_or(mark_out),
|
||
Side::AudioIn => m.audio_in_or(mark_in),
|
||
Side::AudioOut => m.audio_out_or(mark_out),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn source_marks(s: &Session, item: ItemId) -> (Option<Tick>, Option<Tick>) {
|
||
match s.project.item(item).map(|i| &i.kind) {
|
||
Some(filmcraft_project::ItemKind::Media(m)) => (m.mark_in, m.mark_out),
|
||
Some(filmcraft_project::ItemKind::Sequence(q)) => (q.mark_in, q.mark_out),
|
||
_ => (None, None),
|
||
}
|
||
}
|
||
|
||
fn mark_split(s: &mut Session, p: &Value, side: Side) -> Result<Value> {
|
||
let label = format!("Mark Split {}", side.label());
|
||
if str_p(p, "target") == Some("source") {
|
||
let item = s.state.source_item.ok_or_else(|| EngineError::Other("no clip in the Source monitor".into()))?;
|
||
let t = p.get("time").and_then(Value::as_i64).map(Tick).unwrap_or(s.state.source_playhead);
|
||
let split = s.edit(&label, |pr, _| {
|
||
let it = pr.item_mut(item).ok_or_else(|| EngineError::Other("no such item".into()))?;
|
||
side.set(&mut it.split, t);
|
||
Ok(it.split)
|
||
})?;
|
||
return Ok(serde_json::to_value(split).unwrap_or_default());
|
||
}
|
||
s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||
let split = s.edit_sequence(&label, |q, _, _| {
|
||
side.set(&mut q.split, t);
|
||
Ok(q.split)
|
||
})?;
|
||
Ok(serde_json::to_value(split).unwrap_or_default())
|
||
}
|
||
|
||
fn go_to_split(s: &mut Session, p: &Value, side: Side) -> Result<Value> {
|
||
let none = || EngineError::Other(format!("there is no {} point", side.label()));
|
||
if str_p(p, "target") == Some("source") {
|
||
let item = s.state.source_item.ok_or_else(|| EngineError::Other("no clip in the Source monitor".into()))?;
|
||
let (mi, mo) = source_marks(s, item);
|
||
let split = s.project.item(item).map(|i| i.split).unwrap_or_default();
|
||
let t = side.get(&split, mi, mo).ok_or_else(none)?;
|
||
s.state.source_playhead = t;
|
||
return Ok(json!({"time": t.0}));
|
||
}
|
||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||
let t = side.get(&q.split, q.mark_in, q.mark_out).ok_or_else(none)?;
|
||
s.set_playhead(t);
|
||
Ok(json!({"time": s.playhead().0}))
|
||
}
|
||
|
||
/// Source ranges for Insert / Overwrite with split points: (video range, audio range). Equal when
|
||
/// the source has no split points. `full` is the range the ordinary In/Out give.
|
||
pub(crate) fn split_source_ranges(s: &Session, item: ItemId, full: TimeRange) -> (TimeRange, TimeRange) {
|
||
let Some(pi) = s.project.item(item) else { return (full, full) };
|
||
let sp = pi.split;
|
||
if sp.is_empty() {
|
||
return (full, full);
|
||
}
|
||
let fd = pi.frame_rate().frame_duration();
|
||
let range = |i: Option<Tick>, o: Option<Tick>| {
|
||
let a = i.unwrap_or(full.start);
|
||
let b = o.map(|o| o + fd).unwrap_or(full.end());
|
||
TimeRange::from_bounds(a, b.max(a + fd))
|
||
};
|
||
(range(sp.video_in, sp.video_out), range(sp.audio_in, sp.audio_out))
|
||
}
|
||
|
||
// ---------- markers ----------
|
||
|
||
/// Move sequence markers for a ripple edit at `from` by `shift`: later markers move; with a
|
||
/// negative shift, markers in the removed span `[from + shift, from)` are deleted.
|
||
pub fn ripple_markers(markers: &mut Vec<Marker>, from: Tick, shift: Tick) {
|
||
if shift == Tick::ZERO {
|
||
return;
|
||
}
|
||
if shift < Tick::ZERO {
|
||
markers.retain(|m| !(m.start >= from + shift && m.start < from));
|
||
}
|
||
for m in markers.iter_mut().filter(|m| m.start >= from) {
|
||
m.start += shift;
|
||
}
|
||
markers.sort_by_key(|m| m.start);
|
||
}
|
||
|
||
fn add_marker(s: &mut Session, label: &str, t: Tick, dur: Tick, kind: MarkerKind, name: String, p: &Value) -> Result<MarkerId> {
|
||
let comment = str_p(p, "comment").unwrap_or("").to_string();
|
||
let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green);
|
||
s.edit_sequence(label, |q, ctx, _| {
|
||
let id = MarkerId(ctx.alloc());
|
||
q.markers.push(Marker { id, start: t, duration: dur, name, comment, kind, color });
|
||
q.markers.sort_by_key(|m| m.start);
|
||
Ok(id)
|
||
})
|
||
}
|
||
|
||
/// Markers ▸ Add Range Marker: a marker with a duration (one second unless `durationFrames` or
|
||
/// `duration` say otherwise) at the playhead.
|
||
fn add_range_marker(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let rate = s.sequence_rate();
|
||
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
||
let dur = p
|
||
.get("duration")
|
||
.and_then(Value::as_i64)
|
||
.map(Tick)
|
||
.or_else(|| p.get("durationFrames").and_then(Value::as_i64).map(|f| rate.tick_of(f)))
|
||
.unwrap_or(rate.tick_of(rate.timecode_base()));
|
||
if dur <= Tick::ZERO {
|
||
return Err(bad("markers.addRange", "the duration must be positive"));
|
||
}
|
||
let name = str_p(p, "name").unwrap_or("").to_string();
|
||
let id = add_marker(s, "Add Range Marker", t, dur, MarkerKind::Comment, name, p)?;
|
||
Ok(json!({"marker": id.0}))
|
||
}
|
||
|
||
/// Markers ▸ Add Range Marker to In and Out: a marker spanning the sequence In to Out (inclusive).
|
||
fn add_range_marker_in_out(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
||
let (Some(i), Some(o)) = (q.mark_in, q.mark_out) else { return Err(EngineError::Other("mark an In and an Out point first".into())) };
|
||
let dur = o + q.settings.frame_rate.frame_duration() - i;
|
||
let name = str_p(p, "name").unwrap_or("").to_string();
|
||
let id = add_marker(s, "Add Range Marker", i, dur, MarkerKind::Comment, name, p)?;
|
||
Ok(json!({"marker": id.0}))
|
||
}
|
||
|
||
fn filter_colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||
if let Some(a) = p.get("hidden").and_then(Value::as_array) {
|
||
let mut v = Vec::new();
|
||
for x in a {
|
||
let n = x.as_str().unwrap_or_default();
|
||
v.push(Label::from_name(n).ok_or_else(|| bad("markers.filterColors", format!("unknown colour `{n}`")))?);
|
||
}
|
||
s.state.hidden_marker_colors = v;
|
||
} else if let Some(n) = str_p(p, "color") {
|
||
let c = Label::from_name(n).ok_or_else(|| bad("markers.filterColors", format!("unknown colour `{n}`")))?;
|
||
let hidden = s.state.hidden_marker_colors.contains(&c);
|
||
let visible = bool_p(p, "visible").unwrap_or(hidden);
|
||
s.state.hidden_marker_colors.retain(|x| *x != c);
|
||
if !visible {
|
||
s.state.hidden_marker_colors.push(c);
|
||
}
|
||
} else {
|
||
return Err(bad("markers.filterColors", "need `hidden` or `color`"));
|
||
}
|
||
Ok(json!({"hidden": s.state.hidden_marker_colors.iter().map(|c| c.name()).collect::<Vec<_>>()}))
|
||
}
|