Files
filmcraft/crates/engine/src/commands.rs
T
2026-10-10 05:47:31 -04:00

3516 lines
181 KiB
Rust

//! The command registry. Ids follow Premiere's menu structure; every menu item, panel button,
//! shortcut and timeline gesture maps to exactly one of these.
use std::sync::OnceLock;
use filmcraft_edit as edit;
use filmcraft_edit::{Edge, TrimMode};
use filmcraft_media::Generator;
use filmcraft_media::generators::GeneratorSource;
use filmcraft_project::{
ClipId, ItemId, ItemKind, Label, Marker, MarkerId, MarkerKind, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, Track, TrackId, TrackKind,
Transition, TransitionId, resolve_auto_points,
};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange, parse_timecode};
use serde_json::{Value, json};
use crate::{EngineError, Result, Session};
type Run = fn(&mut Session, &Value) -> Result<Value>;
type Enabled = fn(&Session) -> std::result::Result<(), String>;
/// Metadata + implementation for one command.
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Menu placement, e.g. `["Sequence"]` or `["File", "New"]`. Empty = not in menus.
pub menu: &'static [&'static str],
/// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere).
pub shortcut: Option<&'static str>,
/// Parameter description (JSON-ish, for agents and the MCP schema).
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
/// Record in the journal (false for pure queries).
pub journal: bool,
}
macro_rules! cmd {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
};
}
macro_rules! query {
($id:literal, $label:literal, $params:literal, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: always, run: $run, journal: false }
};
}
pub fn command_specs() -> &'static [CommandSpec] {
static SPECS: OnceLock<Vec<CommandSpec>> = OnceLock::new();
SPECS.get_or_init(build)
}
pub fn find(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
}
// ---------- enablement ----------
pub(crate) fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
fn has_edit_points(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if s.state.edit_points.is_empty() { Err("no edit points selected".into()) } else { Ok(()) }
}
fn has_saved_path(s: &Session) -> std::result::Result<(), String> {
if s.path.is_some() { Ok(()) } else { Err("the project has not been saved yet".into()) }
}
fn has_recovery(s: &Session) -> std::result::Result<(), String> {
if s.recovery_candidates().is_empty() { Err("there are no unsaved changes to recover".into()) } else { Ok(()) }
}
pub(crate) fn has_seq(s: &Session) -> std::result::Result<(), String> {
s.active_sequence().map(|_| ()).ok_or_else(|| "no sequence is open".into())
}
pub(crate) fn has_selection(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if s.state.selection.is_empty() { Err("no clips selected".into()) } else { Ok(()) }
}
/// Clips or captions selected (Clear / Ripple Delete work on either).
fn has_any_selection(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if s.state.selection.is_empty() && s.state.caption_selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
}
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 can_undo(s: &Session) -> std::result::Result<(), String> {
if s.history.can_undo() { Ok(()) } else { Err("nothing to undo".into()) }
}
fn can_redo(s: &Session) -> std::result::Result<(), String> {
if s.history.can_redo() { Ok(()) } else { Err("nothing to redo".into()) }
}
fn has_in_out(s: &Session) -> std::result::Result<(), String> {
let seq = s.active_sequence().ok_or("no sequence is open")?;
if seq.mark_in.is_some() || seq.mark_out.is_some() { Ok(()) } else { Err("mark an In or Out point first".into()) }
}
fn has_previews(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if s.previews.count() == 0 { Err("there are no render files".into()) } else { Ok(()) }
}
pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), String> {
if s.state.project_selection.is_empty() { Err("select an item in the Project panel".into()) } else { Ok(()) }
}
fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if s.state.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) }
}
/// The ids a caller named under `many` (a list) or `one` (a single id), when the command documents
/// that key as ids (`"clips":[id]`, `"item":id`); `None` when it named none.
fn named_ids(spec: &CommandSpec, p: &Value, many: &str, one: &str) -> Option<Vec<u64>> {
let ids: Vec<u64> = if spec.params.contains(&format!("\"{many}\":[id]"))
&& let Some(a) = p.get(many).and_then(Value::as_array)
{
a.iter().filter_map(Value::as_u64).collect()
} else if spec.params.contains(&format!("\"{one}\":id")) {
u64_p(p, one).into_iter().collect()
} else {
Vec::new()
};
(!ids.is_empty()).then_some(ids)
}
/// Timeline clips named explicitly in `clips` / `clip` (see [`Session::execute`]): only clips of
/// the active sequence count, so an id of nothing does not stand in for a selection.
pub(crate) fn named_clips(s: &Session, spec: &CommandSpec, p: &Value) -> Option<Vec<ClipId>> {
let seq = s.active_sequence()?;
let clips: Vec<ClipId> = named_ids(spec, p, "clips", "clip")?.into_iter().map(ClipId).filter(|c| seq.find_item(*c).is_some()).collect();
(!clips.is_empty()).then_some(clips)
}
/// Project items named explicitly in `items` / `item`, likewise. The Project panel's selection
/// holds bins beside items (one id space, see `project.select`), so a bin named here counts too;
/// the project's own top bin does not, since no command acts on it (#244).
pub(crate) fn named_items(s: &Session, spec: &CommandSpec, p: &Value) -> Option<Vec<ItemId>> {
let root = s.project.root.id;
let exists = |i: &ItemId| {
let b = filmcraft_project::BinId(i.0);
s.project.item(*i).is_some() || (b != root && s.project.root.find_bin(b).is_some())
};
let items: Vec<ItemId> = named_ids(spec, p, "items", "item")?.into_iter().map(ItemId).filter(exists).collect();
(!items.is_empty()).then_some(items)
}
// ---------- param helpers ----------
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
p.get(k).and_then(Value::as_str)
}
pub(crate) fn f64_p(p: &Value, k: &str) -> Option<f64> {
p.get(k).and_then(Value::as_f64)
}
/// `fps` as a sequence frame rate; zero or negative rates are refused (all frame maths divides by them).
fn fps_p(p: &Value, cmd: &str) -> Result<Option<FrameRate>> {
let Some(fps) = f64_p(p, "fps") else { return Ok(None) };
let r = FrameRate::from_f64(fps);
if r.num <= 0 || r.frame_duration() <= Tick::ZERO {
return Err(bad(cmd, format!("fps must be a positive frame rate, got {fps}")));
}
Ok(Some(r))
}
pub(crate) fn bool_p(p: &Value, k: &str) -> Option<bool> {
p.get(k).and_then(Value::as_bool)
}
pub(crate) fn u64_p(p: &Value, k: &str) -> Option<u64> {
p.get(k).and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
}
/// Typed dimensions/counts must not truncate, wrap or silently accept negative/fractional values.
/// Integer-valued floats (`1920.0`, as JSON from many clients) are integers.
pub(crate) fn checked_u32_p(p: &Value, key: &str, cmd: &str) -> Result<Option<u32>> {
let Some(value) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
let exact = value
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.or_else(|| value.as_f64().filter(|f| f.is_finite() && f.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(f)).map(|f| f as u32));
exact.map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be an unsigned 32-bit integer")))
}
/// Parse a time from params: `time` (ticks), `frame`, `seconds` or `timecode`, with `prefix`.
pub(crate) fn time_p(s: &Session, p: &Value, prefix: &str) -> Option<Tick> {
let rate = s.sequence_rate();
let k = |n: &str| if prefix.is_empty() { n.to_string() } else { format!("{prefix}{}{}", n[..1].to_uppercase(), &n[1..]) };
if let Some(t) = p.get(k("time")).and_then(Value::as_i64) {
return Some(Tick(t));
}
if let Some(f) = p.get(k("frame")).and_then(Value::as_i64) {
return Some(rate.tick_of(f));
}
if let Some(sec) = p.get(k("seconds")).and_then(Value::as_f64) {
return Some(Tick::from_seconds_f64(sec));
}
if let Some(tc) = p.get(k("timecode")).and_then(Value::as_str) {
let df = s.active_sequence().map(|q| q.settings.drop_frame).unwrap_or(false);
let cur = rate.frame_at(s.playhead());
return parse_timecode(tc, rate, df, cur).ok().map(|f| rate.tick_of(f));
}
None
}
pub(crate) fn clip_p(p: &Value, k: &str) -> Option<ClipId> {
u64_p(p, k).map(ClipId)
}
/// Effect by id or display name. Names shared by several definitions ("Invert", "Volume",
/// "Channel Volume") resolve to an applicable (non-intrinsic) effect whose kind matches the
/// target clips' tracks.
fn resolve_effect_name(s: &Session, p: &Value, name: &str) -> Option<&'static filmcraft_project::EffectDef> {
if let Some(d) = filmcraft_project::find_effect(name) {
return Some(d);
}
let n = name.to_ascii_lowercase();
let cands: Vec<_> = filmcraft_project::effect_defs().iter().filter(|e| e.name.to_ascii_lowercase() == n).collect();
if cands.len() <= 1 {
return cands.first().copied().or_else(|| filmcraft_project::find_effect(&n));
}
let on_audio = s.active_sequence().is_some_and(|q| {
let clips = clips_p(s, p);
!clips.is_empty() && clips.iter().all(|c| q.find_item(*c).and_then(|(t, _)| q.track(t)).is_some_and(|t| t.kind == TrackKind::Audio))
});
let want = if on_audio { filmcraft_project::EffectKind::Audio } else { filmcraft_project::EffectKind::Video };
cands.iter().find(|d| !d.intrinsic && d.kind == want).or_else(|| cands.iter().find(|d| !d.intrinsic)).or(cands.first()).copied()
}
pub(crate) fn clips_p(s: &Session, p: &Value) -> Vec<ClipId> {
match p.get("clips").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect(),
None => clip_p(p, "clip").map(|c| vec![c]).unwrap_or_else(|| s.state.selection.clone()),
}
}
/// The track parameter `k` names: a track id, or "V1" / "A2" (kind and 1-based number).
/// `Ok(None)` when the parameter is absent. Naming a track the active sequence does not have is
/// an error (bad parameters of `cmd`): callers fall back to a default track only when no track
/// was asked for.
pub(crate) fn track_p(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<Option<TrackId>> {
let v = match p.get(k) {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let by_name = |name: &str| {
let mut rest = name.chars();
let tracks = match rest.next()? {
'v' | 'V' => &seq.video_tracks,
'a' | 'A' => &seq.audio_tracks,
_ => return None,
};
let i: usize = rest.as_str().parse().ok()?;
tracks.get(i.checked_sub(1)?).map(|t| t.id)
};
let found = match v.as_u64() {
Some(id) => seq.track(TrackId(id)).map(|t| t.id),
None => v.as_str().and_then(by_name),
};
let named = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
found.map(Some).ok_or_else(|| bad(cmd, format!("no track {named} in this sequence (`{k}`)")))
}
pub(crate) fn item_p(p: &Value, k: &str) -> Option<ItemId> {
u64_p(p, k).map(ItemId)
}
/// `timeline.move`: move the listed clips to (track, time). With Linked Selection on (or
/// `linked: true`) the linked partners of a listed clip that are not listed themselves follow by
/// the same time offset on their own tracks, so picture and sound stay in sync; a partner on a
/// locked track, or one whose position would not change, stays as it is. Nothing moves before the sequence start: when a clip would,
/// every clip of the call lands later by the same amount, so the spacing asked for is kept.
/// `overwritten` lists every clip the move covered without being asked to move it: its length
/// before and after (ticks) and the clips what is left of it now lives on as (none = removed; a
/// clip a moved clip lands inside is split in two).
fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "timeline.move";
let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new();
for m in p.get("moves").and_then(Value::as_array).into_iter().flatten() {
let track = track_p(s, m, "track", CMD)?;
if let (Some(clip), Some(track), Some(time)) = (clip_p(m, "clip"), track, m.get("time").and_then(Value::as_i64)) {
listed.push((clip, track, Tick(time)));
}
}
if listed.is_empty() {
return Err(bad(CMD, "need `moves`"));
}
let follow = bool_p(p, "linked").unwrap_or(s.state.linked_selection);
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
// (clip, destination track, new start, where a partner starts now). `time` is any i64: the
// sums are done in i128, where a few i64 values cannot overflow, and checked on the way back.
let mut plan: Vec<(ClipId, TrackId, i128, Option<i128>)> = Vec::new();
for &(clip, track, time) in &listed {
plan.push((clip, track, i128::from(time.0), None));
let Some((_, it)) = q.find_item(clip) else { continue }; // `edit::move_items` reports it
let Some(link) = it.link.filter(|_| follow) else { continue };
let offset = i128::from(time.0) - i128::from(it.start.0);
for t in q.all_tracks().filter(|t| !t.locked) {
for partner in t.items.iter().filter(|i| i.link == Some(link)) {
if !listed.iter().any(|m| m.0 == partner.id) && !plan.iter().any(|m| m.0 == partner.id) {
plan.push((partner.id, t.id, i128::from(partner.start.0) + offset, Some(i128::from(partner.start.0))));
}
}
}
}
let late = plan.iter().map(|m| -m.2).max().unwrap_or(0).max(0);
let mut moves: Vec<(ClipId, TrackId, Tick)> = Vec::with_capacity(plan.len());
for (clip, track, start, was) in plan {
// a partner that ends up where it is does not move (its listed clip only changed track):
// re-placing it would drop the transitions at its edges
if was == Some(start + late) {
continue;
}
let start = i64::try_from(start + late).ok().map(Tick).filter(|t| *t <= Tick::MAX).ok_or_else(|| bad(CMD, "`time` is out of range"))?;
moves.push((clip, track, start));
}
let ins = bool_p(p, "insert").unwrap_or(false);
// every clip on the sequence now: id -> (track, start, end, project item)
let spots = |s: &Session| -> Vec<(u64, TrackId, Tick, Tick, ItemId)> {
let q = s.active_sequence();
q.into_iter().flat_map(|q| q.all_tracks()).flat_map(|t| t.items.iter().map(move |i| (i.id.0, t.id, i.start, i.end(), i.item))).collect()
};
let before = spots(s);
s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?;
// An overwrite shortens, splits or removes whatever already sits where a clip lands, and a
// shortened clip may live on under a new id: name each clip the move changed without being
// asked to, with what is left of it, so the caller hears about it instead of finding out later.
let after = spots(s);
let moved = |c: u64| moves.iter().any(|m| m.0.0 == c);
let new = |c: u64| !before.iter().any(|b| b.0 == c);
let mut overwritten = Vec::new();
for &(c, track, start, end, item) in before.iter().filter(|b| !moved(b.0)) {
let kept = after.iter().find(|a| a.0 == c);
if ins || kept.is_some_and(|k| (k.2, k.3) == (start, end)) {
continue; // untouched (an insert only shifts clips, it never covers one)
}
let pieces: Vec<_> =
after.iter().filter(|a| a.0 == c || (new(a.0) && !moved(a.0) && a.1 == track && a.4 == item && a.2 >= start && a.3 <= end)).collect();
let now: i64 = pieces.iter().map(|a| (a.3 - a.2).0).sum();
overwritten.push(json!({"clip": c, "was": (end - start).0, "now": now, "pieces": pieces.iter().map(|a| a.0).collect::<Vec<_>>()}));
}
Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::<Vec<_>>(), "overwritten": overwritten}))
}
/// Expand a clip selection with linked partners (when linked selection is on).
pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
let Some(seq) = s.active_sequence() else { return clips.to_vec() };
let mut out = clips.to_vec();
if s.state.linked_selection {
let links: Vec<u64> = clips.iter().filter_map(|c| seq.find_item(*c).and_then(|(_, i)| i.link)).collect();
for t in seq.all_tracks() {
for i in &t.items {
if i.link.is_some_and(|l| links.contains(&l)) && !out.contains(&i.id) {
out.push(i.id);
}
}
}
}
out
}
/// Sequence settings matching a media clip (New Sequence From Clip, New Sequence from an item):
/// its frame size, rate and pixel aspect ratio (Interpret Footage's, else the file's), so a
/// 1440 x 1080 clip with 4:3 pixels makes a 1440 x 1080 sequence that displays 16:9.
pub(crate) fn default_seq_settings_for(media: &MediaClip) -> SequenceSettings {
let info = &media.info;
let mut st = SequenceSettings::default();
if let Some(v) = &info.video {
st.width = v.width;
st.height = v.height;
st.frame_rate = v.frame_rate;
st.par = media.pixel_aspect();
st.preset = format!("{}x{} {}", v.width, v.height, v.frame_rate.label());
}
if let Some(a) = info.audio() {
st.sample_rate = a.sample_rate.max(8000);
}
st
}
/// Import bytes as a media item (engine-level; the UI reads files through services).
pub fn import_bytes(s: &mut Session, path: &str, bytes: std::sync::Arc<[u8]>, bin: Option<filmcraft_project::BinId>) -> Result<ItemId> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let identity = crate::relink::identity_of_bytes(&bytes);
let src = s.media.open_bytes(&name, bytes)?;
import_source(s, path, name, src, identity, bin)
}
/// Import a media file through the host's random-access reader ([`crate::Services::reader`]):
/// only the container index is read now (web `Blob`s are never copied whole for containers).
pub fn import_streamed(s: &mut Session, path: &str, bin: Option<filmcraft_project::BinId>) -> Result<ItemId> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let identity = crate::relink::identity_of(&*s.services, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let src = s.media.open_file(path, &*s.services)?;
import_source(s, path, name, src, identity, bin)
}
/// Import the image sequence that the numbered still `path` starts (File ▸ Import with Image
/// Sequence): one movie item at Settings ▸ Media ▸ Indeterminate Media Timebase. Returns the item,
/// the frame count and the missing frame numbers (relative to the first).
pub fn import_image_sequence(s: &mut Session, path: &str, bin: Option<filmcraft_project::BinId>) -> Result<(ItemId, usize, Vec<usize>)> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let rate = crate::settings::timebase_rate(&s.prefs.media.indeterminate_timebase);
let frames = crate::media_pool::image_sequence_frames(path, &*s.services)?;
let missing: Vec<usize> = frames.iter().enumerate().filter(|(_, f)| f.is_none()).map(|(i, _)| i).collect();
let src = crate::media_pool::open_image_sequence(path, &*s.services, rate, &name)?;
let identity = crate::relink::identity_of(&*s.services, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let id = import_source(s, path, name, src, identity, bin)?;
Ok((id, frames.len(), missing))
}
/// Settings ▸ Media ▸ Import image sequences: a single numbered still with later frames beside it.
fn detect_image_sequence(s: &Session, path: &str) -> bool {
filmcraft_media::sequence::Numbered::parse(path).is_some() && crate::media_pool::image_sequence_frames(path, &*s.services).is_ok_and(|f| f.len() > 1)
}
fn import_source(
s: &mut Session,
path: &str,
name: String,
src: filmcraft_media::SharedSource,
identity: filmcraft_project::MediaIdentity,
bin: Option<filmcraft_project::BinId>,
) -> Result<ItemId> {
let mut info = src.info().clone();
// Settings ▸ Labels ▸ Label Defaults
let label = s.prefs.labels.for_media(info.kind, info.has_video(), info.has_audio());
// Settings ▸ Media ▸ Indeterminate Media Timebase: the frame rate stills report
if info.kind == filmcraft_media::MediaKind::Still
&& let Some(v) = info.video.as_mut()
{
v.frame_rate = crate::settings::timebase_rate(&s.prefs.media.indeterminate_timebase);
}
let path_s = path.to_string();
let id = s.edit(&format!("Import {name}"), |p, _| {
Ok(p.add_item(
&name,
label,
ItemKind::Media(MediaClip {
media: MediaRef::File { path: path_s },
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: Some(identity),
}),
bin,
))
})?;
s.media.insert_file(id, path, src);
Ok(id)
}
fn new_generator(s: &mut Session, g: Generator, name: &str, label: Label, p: &Value) -> Result<Value> {
let st = s.active_sequence().map(|q| q.settings.clone()).unwrap_or_default();
let w = u64_p(p, "width").map(|v| v as u32).unwrap_or(st.width);
let h = u64_p(p, "height").map(|v| v as u32).unwrap_or(st.height);
let secs = f64_p(p, "seconds").unwrap_or(5.0);
let name = str_p(p, "name").unwrap_or(name).to_string();
let src = GeneratorSource::new(g, w, h, st.frame_rate, Tick::from_seconds_f64(secs));
let pool = s.media.clone();
let par = filmcraft_project::sane_par(st.par);
let id = s.edit(&format!("New {name}"), |pr, st| {
let id = crate::demo::add_generator(pr, &pool, src, &name, label, None);
// made for the sequence: its pixels have the sequence's aspect, as in Premiere
if let Some(ItemKind::Media(m)) = pr.item_mut(id).map(|i| &mut i.kind)
&& let Some(v) = m.info.video.as_mut()
{
v.par = par;
}
st.project_selection = vec![id];
Ok(id)
})?;
Ok(json!({"item": id.0}))
}
/// `project.matteColor`: change a Color Matte's color (`item`, or the one selected in the Project
/// panel). Every clip of the matte follows; undo brings the old color back.
fn set_matte_color(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "project.matteColor";
let color = str_p(p, "color").and_then(filmcraft_color::parse_hex).ok_or_else(|| bad(CMD, "`color` must be #rrggbb"))?;
let is_matte = |s: &Session, id: ItemId| {
s.project.item(id).is_some_and(|it| matches!(&it.kind, ItemKind::Media(m) if matches!(m.media, MediaRef::Generator(Generator::ColorMatte { .. }))))
};
let item = match item_p(p, "item") {
Some(id) => id,
None => match s.state.project_selection.as_slice() {
[id] => *id,
_ => return Err(bad(CMD, "select one Color Matte (or pass `item`)")),
},
};
if !is_matte(s, item) {
return Err(bad(CMD, format!("item {} is not a Color Matte", item.0)));
}
s.edit("Color Matte Color", |pr, _| {
if let Some(ItemKind::Media(m)) = pr.item_mut(item).map(|it| &mut it.kind)
&& let MediaRef::Generator(Generator::ColorMatte { color: c }) = &mut m.media
{
*c = color;
}
Ok(())
})?;
Ok(json!({"item": item.0, "color": filmcraft_color::to_hex(color)}))
}
/// One audio clip of a placement: how many tracks below the destination it goes, and the source
/// channels it plays (`None` = the clip's own default, used for the first one).
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AudioPlacementSpec {
pub track_offset: usize,
pub audio_stream: usize,
pub source_channels: Option<Vec<u16>>,
}
/// The audio clips to place for a source whose Modify ▸ Audio Channels map has `clips` (one entry
/// per clip): always at least one (on the destination track), then one per further entry on the
/// tracks below. The count is capped at [`crate::sequence_tools::MAX_TRACKS`] (the map comes from
/// project files and scripts: never trusted).
pub(crate) fn audio_placement_specs(clips: &[Vec<u16>], streams: usize) -> Vec<AudioPlacementSpec> {
let mut specs = vec![AudioPlacementSpec { track_offset: 0, audio_stream: 0, source_channels: None }];
let extra = clips.iter().skip(1).take(crate::sequence_tools::MAX_TRACKS.saturating_sub(1));
specs.extend(extra.enumerate().map(|(k, chans)| AudioPlacementSpec {
track_offset: k.saturating_add(1),
audio_stream: 0,
source_channels: Some(chans.clone()),
}));
// Interpret Footage's channel map describes stream 0; further container streams keep their
// own channel layouts and get one clip each, after any channel-map clips.
for stream in 1..streams.min(filmcraft_media::MAX_AUDIO_STREAMS) {
if specs.len() >= crate::sequence_tools::MAX_TRACKS {
break;
}
specs.push(AudioPlacementSpec { track_offset: specs.len(), audio_stream: stream, source_channels: None });
}
specs
}
/// Make sure the sequence has audio tracks for `count` clips starting at track index `first`,
/// adding tracks at the bottom when it has fewer. A new track is a copy of the destination track
/// (channel format, volume, pan, inserts) without its clips, transitions, lock / mute / solo
/// state or mixer routing. Returns the ids of all the sequence's audio tracks, in order.
pub(crate) fn ensure_audio_tracks(p: &mut Project, seq_id: ItemId, first: usize, count: usize, label: &str) -> Result<Vec<TrackId>> {
let (have, template) = {
let q = p.sequence(seq_id).ok_or(EngineError::NoSequence)?;
(q.audio_tracks.len(), q.audio_tracks.get(first).or(q.audio_tracks.last()).cloned())
};
let need = first.saturating_add(count);
if need > crate::sequence_tools::MAX_TRACKS {
return Err(bad(label, format!("a sequence has at most {} audio tracks", crate::sequence_tools::MAX_TRACKS)));
}
if need > have {
let ids: Vec<(usize, TrackId)> = (have..need).map(|k| (k, TrackId(p.alloc_id()))).collect();
let q = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
for (k, id) in ids {
let mut t = template.clone().unwrap_or_else(|| Track::new(id, TrackKind::Audio, String::new()));
t.id = id;
t.name = format!("Audio {}", k + 1);
t.items.clear();
t.transitions.clear();
t.locked = false;
t.muted = false;
t.solo = false;
t.mixer = Default::default();
q.audio_tracks.push(t);
}
}
Ok(p.sequence(seq_id).map(|q| q.audio_tracks.iter().map(|t| t.id).collect()).unwrap_or_default())
}
/// Place a project item on the timeline (drag from Project, or Insert/Overwrite from source).
pub(crate) fn place_item(
s: &mut Session,
item: ItemId,
range: TimeRange,
at: Tick,
vdest: Option<TrackId>,
adest: Option<TrackId>,
insert: bool,
label: &str,
audio_split: Option<(TimeRange, Tick)>,
) -> Result<Vec<ClipId>> {
let pi = s.project.item(item).ok_or_else(|| bad(label, "no such item"))?.clone();
// the nest toggle off: a sequence edits in as its clips (a multi-camera source stays one clip)
if s.state.sequences_as_clips && pi.as_sequence().is_some_and(|q| q.multicam.is_none()) {
return crate::sequence_tools::place_sequence_clips(s, item, range, at, vdest, adest, insert, label);
}
let has_v = pi.has_video() && vdest.is_some();
let has_a = pi.has_audio() && adest.is_some();
if !has_v && !has_a {
return Err(EngineError::Other("no destination track for this media (check source patching)".into()));
}
let src_size = match &pi.kind {
ItemKind::Media(m) => m.info.video.as_ref().map(|v| (v.width, v.height)),
ItemKind::Sequence(q) => Some((q.settings.width, q.settings.height)),
ItemKind::AdjustmentLayer { width, height, .. } => Some((*width, *height)),
_ => None,
}
.unwrap_or((1920, 1080));
// `None`: drawn in the pixels of the sequence it is placed in
let src_par = s.project.source_par(item);
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let media = s.media.clone();
let scaling = s.prefs.media.default_media_scaling.clone();
let is_media = matches!(pi.kind, ItemKind::Media(_) | ItemKind::Subclip { .. });
s.edit(label, |p, st| {
let seq = p.sequence(seq_id).ok_or(EngineError::NoSequence)?;
let rate = seq.settings.frame_rate;
let frame = (seq.settings.width, seq.settings.height);
let seq_par = seq.settings.par;
let mut placements = Vec::new();
let link = if has_v && has_a { Some(p.alloc_id()) } else { None };
if let Some(vdest) = vdest.filter(|_| has_v) {
let mut v = p.make_track_item(item, TrackKind::Video, at, range, rate).ok_or_else(|| bad(label, "bad item"))?;
v.link = link;
for e in &mut v.effects {
resolve_auto_points(e, frame, src_size);
}
// the picture's size at its display aspect, in sequence pixels
let shown = filmcraft_project::conformed_size(src_size, src_par.unwrap_or(seq_par), seq_par);
if is_media && shown != (f64::from(frame.0), f64::from(frame.1)) {
crate::settings::apply_media_scaling(&mut v, &scaling, frame, shown);
}
placements.push((vdest, v));
}
if let Some(adest) = adest.filter(|_| has_a) {
let (arange, aat) = audio_split.unwrap_or((range, at));
let mut a = p.make_track_item(item, TrackKind::Audio, aat, arange, rate).ok_or_else(|| bad(label, "bad item"))?;
// Modify ▸ Audio Channels with several audio clips: one per clip, on the tracks below
let clips: Vec<Vec<u16>> =
p.item(item).and_then(|i| i.as_media()).and_then(|m| m.interpret.audio_channels.as_ref()).map(|m| m.clips.clone()).unwrap_or_default();
let streams = p.resolve_media(item).map_or(1, |(_, m, _)| m.info.audio_streams.len());
let specs = audio_placement_specs(&clips, streams);
let first = p.sequence(seq_id).and_then(|q| q.audio_tracks.iter().position(|t| t.id == adest)).unwrap_or(0);
// too few audio tracks below the destination: grow the sequence from the destination track
let tracks = ensure_audio_tracks(p, seq_id, first, specs.len(), label)?;
a.link = if link.is_none() && specs.len() > 1 { Some(p.alloc_id()) } else { link };
for spec in specs {
let tid = match spec.track_offset {
0 => &adest,
off => match first.checked_add(off).and_then(|i| tracks.get(i)) {
Some(t) => t,
None => continue,
},
};
let mut b = a.clone();
b.audio_stream = spec.audio_stream;
if spec.track_offset > 0 {
b.id = ClipId(p.alloc_id());
}
if spec.audio_stream > 0 {
b.source_channels.clear();
}
if let Some(chans) = spec.source_channels {
b.source_channels = chans;
}
placements.push((*tid, b));
}
}
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 min = rate.frame_duration();
let mut next = p.next_id;
let seq = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
let mut ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: min };
let span = placements.iter().map(|x| x.1.start).min().zip(placements.iter().map(|x| x.1.end()).max());
let ids = if insert { edit::insert(seq, placements, &mut ctx)? } else { edit::overwrite(seq, placements, &mut ctx)? };
if insert
&& st.ripple_sequence_markers
&& let Some((a, b)) = span
{
crate::sequence_tools::ripple_markers(&mut seq.markers, a, b - a);
}
p.next_id = next;
st.selection = ids.clone();
Ok(ids)
})
}
pub(crate) fn source_range(s: &Session) -> Option<(ItemId, TimeRange)> {
let item = s.state.source_item?;
let pi = s.project.item(item)?;
let dur = match &pi.kind {
ItemKind::Media(m) if matches!(m.info.kind, filmcraft_media::MediaKind::Still) => s.prefs.timeline.still_duration(s.sequence_rate()),
_ => pi.duration(),
};
let (mi, mo) = match &pi.kind {
ItemKind::Media(m) => (m.mark_in, m.mark_out),
ItemKind::Sequence(q) => (q.mark_in, q.mark_out),
_ => (None, None),
};
// a subclip shows its range of the parent's media (its clips use the parent's media time)
if let ItemKind::Subclip { range, .. } = &pi.kind {
return Some((item, *range));
}
let start = mi.unwrap_or(Tick::ZERO);
let rate = pi.frame_rate();
let end = mo.map(|o| o + rate.frame_duration()).unwrap_or(dur).min(if dur.0 > 0 { dur } else { Tick::MAX });
Some((item, TimeRange::from_bounds(start, end.max(start + rate.frame_duration()))))
}
fn edit_at_source(s: &mut Session, insert: bool) -> Result<Value> {
let (item, range) = source_range(s).ok_or_else(|| EngineError::Other("no source clip".into()))?;
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
// Three-point editing: sequence In wins over the playhead.
let at = seq.mark_in.unwrap_or(s.playhead());
let tg = s.targeting();
let label = if insert { "Insert" } else { "Overwrite" };
// split points: each channel takes its own range, offset by the difference of the In points
let (vr, ar) = crate::sequence_tools::split_source_ranges(s, item, range);
let base = vr.start.min(ar.start);
let (vat, aat) = (at + (vr.start - base), at + (ar.start - base));
let ids = if vr == ar {
place_item(s, item, range, at, tg.video_dest, tg.audio_dest, insert, label, None)?
} else {
place_item(s, item, vr, vat, tg.video_dest, tg.audio_dest, insert, label, Some((ar, aat)))?
};
let end =
if vr == ar { at + s.sequence_rate().snap_nearest(range.duration) } else { s.sequence_rate().snap_nearest((vat + vr.duration).max(aat + ar.duration)) };
s.set_playhead(end);
Ok(json!({"clips": ids.iter().map(|c| c.0).collect::<Vec<_>>()}))
}
fn marker_list_mut(s: &mut Session) -> Result<(ItemId, Tick)> {
let seq = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
Ok((seq, s.playhead()))
}
fn build() -> Vec<CommandSpec> {
let mut v = vec![
// ================= File =================
cmd!("file.newProject", "Project…", ["File", "New"], Some("Cmd+Alt+N"), r#"{"name":str}"#, always, |s, p| {
let name = str_p(p, "name").unwrap_or("Untitled").to_string();
s.project = std::sync::Arc::new(filmcraft_project::Project::new(&name));
s.history = Default::default();
s.history.limit = 200;
s.state = crate::Session::default().state;
if s.path.take().is_some() {
s.previews.reset_temp();
}
s.revision += 1;
s.saved_revision = s.revision;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(Value::Null)
}),
cmd!("file.openDemoProject", "Demo Project", ["File", "New"], None, "{}", always, |s, _| {
let (p, seq) = crate::demo::demo_project(&s.media);
s.project = std::sync::Arc::new(p);
s.history = Default::default();
s.history.limit = 200;
s.state = crate::Session::default().state;
s.state.active_sequence = Some(seq);
s.state.open_sequences = vec![seq];
s.state.playheads.insert(seq, FrameRate::FPS_23_976.tick_of(4 * 24 + 6));
s.revision += 1;
s.saved_revision = s.revision;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
s.events.push(crate::Event::OpenSequence(seq));
Ok(json!({"sequence": seq.0}))
}),
cmd!(
"file.newSequence",
"Sequence…",
["File", "New"],
Some("Cmd+N"),
r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"mix":"Stereo|Mono|5.1|Adaptive"?,"trackType":"Standard|Mono|5.1|Adaptive"?,"fromItem":itemId?}"#,
always,
|s, p| {
let mut st = SequenceSettings::default();
if let Some(from) = item_p(p, "fromItem").and_then(|i| s.project.item(i)).and_then(|i| i.as_media()) {
st = default_seq_settings_for(from);
}
if let Some(w) = checked_u32_p(p, "width", "file.newSequence")? {
st.width = w;
}
if let Some(h) = checked_u32_p(p, "height", "file.newSequence")? {
st.height = h;
}
if let Some(fps) = fps_p(p, "file.newSequence")? {
st.frame_rate = fps;
}
if let Some(sr) = checked_u32_p(p, "sampleRate", "file.newSequence")? {
st.sample_rate = sr;
}
if let Some(m) = str_p(p, "mix") {
st.audio_master =
crate::mixer::channels_from(m).ok_or_else(|| bad("file.newSequence", format!("unknown mix `{m}` (Stereo, Mono, 5.1, Adaptive)")))?;
}
let track_type = match str_p(p, "trackType") {
Some(t) => Some(
crate::mixer::channels_from(t)
.ok_or_else(|| bad("file.newSequence", format!("unknown track type `{t}` (Standard, Mono, 5.1, Adaptive)")))?,
),
None => None,
};
st.preset = format!("{}x{} {}", st.width, st.height, st.frame_rate.label());
st.validate().map_err(|e| bad("file.newSequence", e))?;
let nv = checked_u32_p(p, "video", "file.newSequence")?.unwrap_or(3);
let na = checked_u32_p(p, "audio", "file.newSequence")?.unwrap_or(3);
if nv > 256 || na > 256 {
return Err(bad("file.newSequence", "at most 256 video and 256 audio tracks are supported"));
}
let n = s.project.sequences().count() + 1;
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Sequence {n:02}"));
let seq_label = s.prefs.labels.defaults.sequence;
let id = s.edit("New Sequence", |pr, st2| {
let id = pr.new_sequence(&name, st, nv as usize, na as usize, None);
if let Some(it) = pr.item_mut(id) {
it.label = seq_label;
}
if let (Some(c), Some(q)) = (track_type, pr.sequence_mut(id)) {
q.audio_tracks.iter_mut().for_each(|t| t.channels = c);
}
st2.active_sequence = Some(id);
if !st2.open_sequences.contains(&id) {
st2.open_sequences.push(id);
}
Ok(id)
})?;
if let Some(from) = item_p(p, "fromItem") {
let dur = s.project.item(from).map(|i| i.duration()).unwrap_or_default();
let tg = s.targeting();
place_item(s, from, TimeRange::new(Tick::ZERO, dur), Tick::ZERO, tg.video_dest, tg.audio_dest, false, "New Sequence From Clip", None)?;
}
s.events.push(crate::Event::OpenSequence(id));
Ok(json!({"sequence": id.0}))
}
),
cmd!("file.newBin", "Bin", ["File", "New"], Some("Cmd+B"), r#"{"name":str,"parent":binId?}"#, always, |s, p| {
let n = str_p(p, "name").unwrap_or("New Bin").to_string();
let parent = u64_p(p, "parent").map(filmcraft_project::BinId);
let id = s.edit("New Bin", |pr, _| Ok(pr.add_bin(&n, parent)))?;
Ok(json!({"bin": id.0}))
}),
cmd!("file.newAdjustmentLayer", "Adjustment Layer…", ["File", "New"], None, r#"{"seconds":f64=5}"#, always, |s, p| {
let st = s.active_sequence().map(|q| q.settings.clone()).unwrap_or_default();
let secs = f64_p(p, "seconds").unwrap_or(5.0);
let id = s.edit("New Adjustment Layer", |pr, st2| {
let id = pr.add_item(
"Adjustment Layer",
Label::Lavender,
ItemKind::AdjustmentLayer { width: st.width, height: st.height, rate: st.frame_rate, duration: Tick::from_seconds_f64(secs) },
None,
);
st2.project_selection = vec![id];
Ok(id)
})?;
Ok(json!({"item": id.0}))
}),
cmd!("file.newBarsAndTone", "Bars and Tone…", ["File", "New"], None, r#"{"seconds":f64=10}"#, always, |s, p| new_generator(
s,
Generator::BarsAndTone,
"Bars and Tone",
Label::Lavender,
p
)),
cmd!("file.newBlackVideo", "Black Video…", ["File", "New"], None, r#"{"seconds":f64}"#, always, |s, p| new_generator(
s,
Generator::BlackVideo,
"Black Video",
Label::Lavender,
p
)),
cmd!("file.newColorMatte", "Color Matte…", ["File", "New"], None, r##"{"color":"#rrggbb","seconds":f64}"##, always, |s, p| {
let c = str_p(p, "color").and_then(filmcraft_color::parse_hex).unwrap_or([0.1, 0.1, 0.1, 1.0]);
new_generator(s, Generator::ColorMatte { color: c }, "Color Matte", Label::Lavender, p)
}),
cmd!("project.matteColor", "Color Matte Color…", [], None, r##"{"item":id?,"color":"#rrggbb"}"##, always, set_matte_color),
cmd!("file.newCountingLeader", "Universal Counting Leader…", ["File", "New"], None, "{}", always, |s, p| new_generator(
s,
Generator::CountingLeader,
"Universal Counting Leader",
Label::Lavender,
p
)),
cmd!("file.newTransparentVideo", "Transparent Video…", ["File", "New"], None, "{}", always, |s, p| new_generator(
s,
Generator::TransparentVideo,
"Transparent Video",
Label::Lavender,
p
)),
cmd!(
"file.importDemoFootage",
"Demo Footage",
["File", "Import From"],
None,
r#"{"scene":"OceanSunset|Aurora|CityNight|Dunes|Plasma|Forest"}"#,
always,
|s, p| {
let scenes: Vec<filmcraft_media::DemoScene> = match str_p(p, "scene") {
Some(n) => filmcraft_media::DemoScene::ALL.iter().copied().filter(|d| format!("{d:?}").eq_ignore_ascii_case(n)).collect(),
None => filmcraft_media::DemoScene::ALL.to_vec(),
};
let pool = s.media.clone();
let ids = s.edit("Import Demo Footage", |pr, _| {
Ok(scenes
.iter()
.map(|sc| crate::demo::add_generator(pr, &pool, GeneratorSource::demo(*sc), sc.file_name(), Label::Iris, None))
.collect::<Vec<_>>())
})?;
Ok(json!({"items": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
),
cmd!("file.import", "Import…", ["File"], Some("Cmd+I"), r#"{"paths":[str],"bin":binId?,"imageSequence":bool?}"#, always, |s, p| {
let bin = u64_p(p, "bin").map(filmcraft_project::BinId);
let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
None => str_p(p, "path").map(|x| vec![x.to_string()]).unwrap_or_default(),
};
if paths.is_empty() {
return Err(bad("file.import", "need `paths`"));
}
let mut ids = Vec::new();
let mut errors = Vec::new();
let mut sequences = Vec::new();
let mut reports = Vec::new();
let mut image_sequences = Vec::new();
// Image Sequence: each path is the first frame of a numbered still sequence. With
// Settings ▸ Media ▸ Import image sequences on, a single numbered still is detected.
let as_sequence = p.get("imageSequence").and_then(Value::as_bool).unwrap_or(false);
let detect = !as_sequence && s.prefs.media.import_image_sequences && paths.len() == 1;
for path in paths {
if as_sequence || (detect && detect_image_sequence(s, &path)) {
match import_image_sequence(s, &path, bin) {
Ok((id, frames, missing)) => {
ids.push(id.0);
image_sequences.push(json!({"item": id.0, "frames": frames, "missing": missing}));
}
Err(e) => errors.push(format!("{path}: {e}")),
}
continue;
}
// Media files through the host's reader (no whole-file read) when it has one.
let streamed = filmcraft_media::is_importable(std::path::Path::new(&path)) && s.services.reader(&path).is_some();
let read = if streamed { Ok(Vec::new()) } else { s.services.read_file(&path) };
match read {
Ok(_) if streamed => match import_streamed(s, &path, bin) {
Ok(id) => ids.push(id.0),
Err(e) => errors.push(format!("{path}: {e}")),
},
Ok(b) => {
if let Some(fmt) = crate::captions::detect(&path, &b) {
match crate::captions::import(s, &path, &b, fmt, None) {
Ok(r) => reports.push(r),
Err(e) => errors.push(format!("{path}: {e}")),
}
} else if let Some(fmt) = crate::interchange::detect(&path, &b) {
match crate::interchange::import(s, &path, &b, fmt) {
Ok(r) => {
sequences.extend(r["sequences"].as_array().cloned().unwrap_or_default());
reports.push(r);
}
Err(e) => errors.push(format!("{path}: {e}")),
}
} else {
match import_bytes(s, &path, b.into(), bin) {
Ok(id) => ids.push(id.0),
Err(e) => errors.push(format!("{path}: {e}")),
}
}
}
Err(e) => errors.push(format!("{path}: {e}")),
}
}
if ids.is_empty() && sequences.is_empty() && reports.is_empty() && !errors.is_empty() {
return Err(EngineError::Other(errors.join("; ")));
}
// Project Settings ▸ Ingest: copy / transcode / create proxies
// (image sequences are many files: ingest copies and transcodes single files only)
let item_ids: Vec<ItemId> = ids.iter().filter(|i| !image_sequences.iter().any(|q| q["item"].as_u64() == Some(**i))).map(|i| ItemId(*i)).collect();
// Settings ▸ Media Analysis & Transcription ▸ Automatically transcribe clips
let ma = &s.prefs.media_analysis;
if ma.auto_transcribe && ma.auto_transcribe_scope == "allImported" && !ids.is_empty() {
let audio: Vec<u64> = ids.iter().copied().filter(|i| s.project.item(ItemId(*i)).is_some_and(|it| it.has_audio())).collect();
if !audio.is_empty() {
// without speech-to-text the setting can't act: say so instead of importing
// silently untranscribed (#89)
let r = crate::transcript::can_transcribe(s)
.map_err(EngineError::Other)
.and_then(|()| s.execute("transcript.generate", json!({"items": audio})));
if let Err(e) = r {
errors.push(format!("transcription: {e}"));
}
}
}
let ingest = match crate::proxies::ingest(s, &item_ids) {
Ok(v) => v,
Err(e) => {
errors.push(format!("ingest: {e}"));
Value::Null
}
};
let mut out = if !ingest.is_null() {
json!({"items": ids, "sequences": sequences, "documents": reports, "errors": errors, "ingest": ingest})
} else if reports.is_empty() {
json!({"items": ids, "errors": errors})
} else {
json!({"items": ids, "sequences": sequences, "documents": reports, "errors": errors})
};
if !image_sequences.is_empty() {
out["imageSequences"] = json!(image_sequences);
}
// Newly imported files may resolve paths the project already listed as offline
// (issue #110): rebuild s.offline.missing so the "Media missing" badge clears, and drop
// the cached slate of every item that came back so the monitors show the file again.
let was_missing = s.offline.missing.clone();
crate::relink::refresh(s);
for item in was_missing.iter().filter(|i| !s.offline.missing.contains(i)) {
s.media.remove(*item);
}
Ok(out)
}),
cmd!(
"file.exportInterchange",
"Export Interchange",
[],
None,
r#"{"format":"edl|xml|fcpxml|otio|aaf|omf"=xml,"path":str,"sequence":id?}"#,
has_seq,
crate::interchange::export
),
cmd!("file.exportEdl", "EDL…", ["File", "Export"], None, r#"{"path":str}"#, has_seq, |s, p| {
let mut q = p.clone();
q["format"] = json!("edl");
crate::interchange::export(s, &q)
}),
cmd!("file.exportFcp7Xml", "Final Cut Pro XML…", ["File", "Export"], None, r#"{"path":str}"#, has_seq, |s, p| {
let mut q = p.clone();
q["format"] = json!("xml");
crate::interchange::export(s, &q)
}),
cmd!("file.exportFcpxml", "FCPXML…", ["File", "Export"], None, r#"{"path":str}"#, has_seq, |s, p| {
let mut q = p.clone();
q["format"] = json!("fcpxml");
crate::interchange::export(s, &q)
}),
cmd!("file.exportOtio", "OpenTimelineIO…", ["File", "Export"], None, r#"{"path":str}"#, has_seq, |s, p| {
let mut q = p.clone();
q["format"] = json!("otio");
crate::interchange::export(s, &q)
}),
cmd!(
"file.exportAaf",
"AAF…",
["File", "Export"],
None,
r#"{"path":str,"sequence":id?,"mixdownVideo":bool?,"mixdownFormat":"mov|mxf"?,"breakoutToMono":bool?,"audio":"embedded|separate|linked"?,"audioFormat":"wav|aiff|mxf"?,"sampleRate":int?,"bitDepth":"16|24"?,"trimAudio":bool?,"handles":frames?,"renderAudioEffects":bool?,"smallSectors":bool?}"#,
has_seq,
|s, p| crate::aaf_omf::export_aaf(s, p)
),
cmd!(
"file.exportOmf",
"OMF…",
["File", "Export"],
None,
r#"{"path":str,"sequence":id?,"title":str?,"audio":"embedded|separate"?,"audioFormat":"wav|aiff"?,"sampleRate":int?,"bitDepth":"16|24"?,"trimAudio":bool?,"handles":frames?,"renderAudioEffects":bool?,"breakoutToMono":bool?}"#,
has_seq,
|s, p| crate::aaf_omf::export_omf(s, p)
),
cmd!("file.importAaf", "Import AAF…", [], None, r#"{"path":str}"#, always, |s, p| crate::aaf_omf::import_document(s, p, "file.importAaf")),
cmd!("file.save", "Save", ["File"], Some("Cmd+S"), r#"{"path":str?}"#, always, |s, p| {
let path = str_p(p, "path").map(str::to_string).or_else(|| s.path.clone()).ok_or_else(|| bad("file.save", "no path (use Save As)"))?;
write_project(s, &path, true)
}),
cmd!("file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), r#"{"path":str}"#, always, |s, p| {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveAs", "need `path`"))?.to_string();
write_project(s, &path, true)
}),
cmd!("file.saveCopy", "Save a Copy…", ["File"], Some("Cmd+Alt+S"), r#"{"path":str}"#, always, |s, p| {
let path = str_p(p, "path").ok_or_else(|| bad("file.saveCopy", "need `path`"))?.to_string();
write_project(s, &path, false)
}),
cmd!("file.revert", "Revert", ["File"], None, "{}", has_saved_path, |s, _| {
let path = s.path.clone().ok_or_else(|| bad("file.revert", "project was never saved"))?;
let keep = s.state.active_sequence;
let r = open_project(s, &path)?;
if let Some(k) = keep.filter(|k| s.project.sequence(*k).is_some()) {
s.state.active_sequence = Some(k);
s.state.open_sequences = vec![k];
}
Ok(r)
}),
cmd!("file.open", "Open Project…", ["File"], Some("Cmd+O"), r#"{"path":str}"#, always, |s, p| {
let path = str_p(p, "path").ok_or_else(|| bad("file.open", "need `path`"))?.to_string();
open_project(s, &path)
}),
cmd!("file.recover", "Recover Unsaved Changes…", ["File"], None, r#"{"id":str?}"#, has_recovery, |s, p| recover(s, str_p(p, "id"))),
cmd!("file.discardRecovery", "Discard Unsaved Changes", [], None, r#"{"id":str?,"all":bool?}"#, has_recovery, |s, p| {
let all = bool_p(p, "all").unwrap_or(false);
let id = str_p(p, "id").map(str::to_string);
let Some(per) = s.persistence.as_mut() else { return Ok(json!({"discarded": 0})) };
let mut n = 0;
let mut keep = Vec::new();
for (i, c) in std::mem::take(&mut per.candidates).into_iter().enumerate() {
let hit = all || id.as_deref().map_or(i == 0, |want| want == c.id);
if hit {
crate::autosave::discard_candidate(&c).map_err(|e| EngineError::Other(e.to_string()))?;
n += 1;
} else {
keep.push(c);
}
}
per.candidates = keep;
if n == 0 {
return Err(bad("file.discardRecovery", "no such recovery session"));
}
Ok(json!({"discarded": n}))
}),
query!("file.recoveryList", "List Recoverable Sessions", "{}", |s, _| {
Ok(s.persistence.as_ref().map(|p| p.candidates_json()).unwrap_or_else(|| json!([])))
}),
cmd!("file.autoSaveNow", "Auto Save Now", [], None, "{}", always, |s, _| {
let per = s.persistence.as_ref().ok_or_else(|| EngineError::Other("auto-save is not running in this session".into()))?;
let r = per.auto_save_now().map_err(EngineError::Other)?;
s.poll_persistence();
Ok(json!({"path": r.map(|p| p.to_string_lossy().into_owned())}))
}),
query!("file.autoSaveStatus", "Auto Save Status", "{}", |s, _| {
let Some(per) = s.persistence.as_ref() else { return Ok(json!({"running": false, "prefs": s.prefs.auto_save})) };
Ok(json!({
"running": true,
"prefs": s.prefs.auto_save,
"dataDir": per.data_dir,
"sessionDir": per.session_dir,
"autoSaveDir": auto_save_dir(s),
"status": per.status,
"lastAutoSaveAt": per.status.last_auto_save_unix.map(|u| per.local_time(u)),
"lastJournalAt": per.status.last_journal_unix.map(|u| per.local_time(u)),
"recoverable": per.candidates.len(),
}))
}),
query!("file.listAutoSaves", "Browse Auto-Saves", "{}", |s, _| {
let dir = auto_save_dir(s);
let files = filmcraft_format::autosave::list_auto_saves(&dir, &auto_save_name(s));
Ok(json!({"dir": dir, "files": files.iter().rev().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>()}))
}),
query!("prefs.get", "Get Preferences", r#"{"key":str?}"#, |s, p| match str_p(p, "key") {
Some(k) => s.prefs.get(k).ok_or_else(|| bad("prefs.get", format!("unknown preference `{k}`"))),
None => Ok(json!({"values": s.prefs.to_value(), "keys": s.prefs.keys()})),
}),
cmd!("prefs.set", "Set Preferences", [], None, r#"{"key":str,"value":any}|{"values":{key:value}}"#, always, |s, p| {
let mut next = s.prefs.clone();
if let Some(k) = str_p(p, "key") {
next.set(k, p.get("value").cloned().ok_or_else(|| bad("prefs.set", "need `value`"))?).map_err(|e| bad("prefs.set", e))?;
}
if let Some(m) = p.get("values").and_then(Value::as_object) {
for (k, v) in m {
next.set(k, v.clone()).map_err(|e| bad("prefs.set", e))?;
}
}
s.set_prefs(next).map_err(|e| EngineError::Other(format!("saving preferences: {e}")))?;
Ok(s.prefs.to_value())
}),
cmd!("prefs.reset", "Reset Preferences", [], None, r#"{"category":str?}"#, always, |s, p| {
let next = match str_p(p, "category") {
Some(c) => {
let mut n = s.prefs.clone();
n.reset_category(c).map_err(|e| bad("prefs.reset", e))?;
n
}
None => Default::default(),
};
s.set_prefs(next).map_err(|e| EngineError::Other(format!("saving preferences: {e}")))?;
Ok(s.prefs.to_value())
}),
cmd!(
"file.exportMedia",
"Media…",
["File", "Export"],
None,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
has_seq,
crate::export_tools::export_media
),
query!("jobs.list", "List Jobs", "{}", |s, _| Ok(Value::Array(s.jobs.iter().map(crate::Job::to_json).collect()))),
query!("perf.stats", "Performance Statistics", "{}", |s, _| Ok(crate::perf::stats(s))),
cmd!("jobs.cancel", "Cancel Job", [], None, r#"{"job":id}"#, always, |s, p| {
let id = u64_p(p, "job").ok_or_else(|| bad("jobs.cancel", "need `job`"))?;
let j = s.jobs.iter().find(|j| j.id == id).ok_or_else(|| bad("jobs.cancel", "no such job"))?;
j.progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(Value::Null)
}),
// ================= Edit =================
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| Ok(json!({"undone": s.undo()}))),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!({"redone": s.redo()}))),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, |s, _| {
copy_selection(s);
let sel = with_links(s, &s.state.selection.clone());
s.edit_sequence("Cut", |q, _, st| {
edit::delete_items(q, &sel);
st.selection.clear();
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, |s, _| {
copy_selection(s);
Ok(json!({"copied": s.state.clipboard.len()}))
}),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{}", has_clipboard, |s, _| paste(s, false)),
cmd!("edit.pasteInsert", "Paste Insert", ["Edit"], Some("Cmd+Shift+V"), "{}", has_clipboard, |s, _| paste(s, true)),
cmd!("edit.clear", "Clear", ["Edit"], Some("Backspace"), r#"{"clips":[id]?}"#, has_any_selection, |s, p| {
if p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty() {
let caps = s.state.caption_selection.clone();
return crate::captions::delete(s, &caps, false);
}
let sel = with_links(s, &clips_p(s, p));
s.edit_sequence("Clear", |q, _, st| {
edit::delete_items(q, &sel);
st.selection.clear();
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("edit.rippleDelete", "Ripple Delete", ["Edit"], Some("Shift+Delete"), r#"{"clips":[id]?}"#, has_any_selection, |s, p| {
if p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty() {
let caps = s.state.caption_selection.clone();
return crate::captions::delete(s, &caps, true);
}
let sel = with_links(s, &clips_p(s, p));
s.edit_sequence("Ripple Delete", |q, _, st| {
let spans = edit::ripple_delete_items(q, &sel)?;
if st.ripple_sequence_markers {
for r in spans.iter().rev() {
crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration);
}
}
st.selection.clear();
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_seq, |s, _| {
let all: Vec<ClipId> = s.active_sequence().map(|q| q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect()).unwrap_or_default();
s.state.selection = all;
Ok(json!({"selected": s.state.selection.len()}))
}),
cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| {
s.state.selection.clear();
s.state.caption_selection.clear();
Ok(Value::Null)
}),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+Shift+/"), "{}", has_project_selection, |s, _| {
let sel = s.state.project_selection.clone();
let ids = s.edit("Duplicate", |p, st| {
let mut out = Vec::new();
for id in sel {
if let Some(it) = p.item(id).cloned() {
let nid = p.add_item(&format!("{} Copy", it.name), it.label, it.kind.clone(), None);
out.push(nid);
}
}
st.project_selection = out.clone();
Ok(out)
})?;
Ok(json!({"items": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}),
cmd!("edit.label", "Label", [], None, r#"{"label":"Violet|Iris|…"}"#, always, |s, p| {
let l = str_p(p, "label").and_then(Label::from_name).ok_or_else(|| bad("edit.label", "unknown label"))?;
let clips = s.state.selection.clone();
let items = s.state.project_selection.clone();
let seq = s.state.active_sequence;
s.edit("Label", |pr, _| {
for i in &items {
if let Some(it) = pr.item_mut(*i) {
it.label = l;
}
}
if let Some(q) = seq.and_then(|q| pr.sequence_mut(q)) {
for c in &clips {
if let Some((_, it)) = q.find_item_mut(*c) {
it.label = l;
}
}
}
Ok(())
})?;
Ok(Value::Null)
}),
// ================= Clip =================
cmd!("clip.rename", "Rename…", ["Clip"], None, r#"{"clip":id?,"item":id?,"name":str}"#, always, |s, p| {
let name = str_p(p, "name").ok_or_else(|| bad("clip.rename", "need `name`"))?.to_string();
let item = item_p(p, "item");
let clip = clip_p(p, "clip").or_else(|| s.state.selection.first().copied());
let seq = s.state.active_sequence;
s.edit("Rename", |pr, _| {
if let Some(i) = item {
pr.item_mut(i).ok_or_else(|| bad("clip.rename", "no such item"))?.name = name;
} else if let (Some(c), Some(q)) = (clip, seq) {
pr.sequence_mut(q).and_then(|q| q.find_item_mut(c)).ok_or_else(|| bad("clip.rename", "no such clip"))?.1.name = name;
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"clip.speedDuration",
"Speed/Duration…",
["Clip"],
Some("Cmd+R"),
r#"{"clips":[id]?,"speed":percent=100,"reverse":bool,"ripple":bool,"interpolation":"frameSampling|frameBlending|opticalFlow"?}"#,
has_selection,
|s, p| {
let speed = f64_p(p, "speed").unwrap_or(100.0) / 100.0;
let reverse = bool_p(p, "reverse").unwrap_or(false);
let ripple = bool_p(p, "ripple").unwrap_or(false);
let interp = match str_p(p, "interpolation") {
Some(m) => Some(filmcraft_project::TimeInterpolation::from_name(m).ok_or_else(|| bad("clip.speedDuration", "unknown interpolation"))?),
None => None,
};
let sel = with_links(s, &clips_p(s, p));
s.edit_sequence("Speed/Duration", |q, ctx, _| {
// a clip and its linked partners on other tracks change as one edit, so a ripple
// moves the later clips once
let place = |q: &filmcraft_project::Sequence, c: ClipId| q.find_item(c).map(|(t, i)| (t, i.link));
let mut groups: Vec<Vec<ClipId>> = Vec::new();
for c in &sel {
// a clip that is not there goes on alone, so the edit fails with "no such item"
let Some((track, link)) = place(q, *c) else {
groups.push(vec![*c]);
continue;
};
let joins = |g: &Vec<ClipId>| link.is_some() && g.iter().all(|o| place(q, *o).is_some_and(|(t, l)| l == link && t != track));
match groups.iter_mut().find(|g| joins(g)) {
Some(g) => g.push(*c),
None => groups.push(vec![*c]),
}
}
for group in &groups {
edit::set_speed_group(q, group, speed, reverse, ripple, ctx)?;
}
for c in &sel {
if let (Some(m), Some((_, it))) = (interp, q.find_item_mut(*c)) {
it.time_interpolation = m;
}
}
Ok(())
})?;
Ok(Value::Null)
}
),
// Each clip flips on its own, as in Premiere (#484): with one enabled and one disabled clip
// selected, Enable swaps them rather than first making both the same.
cmd!("clip.enable", "Enable", ["Clip"], Some("Shift+E"), r#"{"clips":[id]?}"#, has_selection, |s, p| {
let mut sel = with_links(s, &clips_p(s, p));
// a clip named twice must still flip once
sel.sort();
sel.dedup();
s.edit_sequence("Enable", |q, _, _| {
for c in &sel {
if let Some((_, i)) = q.find_item_mut(*c) {
i.enabled = !i.enabled;
}
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("clip.link", "Link", ["Clip"], Some("Cmd+L"), r#"{"clips":[id]?}"#, has_selection, |s, p| {
let sel = clips_p(s, p);
let linked = sel.iter().all(|c| s.active_sequence().and_then(|q| q.find_item(*c)).is_some_and(|(_, i)| i.link.is_some()));
s.edit_sequence(if linked { "Unlink" } else { "Link" }, |q, ctx, _| {
let l = if linked { None } else { Some(ctx.alloc()) };
for c in &sel {
if let Some((_, i)) = q.find_item_mut(*c) {
i.link = l;
}
}
Ok(())
})?;
Ok(json!({"linked": !linked}))
}),
cmd!("clip.group", "Group", ["Clip"], Some("Cmd+G"), "{}", has_selection, |s, _| {
let sel = s.state.selection.clone();
s.edit_sequence("Group", |q, ctx, _| {
let g = ctx.alloc();
for c in &sel {
if let Some((_, i)) = q.find_item_mut(*c) {
i.group = Some(g);
}
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("clip.ungroup", "Ungroup", ["Clip"], Some("Cmd+Shift+G"), "{}", has_selection, |s, _| {
let sel = s.state.selection.clone();
s.edit_sequence("Ungroup", |q, _, _| {
for c in &sel {
if let Some((_, i)) = q.find_item_mut(*c) {
i.group = None;
}
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("clip.scaleToFrameSize", "Scale to Frame Size", ["Clip", "Video Options"], None, "{}", has_selection, |s, _| {
let sel = s.state.selection.clone();
s.edit_sequence("Scale to Frame Size", |q, _, _| {
for c in &sel {
if let Some((_, i)) = q.find_item_mut(*c) {
i.scale_to_frame = !i.scale_to_frame;
}
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"clip.audioGain",
"Audio Gain…",
["Clip", "Audio Options"],
Some("G"),
r#"{"clips":[id]?,"mode":"set|adjust|normalizeMax|normalizeAll"?,"db":f64,"relative":bool?}"#,
has_selection,
|s, p| crate::mixer::audio_gain(s, p)
),
query!("clip.audioPeak", "Audio Clip Peak Amplitude", r#"{"clips":[id]?}"#, |s, p| crate::mixer::audio_peak(s, p)),
cmd!("clip.frameHold", "Add Frame Hold", ["Clip", "Video Options"], None, r#"{"clips":[id]?,"time":ticks?}"#, has_seq, |s, p| {
crate::clip_ops::add_frame_hold(s, p)
}),
cmd!("clip.nest", "Nest…", ["Clip"], None, r#"{"name":str}"#, has_selection, |s, p| nest(s, p)),
// Reveal in Project (clip context menu) and Reveal Sequence in Project (Timeline tab menu):
// select the item in the Project panel and show it there
cmd!("clip.revealInProject", "Reveal in Project", [], None, r#"{"clip":id?}"#, has_seq, |s, p| {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let t = s.playhead();
let tg = s.targeting().targeted;
// the named or first selected clip, else the topmost clip under the playhead on a targeted track
let item = clips_p(s, p)
.iter()
.find_map(|c| seq.find_item(*c).map(|(_, i)| i.item))
.or_else(|| {
seq.video_tracks.iter().rev().chain(seq.audio_tracks.iter()).filter(|tr| tg.contains(&tr.id)).find_map(|tr| tr.item_at(t).map(|i| i.item))
})
.ok_or_else(|| EngineError::Other("select a clip to reveal".into()))?;
reveal_in_project(s, item)
}),
cmd!("sequence.revealInProject", "Reveal Sequence in Project", [], None, r#"{"item":id?}"#, has_seq, |s, p| {
let item = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
reveal_in_project(s, item)
}),
// ================= Sequence =================
cmd!("sequence.open", "Open in Timeline", [], None, r#"{"item":id}"#, always, |s, p| {
let id = item_p(p, "item").ok_or_else(|| bad("sequence.open", "need `item`"))?;
s.project.sequence(id).ok_or_else(|| bad("sequence.open", "not a sequence"))?;
s.state.active_sequence = Some(id);
if !s.state.open_sequences.contains(&id) {
s.state.open_sequences.push(id);
}
s.state.selection.clear();
s.events.push(crate::Event::OpenSequence(id));
Ok(Value::Null)
}),
cmd!("sequence.close", "Close Sequence", [], None, r#"{"item":id?}"#, has_seq, |s, p| {
let id = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
s.state.open_sequences.retain(|x| *x != id);
if s.state.active_sequence == Some(id) {
s.state.active_sequence = s.state.open_sequences.last().copied();
}
Ok(Value::Null)
}),
cmd!("sequence.closeOthers", "Close Other Timeline Panels", [], None, r#"{"item":id?}"#, has_seq, |s, p| {
let id = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
if !s.state.open_sequences.contains(&id) {
return Err(bad("sequence.closeOthers", "that sequence is not open"));
}
let closed = s.state.open_sequences.len().saturating_sub(1);
s.state.open_sequences = vec![id];
if s.state.active_sequence != Some(id) {
s.state.active_sequence = Some(id);
s.state.selection.clear();
s.events.push(crate::Event::OpenSequence(id));
}
Ok(json!({"closed": closed}))
}),
cmd!("sequence.moveTab", "Move Sequence Tab", [], None, r#"{"item":id?,"index":int}"#, has_seq, |s, p| {
let id = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
let to = u64_p(p, "index").ok_or_else(|| bad("sequence.moveTab", "need `index`"))?;
let from = s.state.open_sequences.iter().position(|x| *x == id).ok_or_else(|| bad("sequence.moveTab", "that sequence is not open"))?;
// past the end is the end
let to = usize::try_from(to).unwrap_or(usize::MAX).min(s.state.open_sequences.len().saturating_sub(1));
let tab = s.state.open_sequences.remove(from);
s.state.open_sequences.insert(to, tab);
Ok(json!({"index": to}))
}),
cmd!("sequence.addEdit", "Add Edit", ["Sequence"], Some("Cmd+K"), r#"{"time":ticks?}"#, has_seq, |s, p| {
let t = time_p(s, p, "").unwrap_or(s.playhead());
// Like Premiere: selected clips under the playhead are cut, and only they (#164);
// with none, the targeted tracks are.
let sel = s.state.selection.clone();
let selected_here =
s.active_sequence().is_some_and(|q| sel.iter().any(|c| q.find_item(*c).is_some_and(|(_, it)| it.start < t && t < it.start + it.duration)));
let n = if selected_here {
s.edit_sequence("Add Edit", |q, ctx, _| Ok(edit::razor_items(q, &sel, t, ctx)))?
} else {
let tg = s.targeting().targeted;
s.edit_sequence("Add Edit", |q, ctx, _| Ok(edit::razor(q, &tg, t, ctx)))?
};
Ok(json!({"cuts": n.len()}))
}),
cmd!("sequence.addEditAllTracks", "Add Edit to All Tracks", ["Sequence"], Some("Cmd+Shift+K"), r#"{"time":ticks?}"#, has_seq, |s, p| {
let t = time_p(s, p, "").unwrap_or(s.playhead());
let n = s.edit_sequence("Add Edit to All Tracks", |q, ctx, _| {
let mut n = edit::razor(q, &[], t, ctx);
n.extend(edit::captions::split_captions_at(q, &[], t, ctx));
Ok(n)
})?;
Ok(json!({"cuts": n.len()}))
}),
cmd!("sequence.lift", "Lift", ["Sequence"], Some(";"), "{}", has_in_out, |s, _| {
let r = in_out_range(s)?;
let tg = s.targeting().targeted;
s.edit_sequence("Lift", |q, ctx, _| Ok(edit::lift(q, &tg, r, ctx)))?;
Ok(Value::Null)
}),
cmd!("sequence.extract", "Extract", ["Sequence"], Some("'"), "{}", has_in_out, |s, _| {
let r = in_out_range(s)?;
let tg = s.targeting().targeted;
s.edit_sequence("Extract", |q, ctx, st| {
edit::extract(q, &tg, r, ctx);
if st.ripple_sequence_markers {
crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration);
}
q.mark_in = None;
q.mark_out = None;
q.split = Default::default();
Ok(())
})?;
s.set_playhead(r.start);
Ok(Value::Null)
}),
cmd!(
"sequence.applyVideoTransition",
"Apply Video Transition",
["Sequence"],
Some("Cmd+D"),
r#"{"clip":id?,"effect":"cross_dissolve|Cross Dissolve|…"?,"frames":i64?,"edge":"in"|"out"?,"params":{param:value}?,"reverse":bool?}"#,
has_seq,
|s, p| apply_transition(s, p, TrackKind::Video)
),
cmd!(
"sequence.applyAudioTransition",
"Apply Audio Transition",
["Sequence"],
Some("Cmd+Shift+D"),
r#"{"clip":id?,"effect":str?,"frames":i64?}"#,
has_seq,
|s, p| apply_transition(s, p, TrackKind::Audio)
),
cmd!(
"sequence.setTransition",
"Edit Transition Settings",
[],
None,
r#"{"transition":id,"params":{param:value}?,"reverse":bool?,"reset":bool?}"#,
has_seq,
set_transition
),
cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id,"time":ticks}"#, has_seq, |s, p| {
let tr = track_p(s, p, "track", "sequence.closeGap")?.ok_or_else(|| bad("sequence.closeGap", "need `track`"))?;
let t = time_p(s, p, "").unwrap_or(s.playhead());
s.edit_sequence("Ripple Delete", |q, _, st| {
let before: Vec<(ClipId, Tick)> = q.track(tr).map(|x| x.items.iter().map(|i| (i.id, i.start)).collect()).unwrap_or_default();
edit::close_gap(q, tr, t)?;
if st.ripple_sequence_markers {
// the gap ended where the first clip that moved started
let moved = before
.iter()
.filter_map(|(id, old)| q.find_item(*id).filter(|(_, i)| i.start != *old).map(|(_, i)| (*old, *old - i.start)))
.min_by_key(|m| m.0);
if let Some((end, len)) = moved {
crate::sequence_tools::ripple_markers(&mut q.markers, end, -len);
}
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("sequence.snap", "Snap in Timeline", ["Sequence"], Some("S"), r#"{"on":bool?}"#, always, |s, p| {
s.state.snapping = bool_p(p, "on").unwrap_or(!s.state.snapping);
Ok(json!({"snapping": s.state.snapping}))
}),
cmd!("sequence.linkedSelection", "Linked Selection", ["Sequence"], None, r#"{"on":bool?}"#, always, |s, p| {
s.state.linked_selection = bool_p(p, "on").unwrap_or(!s.state.linked_selection);
Ok(json!({"linkedSelection": s.state.linked_selection}))
}),
cmd!("sequence.nestSequences", "Insert and overwrite sequences as nests or individual clips", [], None, r#"{"on":bool?}"#, always, |s, p| {
let nest = bool_p(p, "on").unwrap_or(s.state.sequences_as_clips);
s.state.sequences_as_clips = !nest;
Ok(json!({"nest": nest}))
}),
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, _, _| {
q.video_tracks.retain(|t| t.id != tr);
q.audio_tracks.retain(|t| t.id != tr);
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"sequence.settings",
"Sequence Settings…",
["Sequence"],
None,
r#"{"width":u32?,"height":u32?,"fps":f64?,"name":str?,"sampleRate":u32?,"mix":"Stereo|Mono|5.1|Adaptive"?,"dropFrame":bool?,"maxRenderQuality":bool?,"workingSpace":"rec709"|"rec2100-pq"|"rec2100-hlg"?,"wideGamut":bool?,"autoToneMap":bool?,"scaleMotion":bool=false}"#,
has_seq,
|s, p| {
const CMD: &str = "sequence.settings";
let id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let fps = fps_p(p, CMD)?;
let mix = match str_p(p, "mix") {
Some(m) => Some(crate::mixer::channels_from(m).ok_or_else(|| bad(CMD, format!("unknown mix `{m}` (Stereo, Mono, 5.1, Adaptive)")))?),
None => None,
};
let working = match str_p(p, "workingSpace") {
Some(w) => Some(filmcraft_color::WorkingSpace::parse(w).ok_or_else(|| bad(CMD, format!("unknown working space `{w}`")))?),
None => None,
};
let drop_frame = bool_p(p, "dropFrame");
let rate = fps.or_else(|| s.active_sequence().map(|q| q.settings.frame_rate)).unwrap_or(FrameRate::FPS_23_976);
if drop_frame == Some(true) && !rate.supports_drop_frame() {
return Err(bad(CMD, format!("drop-frame timecode needs a 29.97, 59.94 or 119.88 fps timebase, not {rate}")));
}
let scale_motion = bool_p(p, "scaleMotion").unwrap_or(false);
let p = p.clone();
let scaled = s.edit("Sequence Settings", |pr, _| {
if let Some(n) = str_p(&p, "name") {
pr.item_mut(id).ok_or(EngineError::NoSequence)?.name = n.to_string();
}
let q = pr.sequence_mut(id).ok_or(EngineError::NoSequence)?;
let old_size = (q.settings.width, q.settings.height);
if let Some(w) = checked_u32_p(&p, "width", CMD)? {
q.settings.width = w;
}
if let Some(h) = checked_u32_p(&p, "height", CMD)? {
q.settings.height = h;
}
if let Some(f) = fps {
q.settings.frame_rate = f;
}
if let Some(sr) = checked_u32_p(&p, "sampleRate", CMD)? {
q.settings.sample_rate = sr;
}
if let Some(m) = mix {
q.settings.audio_master = m;
}
if let Some(df) = drop_frame {
q.settings.drop_frame = df;
}
if let Some(v) = bool_p(&p, "maxRenderQuality") {
q.settings.max_render_quality = v;
}
let c = &mut q.settings.color;
if let Some(w) = working {
c.working = w;
}
if let Some(v) = bool_p(&p, "wideGamut") {
c.wide_gamut = v;
}
if let Some(v) = bool_p(&p, "autoToneMap") {
c.auto_tone_map = v;
}
q.settings.working_space = q.settings.color.working.label().into();
q.settings.validate().map_err(|e| bad(CMD, e))?;
let new_size = (q.settings.width, q.settings.height);
Ok(if scale_motion { edit::frame_size::scale_motion(q, old_size, new_size) } else { 0 })
})?;
Ok(json!({"scaledClips": scaled}))
}
),
cmd!("sequence.renderEffectsInToOut", "Render Effects In to Out", ["Sequence"], Some("Enter"), r#"{"wait":bool=false}"#, has_seq, |s, p| {
crate::previews::render(s, crate::previews::RenderMode::EffectsInToOut, p)
}),
cmd!("sequence.renderInToOut", "Render In to Out", ["Sequence"], None, r#"{"wait":bool=false}"#, has_seq, |s, p| {
crate::previews::render(s, crate::previews::RenderMode::InToOut, p)
}),
cmd!("sequence.renderSelection", "Render Selection", ["Sequence"], None, r#"{"wait":bool=false}"#, has_selection, |s, p| {
crate::previews::render(s, crate::previews::RenderMode::Selection, p)
}),
cmd!("sequence.renderAudio", "Render Audio", ["Sequence"], None, r#"{"wait":bool=false}"#, has_seq, |s, p| crate::previews::render_audio(s, p)),
cmd!("sequence.deleteRenderFiles", "Delete Render Files", ["Sequence"], None, "{}", has_previews, |s, _| crate::previews::delete(s, false)),
cmd!("sequence.deleteRenderFilesInToOut", "Delete Render Files In to Out", ["Sequence"], None, "{}", has_in_out, |s, _| {
crate::previews::delete(s, true)
}),
query!("sequence.renderBar", "Render Bar", "{}", |s, _| crate::previews::bar_json(s)),
cmd!("sequence.matchFrame", "Match Frame", ["Sequence"], Some("F"), "{}", has_seq, |s, _| {
let t = s.playhead();
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let hit = seq.video_tracks.iter().rev().chain(seq.audio_tracks.iter()).find_map(|tr| tr.item_at(t).map(|i| (i.item, i.source_time_at(t))));
let (item, st) = hit.ok_or_else(|| EngineError::Other("no clip at the playhead".into()))?;
s.state.source_item = Some(item);
s.state.source_playhead = st;
s.events.push(crate::Event::OpenSource(item));
Ok(json!({"item": item.0}))
}),
// ================= Markers =================
cmd!("markers.markIn", "Mark In", ["Markers"], Some("I"), r#"{"time":ticks?,"target":"program|source"}"#, always, |s, p| mark(s, p, true)),
cmd!("markers.markOut", "Mark Out", ["Markers"], Some("O"), r#"{"time":ticks?,"target":"program|source"}"#, always, |s, p| mark(s, p, false)),
cmd!("markers.markClip", "Mark Clip", ["Markers"], Some("X"), "{}", has_seq, |s, _| {
let t = s.playhead();
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let tg = s.targeting().targeted;
let r = seq
.all_tracks()
.filter(|tr| tg.contains(&tr.id))
.find_map(|tr| tr.item_at(t).map(|i| i.range()))
.ok_or_else(|| EngineError::Other("no clip at the playhead".into()))?;
let fd = s.sequence_rate().frame_duration();
s.edit_sequence("Mark Clip", |q, _, _| {
q.mark_in = Some(r.start);
q.mark_out = Some(r.end() - fd);
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("markers.markSelection", "Mark Selection", ["Markers"], Some("/"), "{}", has_selection, |s, _| {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let sel = s.state.selection.clone();
let items: Vec<_> = sel.iter().filter_map(|c| seq.find_item(*c).map(|(_, i)| i.range())).collect();
let a = items.iter().map(|r| r.start).min().unwrap_or_default();
let b = items.iter().map(|r| r.end()).max().unwrap_or_default();
let fd = s.sequence_rate().frame_duration();
s.edit_sequence("Mark Selection", |q, _, _| {
q.mark_in = Some(a);
q.mark_out = Some(b - fd);
Ok(())
})?;
Ok(Value::Null)
}),
// With no In / Out mark, like Premiere: the start / end of the sequence (#208).
cmd!("markers.goToIn", "Go to In", ["Markers"], Some("Shift+I"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.goToIn", p)? {
return Ok(v);
}
s.active_sequence().ok_or(EngineError::NoSequence)?;
if let Some(i) = s.active_sequence().map(|q| q.mark_in.unwrap_or(Tick::ZERO)) {
s.set_playhead(i);
}
Ok(Value::Null)
}),
cmd!("markers.goToOut", "Go to Out", ["Markers"], Some("Shift+O"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.goToOut", p)? {
return Ok(v);
}
s.active_sequence().ok_or(EngineError::NoSequence)?;
if let Some(o) = s.active_sequence().map(|q| q.mark_out.unwrap_or_else(|| q.duration())) {
s.set_playhead(o);
}
Ok(Value::Null)
}),
cmd!("markers.clearIn", "Clear In", ["Markers"], Some("Cmd+Shift+I"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearIn", p)? {
return Ok(v);
}
s.edit_sequence("Clear In", |q, _, _| {
q.mark_in = None;
q.split.video_in = None;
q.split.audio_in = None;
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("markers.clearOut", "Clear Out", ["Markers"], Some("Cmd+Shift+O"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearOut", p)? {
return Ok(v);
}
s.edit_sequence("Clear Out", |q, _, _| {
q.mark_out = None;
q.split.video_out = None;
q.split.audio_out = None;
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("markers.clearInOut", "Clear In and Out", ["Markers"], Some("Cmd+Shift+X"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearInOut", p)? {
return Ok(v);
}
s.edit_sequence("Clear In and Out", |q, _, _| {
q.mark_in = None;
q.mark_out = None;
q.split = Default::default();
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"markers.add",
"Add Marker",
["Markers"],
Some("M"),
r#"{"target":"program|source","time":ticks?,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
always,
|s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.add", p)? {
return Ok(v);
}
let (_seq, ph) = marker_list_mut(s)?;
let t = time_p(s, p, "").unwrap_or(ph);
let name = str_p(p, "name").unwrap_or("").to_string();
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);
let dur = p.get("durationFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)).unwrap_or_default();
let id = s.edit_sequence("Add Marker", |q, ctx, _| {
let id = MarkerId(ctx.alloc());
q.markers.push(Marker { id, start: t, duration: dur, name, comment, kind: MarkerKind::Comment, color });
q.markers.sort_by_key(|m| m.start);
Ok(id)
})?;
Ok(json!({"marker": id.0}))
}
),
cmd!("markers.goNext", "Go to Next Marker", ["Markers"], Some("Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.goNext", p)? {
return Ok(v);
}
let t = s.playhead();
s.active_sequence().ok_or(EngineError::NoSequence)?;
if let Some(m) = s.active_sequence().and_then(|q| q.markers.iter().map(|m| m.start).find(|m| *m > t)) {
s.set_playhead(m);
}
Ok(Value::Null)
}),
cmd!("markers.goPrev", "Go to Previous Marker", ["Markers"], Some("Cmd+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.goPrev", p)? {
return Ok(v);
}
let t = s.playhead();
s.active_sequence().ok_or(EngineError::NoSequence)?;
if let Some(m) = s.active_sequence().and_then(|q| q.markers.iter().rev().map(|m| m.start).find(|m| *m < t)) {
s.set_playhead(m);
}
Ok(Value::Null)
}),
cmd!("markers.clearCurrent", "Clear Selected Marker", ["Markers"], Some("Cmd+Alt+M"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearCurrent", p)? {
return Ok(v);
}
let t = s.playhead();
s.edit_sequence("Clear Marker", |q, _, _| {
q.markers.retain(|m| m.start != t);
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"markers.exportCsv",
"Export Markers as CSV…",
["Markers"],
None,
r#"{"path":str} (all markers in the active sequence)"#,
has_seq,
crate::marker_export::export
),
cmd!("markers.clearAll", "Clear Markers", ["Markers"], Some("Cmd+Alt+Shift+M"), r#"{"target":"program|source"}"#, always, |s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.clearAll", p)? {
return Ok(v);
}
s.edit_sequence("Clear All Markers", |q, _, _| {
q.markers.clear();
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"markers.edit",
"Edit Marker…",
[],
None,
r#"{"target":"program|source","marker":id,"name":str?,"comment":str?,"color":label?,"durationFrames":i64?}"#,
always,
|s, p| {
if let Some(v) = crate::source_monitor::route(s, "markers.edit", p)? {
return Ok(v);
}
let id = u64_p(p, "marker").map(MarkerId).ok_or_else(|| bad("markers.edit", "need `marker`"))?;
let rate = s.sequence_rate();
let p = p.clone();
s.edit_sequence("Edit Marker", |q, _, _| {
let m = q.markers.iter_mut().find(|m| m.id == id).ok_or_else(|| bad("markers.edit", "no such marker"))?;
if let Some(n) = str_p(&p, "name") {
m.name = n.into();
}
if let Some(c) = str_p(&p, "comment") {
m.comment = c.into();
}
if let Some(c) = str_p(&p, "color").and_then(Label::from_name) {
m.color = c;
}
if let Some(f) = p.get("durationFrames").and_then(Value::as_i64) {
m.duration = rate.tick_of(f);
}
Ok(())
})?;
Ok(Value::Null)
}
),
// ================= Playhead / navigation =================
cmd!("playhead.set", "Set Playhead", [], None, r#"{"time":ticks|"frame":i64|"seconds":f64|"timecode":str}"#, has_seq, |s, p| {
let t = time_p(s, p, "").ok_or_else(|| bad("playhead.set", "need time/frame/seconds/timecode"))?;
s.set_playhead(t);
Ok(json!({"time": s.playhead().0}))
}),
cmd!("playhead.step", "Step Frames", [], None, r#"{"frames":i64}"#, has_seq, |s, p| {
let n = p.get("frames").and_then(Value::as_i64).unwrap_or(1);
let r = s.sequence_rate();
let f = r.frame_at(s.playhead()).checked_add(n).ok_or_else(|| bad("playhead.step", "frame offset is too large"))?.max(0);
if f > r.frame_at(Tick::MAX) {
return Err(bad("playhead.step", "target frame exceeds the supported time range"));
}
s.set_playhead(r.tick_of(f));
Ok(json!({"frame": r.frame_at(s.playhead())}))
}),
cmd!("playhead.stepForward", "Step Forward One Frame", [], Some("Right"), "{}", has_seq, |s, _| s.execute("playhead.step", json!({"frames": 1}))),
cmd!("playhead.stepBack", "Step Back One Frame", [], Some("Left"), "{}", has_seq, |s, _| s.execute("playhead.step", json!({"frames": -1}))),
// Settings ▸ Playback ▸ Step forward/back many
cmd!("playhead.stepForward5", "Step Forward Five Frames", [], Some("Shift+Right"), "{}", has_seq, |s, _| {
let n = s.prefs.playback.step_many_frames as i64;
s.execute("playhead.step", json!({"frames": n}))
}),
cmd!("playhead.stepBack5", "Step Back Five Frames", [], Some("Shift+Left"), "{}", has_seq, |s, _| {
let n = s.prefs.playback.step_many_frames as i64;
s.execute("playhead.step", json!({"frames": -n}))
}),
// Up / Down: edit points on the targeted tracks (Shift+Up / Shift+Down: any track)
cmd!("playhead.nextEdit", "Go to Next Edit Point", [], Some("Down"), "{}", has_seq, |s, _| crate::keyboard::go_to_edit(s, true, false)),
cmd!("playhead.prevEdit", "Go to Previous Edit Point", [], Some("Up"), "{}", has_seq, |s, _| crate::keyboard::go_to_edit(s, false, false)),
cmd!("playhead.start", "Go to Sequence Start", [], Some("Home"), "{}", has_seq, |s, _| {
s.set_playhead(Tick::ZERO);
Ok(Value::Null)
}),
cmd!("playhead.end", "Go to Sequence End", [], Some("End"), "{}", has_seq, |s, _| {
let e = s.active_sequence().map(|q| q.duration()).unwrap_or_default();
s.set_playhead(e);
Ok(Value::Null)
}),
// ================= Source monitor =================
cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id?}"#, always, |s, p| {
let id = item_p(p, "item")
.or(s.state.project_selection.first().copied())
.ok_or_else(|| bad("source.open", "need `item` (or a Project panel selection)"))?;
s.project.item(id).ok_or_else(|| bad("source.open", "no such item"))?;
s.state.source_item = Some(id);
// a subclip opens at its In point
s.state.source_playhead = crate::clip_ops::source_view(s, id).map(|v| v.start).unwrap_or_default();
s.events.push(crate::Event::OpenSource(id));
Ok(Value::Null)
}),
cmd!("source.setPlayhead", "Set Source Playhead", [], None, r#"{"time":ticks|"frame":i64|"seconds":f64}"#, always, |s, p| {
let item = s.state.source_item.ok_or_else(|| EngineError::Other("no source clip".into()))?;
let view = crate::clip_ops::source_view(s, item);
let rate = view.as_ref().map(|v| v.rate).unwrap_or_default();
let t = p
.get("time")
.and_then(Value::as_i64)
.map(Tick)
.or_else(|| p.get("frame").and_then(Value::as_i64).map(|f| rate.tick_of(f)))
.or_else(|| f64_p(p, "seconds").map(Tick::from_seconds_f64))
.unwrap_or_default();
// the Source monitor's span: a subclip that restricts trims keeps the playhead inside it
let (lo, hi) = view.map(|v| (v.start, v.end)).unwrap_or((Tick::ZERO, Tick::MAX));
s.state.source_playhead = rate.snap(t.clamp(lo, (hi - rate.frame_duration()).max(lo))).max(lo);
Ok(json!({"time": s.state.source_playhead.0}))
}),
query!("source.inspect", "Inspect Source Monitor", "{}", |s, _| {
let Some(item) = s.state.source_item else { return Ok(json!({"item": null})) };
let v = crate::clip_ops::source_view(s, item).ok_or_else(|| bad("source.inspect", "the source item is gone"))?;
Ok(v.to_json(s.state.source_playhead))
}),
cmd!("source.insert", "Insert", ["Clip"], Some(","), "{}", has_source, |s, _| edit_at_source(s, true)),
cmd!("source.overwrite", "Overwrite", ["Clip"], Some("."), "{}", has_source, |s, _| edit_at_source(s, false)),
// ================= Timeline gestures =================
cmd!(
"timeline.place",
"Place Clip",
[],
None,
r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#,
has_seq,
|s, p| {
let stream_flag = |key: &str| match p.get(key) {
None => Ok(true),
Some(Value::Bool(v)) => Ok(*v),
_ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))),
};
let (video, audio) = (stream_flag("video")?, stream_flag("audio")?);
if !video && !audio {
return Err(bad("timeline.place", "enable video, audio, or both"));
}
let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?;
let at = time_p(s, p, "").unwrap_or(s.playhead());
let tg = s.targeting();
let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?;
let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t));
// `track` is where the picture goes and `audioTrack` where the sound goes. A caller that
// names an audio track as `track` asks for the sound alone, on that track.
let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) {
(Some(t), Some(_)) if is_audio(t) => {
return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once"));
}
(_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")),
(Some(t), None) if is_audio(t) && !pi.has_audio() => {
return Err(bad("timeline.place", "`track` names an audio track and this item has no sound"));
}
(Some(t), None) if is_audio(t) => (None, Some(t)),
(Some(_), None) if !pi.has_video() => {
return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track"));
}
(v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)),
};
let v = v.filter(|_| video);
let a = a.filter(|_| audio);
if p.get("video").is_some() && video && pi.has_video() && v.is_none() {
return Err(bad("timeline.place", "no video destination: add or enable a video track"));
}
if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() {
return Err(bad("timeline.place", "no audio destination: add or enable an audio track"));
}
let full = match &pi.kind {
// Settings ▸ Timeline ▸ Still Image Default Duration
ItemKind::Media(m)
if matches!(m.info.kind, filmcraft_media::MediaKind::Still)
|| (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) =>
{
s.prefs.timeline.still_duration(s.sequence_rate())
}
_ => pi.duration(),
};
let (mi, mo) = match &pi.kind {
ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())),
ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())),
_ => (None, None),
};
let sin = p.get("sourceIn").and_then(Value::as_i64).map(Tick).or(mi).unwrap_or_default();
let dur = p.get("duration").and_then(Value::as_i64).map(Tick).unwrap_or_else(|| mo.unwrap_or(full) - sin);
let ids = place_item(s, item, TimeRange::new(sin, dur), at, v, a, bool_p(p, "insert").unwrap_or(false), "Place Clip", None)?;
Ok(json!({"clips": ids.iter().map(|c| c.0).collect::<Vec<_>>()}))
}
),
cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"add":bool,"toggle":bool}"#, has_seq, |s, p| {
let clips: Vec<ClipId> =
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default();
let clips = with_links(s, &clips);
// selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive)
s.state.edit_points.clear();
s.state.trim_shift = Default::default();
if bool_p(p, "toggle").unwrap_or(false) {
for c in clips {
if let Some(i) = s.state.selection.iter().position(|x| *x == c) {
s.state.selection.remove(i);
} else {
s.state.selection.push(c);
}
}
} else if bool_p(p, "add").unwrap_or(false) {
for c in clips {
if !s.state.selection.contains(&c) {
s.state.selection.push(c);
}
}
} else {
s.state.selection = clips;
s.state.caption_selection.clear();
}
Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>()}))
}),
cmd!(
"timeline.move",
"Move Clips",
[],
None,
r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?}"#,
has_seq,
move_clips
),
cmd!(
"timeline.trim",
"Trim Edit",
[],
None,
r#"{"clip":id,"edge":"in|out","mode":"regular|ripple","delta":ticks|"deltaFrames":i64}"#,
has_seq,
|s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.trim", "need `clip`"))?;
let edge = if str_p(p, "edge") == Some("in") { Edge::In } else { Edge::Out };
let mode = if str_p(p, "mode") == Some("ripple") { TrimMode::Ripple } else { TrimMode::Regular };
let d = p
.get("delta")
.and_then(Value::as_i64)
.map(Tick)
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
.unwrap_or_default();
let clips = with_links(s, &[c]);
let applied = s.edit_sequence(if mode == TrimMode::Ripple { "Ripple Trim" } else { "Trim" }, |q, ctx, st| {
let before = q.find_item(c).map(|(_, i)| i.range());
// clamp across all linked partners, then apply the common delta
let mut dd = d;
for c in &clips {
let x = edit::clamp_trim(q, *c, edge, mode, dd, ctx)?;
if x.abs() < dd.abs() {
dd = x;
}
}
if mode == TrimMode::Ripple {
// the clip and its linked partners ripple as one edit
let mut group = vec![c];
group.extend(clips.iter().copied().filter(|o| *o != c));
dd = edit::ripple_trim_group(q, &group, edge, dd, ctx)?;
if st.ripple_sequence_markers
&& let Some(r) = before
{
let (from, shift) = match edge {
Edge::In if dd > Tick::ZERO => (r.start + dd, -dd),
Edge::In => (r.start, -dd),
Edge::Out => (r.end(), dd),
};
crate::sequence_tools::ripple_markers(&mut q.markers, from, shift);
}
} else {
for c in &clips {
edit::trim(q, *c, edge, mode, dd, ctx)?;
}
}
Ok(dd)
})?;
Ok(json!({"delta": applied.0}))
}
),
// ================= Trim mode =================
cmd!("trim.selectEditPoint", "Select Edit Point", [], None, r#"{"clip":id,"edge":"in|out","kind":"trim|ripple|roll","add":bool?}"#, has_seq, |s, p| {
crate::trim::select(s, p)
}),
cmd!("trim.selectNearest", "Select Nearest Edit Point", [], None, r#"{"kind":"rippleIn|rippleOut|roll|trimIn|trimOut"}"#, has_seq, |s, p| {
crate::trim::select_nearest(s, p)
}),
cmd!("trim.edit", "Trim Edit", ["Sequence"], Some("Shift+T"), "{}", has_seq, |s, _| {
if s.state.edit_points.is_empty() {
crate::trim::select_nearest(s, &json!({"kind": "roll"}))
} else {
Ok(json!({"editPoints": s.state.edit_points}))
}
}),
cmd!("trim.clear", "Clear Edit Point Selection", [], None, "{}", has_edit_points, |s, _| {
s.state.edit_points.clear();
s.state.trim_shift = Default::default();
s.trim_play.around = None;
Ok(Value::Null)
}),
cmd!("trim.toggleType", "Toggle Trim Type", [], Some("Ctrl+T"), "{}", has_edit_points, |s, _| crate::trim::toggle_type(s)),
cmd!("trim.backward", "Trim Backward", [], Some("Alt+Left"), "{}", has_edit_points, |s, _| crate::trim::nudge(s, &json!({"frames": -1}))),
cmd!("trim.forward", "Trim Forward", [], Some("Alt+Right"), "{}", has_edit_points, |s, _| crate::trim::nudge(s, &json!({"frames": 1}))),
cmd!("trim.backwardMany", "Trim Backward Many", [], Some("Alt+Shift+Left"), "{}", has_edit_points, |s, _| {
let n = -(s.prefs.trim.large_trim_offset as i64);
crate::trim::nudge(s, &json!({"frames": n}))
}),
cmd!("trim.forwardMany", "Trim Forward Many", [], Some("Alt+Shift+Right"), "{}", has_edit_points, |s, _| {
let n = s.prefs.trim.large_trim_offset as i64;
crate::trim::nudge(s, &json!({"frames": n}))
}),
cmd!("trim.applyDefaultTransition", "Apply Default Transitions to Selection", ["Sequence"], None, "{}", has_edit_points, |s, _| {
crate::trim::apply_default_transitions(s)
}),
cmd!("trim.shuttle", "Dynamic Trim (Shuttle)", [], None, r#"{"direction":"forward|reverse","slow":bool?,"clock":seconds}"#, has_edit_points, |s, p| {
crate::trim::shuttle(s, p)
}),
cmd!("trim.shuttleStop", "Dynamic Trim Stop", [], None, r#"{"clock":seconds?}"#, has_seq, |s, p| crate::trim::stop(s, p)),
cmd!("trim.cancelDynamic", "Cancel Dynamic Trim", [], None, "{}", has_seq, |s, _| crate::trim::cancel(s)),
cmd!("trim.playAround", "Play Around Edit", [], None, r#"{"clock":seconds,"loop":bool=true,"toggle":bool?}"#, has_seq, |s, p| {
crate::trim::play_around(s, p)
}),
CommandSpec {
id: "trim.tick",
label: "Advance Trim Playback",
menu: &[],
shortcut: None,
params: r#"{"clock":seconds}"#,
enabled: has_seq,
run: |s, p| crate::trim::tick(s, p.get("clock").and_then(Value::as_f64).unwrap_or(0.0)),
journal: false,
},
query!("trim.monitor", "Trim Monitor State", "{}", |s, _| Ok(crate::trim::monitor_info(s))),
cmd!("trim.nudge", "Trim by Frames", [], None, r#"{"frames":i64}"#, has_edit_points, |s, p| crate::trim::nudge(s, p)),
cmd!("trim.extendToPlayhead", "Extend Selected Edit to Playhead", [], Some("E"), "{}", has_edit_points, |s, _| { crate::trim::extend_to_playhead(s) }),
cmd!("trim.ripplePrevious", "Ripple Trim Previous Edit to Playhead", [], Some("Q"), "{}", has_seq, |s, _| crate::trim::to_playhead(
s,
&json!({"side": "previous", "ripple": true})
)),
cmd!("trim.rippleNext", "Ripple Trim Next Edit to Playhead", [], Some("W"), "{}", has_seq, |s, _| crate::trim::to_playhead(
s,
&json!({"side": "next", "ripple": true})
)),
cmd!("trim.previous", "Trim Previous Edit to Playhead", [], Some("Alt+Q"), "{}", has_seq, |s, _| crate::trim::to_playhead(
s,
&json!({"side": "previous", "ripple": false})
)),
cmd!("trim.next", "Trim Next Edit to Playhead", [], Some("Alt+W"), "{}", has_seq, |s, _| crate::trim::to_playhead(
s,
&json!({"side": "next", "ripple": false})
)),
cmd!("timeline.roll", "Rolling Edit", [], None, r#"{"left":id,"right":id,"delta":ticks|"deltaFrames":i64}"#, has_seq, |s, p| {
let (l, r) = (
clip_p(p, "left").ok_or_else(|| bad("timeline.roll", "need `left`"))?,
clip_p(p, "right").ok_or_else(|| bad("timeline.roll", "need `right`"))?,
);
let d = p
.get("delta")
.and_then(Value::as_i64)
.map(Tick)
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
.unwrap_or_default();
let x = s.edit_sequence("Rolling Edit", |q, ctx, _| Ok(edit::roll(q, l, r, d, ctx)?))?;
Ok(json!({"delta": x.0}))
}),
cmd!("timeline.slip", "Slip", [], None, r#"{"clip":id,"delta":ticks|"deltaFrames":i64}"#, has_seq, |s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.slip", "need `clip`"))?;
let d = p
.get("delta")
.and_then(Value::as_i64)
.map(Tick)
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
.unwrap_or_default();
let clips = with_links(s, &[c]);
s.edit_sequence("Slip", |q, ctx, _| {
// clamp across all linked partners, then slip them by the common delta
let mut dd = d;
for c in &clips {
let x = edit::slip(&mut q.clone(), *c, dd, ctx)?;
if x.abs() < dd.abs() {
dd = x;
}
}
for c in &clips {
edit::slip(q, *c, dd, ctx)?;
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("timeline.slide", "Slide", [], None, r#"{"clip":id,"delta":ticks|"deltaFrames":i64}"#, has_seq, |s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.slide", "need `clip`"))?;
let d = p
.get("delta")
.and_then(Value::as_i64)
.map(Tick)
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
.unwrap_or_default();
let clips = with_links(s, &[c]);
s.edit_sequence("Slide", |q, ctx, _| {
// clamp across all linked partners, then slide them by the common delta
let mut dd = d;
for c in &clips {
let x = edit::slide(&mut q.clone(), *c, dd, ctx)?;
if x.abs() < dd.abs() {
dd = x;
}
}
for c in &clips {
edit::slide(q, *c, dd, ctx)?;
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.rateStretch", "need `clip`"))?;
let edge = if str_p(p, "edge") == Some("in") { Edge::In } else { Edge::Out };
let d = p
.get("delta")
.and_then(Value::as_i64)
.map(Tick)
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
.unwrap_or_default();
let clips = with_links(s, &[c]);
let sp = s.edit_sequence("Rate Stretch", |q, ctx, _| {
let mut sp = 1.0;
for c in &clips {
sp = edit::rate_stretch(q, *c, edge, d, ctx)?;
}
Ok(sp)
})?;
Ok(json!({"speed": sp}))
}),
cmd!("timeline.razor", "Razor", [], None, r#"{"time":ticks,"track":"V1"|id?,"clip":id?}"#, has_seq, |s, p| {
let t = time_p(s, p, "").unwrap_or(s.playhead());
let tr = track_p(s, p, "track", "timeline.razor")?;
let clip = clip_p(p, "clip");
let n = if let Some(c) = clip {
let clips = with_links(s, &[c]);
s.edit_sequence("Razor", |q, ctx, _| Ok(edit::razor_items(q, &clips, t, ctx)))?
} else {
let tracks: Vec<TrackId> = tr.into_iter().collect();
s.edit_sequence("Razor", |q, ctx, _| Ok(edit::razor(q, &tracks, t, ctx)))?
};
Ok(json!({"cuts": n.len()}))
}),
cmd!(
"timeline.setTrack",
"Track Settings",
[],
None,
r#"{"track":"V1"|id,"locked":bool?,"syncLock":bool?,"enabled":bool?,"muted":bool?,"solo":bool?,"name":str?,"volumeDb":f64?,"pan":f64?}"#,
has_seq,
|s, p| {
let tr = track_p(s, p, "track", "timeline.setTrack")?.ok_or_else(|| bad("timeline.setTrack", "need `track`"))?;
let p = p.clone();
s.edit_sequence("Track Settings", |q, _, _| {
let t = q.track_mut(tr).ok_or(filmcraft_edit::EditError::NoTrack(tr))?;
if let Some(v) = bool_p(&p, "locked") {
t.locked = v;
}
if let Some(v) = bool_p(&p, "syncLock") {
t.sync_lock = v;
}
if let Some(v) = bool_p(&p, "enabled") {
t.enabled = v;
}
if let Some(v) = bool_p(&p, "muted") {
t.muted = v;
}
if let Some(v) = bool_p(&p, "solo") {
t.solo = v;
}
if let Some(v) = str_p(&p, "name") {
t.name = v.to_string();
}
if let Some(v) = f64_p(&p, "volumeDb") {
t.volume_db = v;
}
if let Some(v) = f64_p(&p, "pan") {
t.pan = v.clamp(-100.0, 100.0);
}
Ok(())
})?;
Ok(Value::Null)
}
),
cmd!("timeline.setTargeting", "Track Targeting", [], None, r#"{"track":"V1"|id,"targeted":bool?,"sourcePatch":bool?}"#, has_seq, |s, p| {
let tr = track_p(s, p, "track", "timeline.setTargeting")?.ok_or_else(|| bad("timeline.setTargeting", "need `track`"))?;
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let mut tg = s.targeting();
let is_video = s.active_sequence().is_some_and(|q| q.video_tracks.iter().any(|t| t.id == tr));
if let Some(on) = bool_p(p, "targeted") {
tg.targeted.retain(|t| *t != tr);
if on {
tg.targeted.push(tr);
}
}
if let Some(on) = bool_p(p, "sourcePatch") {
let slot = if is_video { &mut tg.video_dest } else { &mut tg.audio_dest };
*slot = if on {
Some(tr)
} else if *slot == Some(tr) {
None
} else {
*slot
};
}
s.state.targeting.insert(seq_id, tg);
Ok(Value::Null)
}),
// ================= Effects =================
cmd!("effects.apply", "Apply Effect", [], None, r#"{"clips":[id]?,"effect":"gaussian_blur|Gaussian Blur|…"}"#, has_seq, |s, p| {
let name = str_p(p, "effect").ok_or_else(|| bad("effects.apply", "need `effect`"))?;
let def = resolve_effect_name(s, p, name).ok_or_else(|| bad("effects.apply", format!("unknown effect `{name}`")))?;
if filmcraft_project::graphic::is_layer_id(def.id) {
return Err(bad("effects.apply", "graphic layers are added with graphics.newText / graphics.newShape"));
}
if matches!(def.kind, filmcraft_project::EffectKind::VideoTransition | filmcraft_project::EffectKind::AudioTransition) {
let mut q = p.clone();
q["effect"] = json!(def.id);
return apply_transition(s, &q, if def.kind == filmcraft_project::EffectKind::VideoTransition { TrackKind::Video } else { TrackKind::Audio });
}
let clips = clips_p(s, p);
if clips.is_empty() {
return Err(bad("effects.apply", "select clips first"));
}
let is_audio = def.kind == filmcraft_project::EffectKind::Audio;
let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
let label = format!("Apply {}", def.name);
s.edit_sequence(&label, |q, _, _| {
let mut n = 0;
for t in q.all_tracks_mut() {
if (t.kind == TrackKind::Audio) != is_audio {
continue;
}
for i in t.items.iter_mut().filter(|i| clips.contains(&i.id)) {
let mut inst = def.instance();
resolve_auto_points(&mut inst, (fw, fh), (fw, fh));
// standard effects go before the intrinsic ones (render order)
let pos = i.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap_or(i.effects.len());
let pos = if is_audio { i.effects.len() } else { pos };
i.effects.insert(pos, inst);
n += 1;
}
}
Ok(n)
})?;
Ok(Value::Null)
}),
cmd!("effects.remove", "Remove Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.remove", "need `clip`"))?;
let idx = u64_p(p, "index").ok_or_else(|| bad("effects.remove", "need `index`"))? as usize;
s.edit_sequence("Remove Effect", |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
if it.effects.get(idx).and_then(|e| e.def()).is_some_and(|d| d.intrinsic) {
return Err(EngineError::Other("intrinsic effects cannot be removed".into()));
}
if idx < it.effects.len() {
it.effects.remove(idx);
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"effects.setParam",
"Set Effect Parameter",
[],
None,
r##"{"clip":id,"effect":"motion"|index,"param":str,"mask":n?,"value":num|[x,y]|"#rrggbb"|bool|path,"time":ticks?,"merge":bool?,"begin":bool?}"##,
has_seq,
|s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.setParam", "need `clip`"))?;
let pid = str_p(p, "param").ok_or_else(|| bad("effects.setParam", "need `param`"))?.to_string();
let val = p.get("value").cloned().ok_or_else(|| bad("effects.setParam", "need `value`"))?;
let eff = p.get("effect").cloned().unwrap_or(json!("motion"));
let ph = s.playhead();
let tl = time_p(s, p, "").unwrap_or(ph);
let pq = p.clone();
// `merge`: a drag of one parameter is one undo step (#201); `begin` starts a new one
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("setParam:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null)));
if bool_p(p, "begin").unwrap_or(false) {
s.history.merge_key = None;
}
s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
let mt = it.source_time_at(tl.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
let e = match &eff {
Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
_ => None,
}
.ok_or_else(|| bad("effects.setParam", "no such effect on clip"))?;
if eff == json!("opacity") || eff == json!("motion") {
e.enabled = true;
}
// instances saved before a parameter existed get it from the definition
if !e.params.contains_key(&pid)
&& let Some(d) = e.def().and_then(|d| d.param(&pid))
{
e.params.insert(pid.clone(), filmcraft_project::Param::new(d.default.clone()));
}
let prm = crate::masks::target_param(e, &pq, &pid).ok_or_else(|| bad("effects.setParam", format!("no param `{pid}`")))?;
let v = json_to_param(&prm.value, &val).ok_or_else(|| bad("effects.setParam", "value has the wrong type"))?;
prm.set_at(mt, v);
Ok(())
})?;
Ok(Value::Null)
}
),
cmd!("effects.toggleAnimation", "Toggle Animation", [], None, r#"{"clip":id,"effect":str|index,"param":str,"mask":n?}"#, has_seq, |s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.toggleAnimation", "need `clip`"))?;
let pid = str_p(p, "param").ok_or_else(|| bad("effects.toggleAnimation", "need `param`"))?.to_string();
let eff = p.get("effect").cloned().unwrap_or(json!("motion"));
let ph = s.playhead();
s.edit_sequence("Toggle Animation", |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
let e = match &eff {
Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
_ => None,
}
.ok_or_else(|| bad("effects.toggleAnimation", "no such effect"))?;
crate::masks::target_param(e, p, &pid).ok_or_else(|| bad("effects.toggleAnimation", "no such param"))?.toggle_animation(mt);
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("effects.toggleEnabled", "Toggle Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.toggleEnabled", "need `clip`"))?;
let idx = u64_p(p, "index").unwrap_or(0) as usize;
s.edit_sequence("Toggle Effect", |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
if let Some(e) = it.effects.get_mut(idx) {
e.enabled = !e.enabled;
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!("effects.reset", "Reset Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.reset", "need `clip`"))?;
let idx = u64_p(p, "index").unwrap_or(0) as usize;
let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
s.edit_sequence("Reset Effect", |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
if let Some(e) = it.effects.get_mut(idx)
&& let Some(d) = e.def()
{
let mut fresh = d.instance();
resolve_auto_points(&mut fresh, (fw, fh), (fw, fh));
*e = fresh;
}
Ok(())
})?;
Ok(Value::Null)
}),
cmd!(
"effects.addKeyframe",
"Add/Remove Keyframe",
[],
None,
r#"{"clip":id,"effect":str|index,"param":str,"mask":n?,"time":ticks?}"#,
has_seq,
|s, p| { keyframe_op(s, p, "add") }
),
cmd!("effects.deleteKeyframe", "Delete Keyframe", [], None, r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks}"#, has_seq, |s, p| {
keyframe_op(s, p, "delete")
}),
cmd!(
"effects.moveKeyframe",
"Move Keyframe",
[],
None,
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"to":ticks,"value":any?,"merge":bool?,"begin":bool?}"#,
has_seq,
|s, p| keyframe_op(s, p, "move")
),
cmd!(
"effects.setInterpolation",
"Keyframe Interpolation",
[],
None,
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"interpolation":"linear|bezier|autoBezier|continuousBezier|hold|easeIn|easeOut"}"#,
has_seq,
|s, p| keyframe_op(s, p, "interp")
),
cmd!(
"effects.setKeyframe",
"Edit Keyframe",
[],
None,
r#"{"clip":id,"effect":str|index,"param":str,"mediaTime":ticks,"value":any?,"inInfluence":0..1?,"outInfluence":0..1?,"merge":bool?,"begin":bool?}"#,
has_seq,
|s, p| keyframe_op(s, p, "set")
),
// ================= Project panel =================
cmd!("project.select", "Select Project Items", [], None, r#"{"items":[id]}"#, always, |s, p| {
s.state.project_selection =
p.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect()).unwrap_or_default();
Ok(Value::Null)
}),
cmd!("project.delete", "Clear", [], Some("Delete"), r#"{"items":[id]?}"#, has_project_selection, |s, p| {
let asked: Vec<ItemId> = p
.get("items")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect())
.unwrap_or_else(|| s.state.project_selection.clone());
// A bin named here goes with everything in it, as in the Project panel of any editor.
let root = s.project.root.id;
let named: Vec<filmcraft_project::BinId> =
asked.iter().map(|i| filmcraft_project::BinId(i.0)).filter(|b| *b != root && s.project.root.find_bin(*b).is_some()).collect();
// a bin inside another named bin goes with that one
let bins: Vec<filmcraft_project::BinId> = named
.iter()
.copied()
.filter(|b| !named.iter().any(|o| o != b && s.project.root.find_bin(*o).is_some_and(|o| o.find_bin(*b).is_some())))
.collect();
let mut items: Vec<ItemId> = asked.iter().copied().filter(|i| !named.contains(&filmcraft_project::BinId(i.0))).collect();
for b in &bins {
if let Some(bin) = s.project.root.find_bin(*b) {
bin.all_items(&mut items);
}
}
items.sort_by_key(|i| i.0);
items.dedup();
s.edit("Clear", |pr, st| {
for b in &bins {
pr.root.remove_bin(*b);
}
for i in &items {
pr.items.remove(i);
pr.root.remove_item(*i);
}
// remove track items that referenced deleted media
let seq_ids: Vec<ItemId> = pr.sequences().map(|q| q.id).collect();
for sid in seq_ids {
if let Some(q) = pr.sequence_mut(sid) {
for t in q.all_tracks_mut() {
t.items.retain(|x| !items.contains(&x.item));
filmcraft_edit::remove_orphan_transitions(t);
}
}
}
st.project_selection.clear();
Ok(())
})?;
for i in &items {
s.media.remove(*i);
}
s.fix_state();
Ok(json!({"items": items.len(), "bins": bins.len()}))
}),
cmd!("project.moveToBin", "Move to Bin", [], None, r#"{"items":[id]?,"bin":binId|null}"#, always, |s, p| {
let items: Vec<ItemId> = match p.get("items").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_u64().map(ItemId)).collect(),
None => s.state.project_selection.clone(),
};
if items.is_empty() {
return Err(bad("project.moveToBin", "need `items` (or a Project panel selection)"));
}
let bin = u64_p(p, "bin").map(filmcraft_project::BinId);
let moved = s.edit("Move to Bin", |pr, _| pr.move_to_bin(&items, bin).ok_or_else(|| bad("project.moveToBin", "no such bin")))?;
Ok(json!({"moved": moved}))
}),
cmd!("project.setMarks", "Set Source In/Out", [], None, r#"{"item":id,"in":ticks?|null,"out":ticks?|null}"#, always, |s, p| {
let id = item_p(p, "item").or(s.state.source_item).ok_or_else(|| bad("project.setMarks", "need `item`"))?;
let p = p.clone();
s.edit("Mark", |pr, _| {
let it = pr.item_mut(id).ok_or_else(|| bad("project.setMarks", "no such item"))?;
// an ordinary In/Out replaces that side's split points
if p.get("in").is_some() {
it.split.video_in = None;
it.split.audio_in = None;
}
if p.get("out").is_some() {
it.split.video_out = None;
it.split.audio_out = None;
}
if let Some(m) = it.as_media_mut() {
if let Some(v) = p.get("in") {
m.mark_in = v.as_i64().map(Tick);
}
if let Some(v) = p.get("out") {
m.mark_out = v.as_i64().map(Tick);
}
} else if let Some(q) = it.as_sequence_mut() {
// a sequence loaded in the Source Monitor is marked like a clip: these are the
// sequence's own In and Out
if let Some(v) = p.get("in") {
q.mark_in = v.as_i64().map(Tick);
q.split.video_in = None;
q.split.audio_in = None;
}
if let Some(v) = p.get("out") {
q.mark_out = v.as_i64().map(Tick);
q.split.video_out = None;
q.split.audio_out = None;
}
}
Ok(())
})?;
Ok(Value::Null)
}),
// ================= Queries =================
query!("command.list", "List Commands", "{}", |s, _| {
Ok(Value::Array(
command_specs()
.iter()
.map(|c| {
let all: Vec<&str> = s.shortcuts.for_command(c.id).iter().map(|b| b.keys.as_str()).collect();
json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": s.shortcuts.primary(c.id), "shortcuts": all, "defaultShortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok()})
})
.collect(),
))
}),
query!("project.inspect", "Inspect Project", "{}", |s, _| Ok(crate::commands::inspect_project(s))),
query!("sequence.inspect", "Inspect Sequence", r#"{"item":id?}"#, |s, p| {
let id = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
let q = s.project.sequence(id).ok_or(EngineError::NoSequence)?;
Ok(inspect_sequence(s, id, q))
}),
query!("state.inspect", "Inspect Editor State", "{}", |s, _| Ok(serde_json::to_value(&s.state).unwrap_or_default())),
query!(
"effects.list",
"List Effects",
r#"{"kind":"Video"|"Audio"|"VideoTransition"|"AudioTransition"?,"folder":"Video Transitions/Wipe"?,"detail":bool?}"#,
|_, p| Ok(list_effects(p))
),
query!("history.list", "List History", "{}", |s, _| Ok(
json!({"undo": s.history.undo.iter().map(|h| &h.0).collect::<Vec<_>>(), "redo": s.history.redo.iter().map(|h| &h.0).collect::<Vec<_>>()})
)),
];
crate::sequence_tools::splice(&mut v);
v.extend(crate::captions::commands());
v.extend(crate::settings::commands());
v.extend(crate::mixer::commands());
v.extend(crate::multicam::commands());
v.extend(crate::essential_sound::commands());
v.extend(crate::color::commands());
v.extend(crate::graphics::commands());
v.extend(crate::graphic_templates::commands());
v.extend(crate::shortcuts::commands());
v.extend(crate::relink::commands());
v.extend(crate::proxies::commands());
v.extend(crate::project_manager::commands());
v.extend(crate::masks::commands());
v.extend(crate::presets::commands());
v.extend(crate::export_tools::commands());
v.extend(crate::transcript::commands());
v.extend(crate::panels::commands());
v.extend(crate::scopes::commands());
v.extend(crate::remix::commands());
v.extend(crate::voiceover::commands());
v.extend(crate::keyboard::commands());
v.extend(crate::project_panel::commands());
v.extend(crate::media_browser::commands());
// Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip…
// and their menu order
crate::clip_ops::apply_layout(&mut v);
// Search Bin, Find, Project Settings, Scene Edit Detection, Normalize Mix Track… (M3.11)
crate::project_tools::apply_layout(&mut v);
v.shrink_to_fit();
v
}
fn keyframe_op(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
let c = clip_p(p, "clip").ok_or_else(|| bad("keyframe", "need `clip`"))?;
let pid = str_p(p, "param").ok_or_else(|| bad("keyframe", "need `param`"))?.to_string();
let eff = p.get("effect").cloned().unwrap_or(json!("motion"));
let ph = time_p(s, p, "").unwrap_or(s.playhead());
let mtime = p.get("mediaTime").and_then(Value::as_i64).map(Tick);
let to = p.get("to").and_then(Value::as_i64).map(Tick);
let interp = str_p(p, "interpolation").map(str::to_string);
let label = match op {
"add" => "Add Keyframe",
"delete" => "Delete Keyframe",
"move" => "Move Keyframe",
"set" => "Edit Keyframe",
_ => "Keyframe Interpolation",
};
// a keyframe dragged on the timeline is one undo step, like a dragged value
let merge = bool_p(p, "merge").unwrap_or(false).then(|| format!("keyframe:{op}:{}:{eff}:{pid}:{}", c.0, p.get("mask").unwrap_or(&Value::Null)));
if bool_p(p, "begin").unwrap_or(false) {
s.history.merge_key = None;
}
s.edit_sequence_as(label, merge.as_deref(), |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
let mt_now = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
let e = match &eff {
Value::Number(n) => it.effects.get_mut(n.as_u64().unwrap_or(0) as usize),
Value::String(sid) => it.effects.iter_mut().find(|e| &e.effect == sid),
_ => None,
}
.ok_or_else(|| bad("keyframe", "no such effect"))?;
let prm = crate::masks::target_param(e, p, &pid).ok_or_else(|| bad("keyframe", "no such param"))?;
match op {
"add" => {
// toggle: remove if a keyframe sits at the playhead, else add
if !prm.remove_keyframe_at(mt_now) {
prm.add_keyframe(mt_now);
}
}
"delete" => {
prm.remove_keyframe_at(mtime.ok_or_else(|| bad("keyframe", "need `mediaTime`"))?);
}
"move" => {
let from = mtime.ok_or_else(|| bad("keyframe", "need `mediaTime`"))?;
let to = to.ok_or_else(|| bad("keyframe", "need `to`"))?;
if let Some(i) = prm.keyframes.iter().position(|k| k.time == from) {
let mut k = prm.keyframes.remove(i);
k.time = to;
if let Some(v) = p.get("value") {
k.value = json_to_param(&k.value, v).ok_or_else(|| bad("keyframe", "value has the wrong type"))?;
}
prm.keyframes.retain(|x| x.time != to);
let at = prm.keyframes.partition_point(|x| x.time < to);
prm.keyframes.insert(at, k);
}
}
"set" => {
let at = mtime.ok_or_else(|| bad("keyframe", "need `mediaTime`"))?;
let k = prm.keyframes.iter_mut().find(|k| k.time == at).ok_or_else(|| bad("keyframe", "no keyframe at that time"))?;
if let Some(v) = p.get("value") {
k.value = json_to_param(&k.value, v).ok_or_else(|| bad("keyframe", "value has the wrong type"))?;
}
if let Some(x) = f64_p(p, "inInfluence") {
k.in_influence = x.clamp(0.01, 1.0);
}
if let Some(x) = f64_p(p, "outInfluence") {
k.out_influence = x.clamp(0.01, 1.0);
}
}
_ => {
let at = mtime.unwrap_or(mt_now);
let kind = match interp.as_deref().unwrap_or("linear").to_ascii_lowercase().as_str() {
"bezier" => filmcraft_project::Interpolation::Bezier,
"autobezier" => filmcraft_project::Interpolation::AutoBezier,
"continuousbezier" => filmcraft_project::Interpolation::ContinuousBezier,
"hold" => filmcraft_project::Interpolation::Hold,
"easein" => filmcraft_project::Interpolation::EaseIn,
"easeout" => filmcraft_project::Interpolation::EaseOut,
_ => filmcraft_project::Interpolation::Linear,
};
let k = prm.keyframes.iter_mut().find(|k| k.time == at).ok_or_else(|| bad("keyframe", "no keyframe at that time"))?;
k.interp = kind;
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn in_out_range(s: &Session) -> Result<TimeRange> {
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let fd = q.settings.frame_rate.frame_duration();
let a = q.mark_in.unwrap_or(Tick::ZERO);
let b = q.mark_out.map(|o| o + fd).unwrap_or(q.duration());
if b <= a {
return Err(EngineError::Other("In point is after Out point".into()));
}
Ok(TimeRange::from_bounds(a, b))
}
fn mark(s: &mut Session, p: &Value, is_in: bool) -> Result<Value> {
let target_source = str_p(p, "target") == Some("source");
if target_source {
let item = s.state.source_item.ok_or_else(|| EngineError::Other("no source clip".into()))?;
let t = p.get("time").and_then(Value::as_i64).map(Tick).unwrap_or(s.state.source_playhead);
let key = if is_in { "in" } else { "out" };
return s.execute("project.setMarks", json!({"item": item.0, key: t.0}));
}
let t = time_p(s, p, "").unwrap_or(s.playhead());
s.edit_sequence(if is_in { "Mark In" } else { "Mark Out" }, |q, _, _| {
if is_in {
q.mark_in = Some(t);
q.split.video_in = None;
q.split.audio_in = None;
if q.mark_out.is_some_and(|o| o < t) {
q.mark_out = None;
}
} else {
q.mark_out = Some(t);
q.split.video_out = None;
q.split.audio_out = None;
if q.mark_in.is_some_and(|i| i > t) {
q.mark_in = None;
}
}
Ok(())
})?;
Ok(Value::Null)
}
pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamValue> {
Some(match template {
ParamValue::Float(_) => ParamValue::Float(v.as_f64()?),
ParamValue::Vec2(_) => {
let a = v.as_array()?;
ParamValue::Vec2(filmcraft_geom::Vec2::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
ParamValue::Color(_) => match v {
Value::String(s) => ParamValue::Color(filmcraft_color::parse_hex(s)?),
Value::Array(a) => ParamValue::Color([
a.first()?.as_f64()? as f32,
a.get(1)?.as_f64()? as f32,
a.get(2)?.as_f64()? as f32,
a.get(3).and_then(Value::as_f64).unwrap_or(1.0) as f32,
]),
_ => return None,
},
ParamValue::Bool(_) => ParamValue::Bool(v.as_bool()?),
ParamValue::Choice(_) => ParamValue::Choice(v.as_u64()? as u32),
ParamValue::Text(_) => ParamValue::Text(v.as_str()?.to_string()),
ParamValue::Path(_) => ParamValue::Path(crate::masks::path_from_json(v)?),
ParamValue::Curve(_) => {
ParamValue::Curve(v.as_array()?.iter().filter_map(|p| Some([p.get(0)?.as_f64()? as f32, p.get(1)?.as_f64()? as f32])).collect())
}
})
}
fn copy_selection(s: &mut Session) {
let Some(q) = s.active_sequence() else { return };
let sel = with_links(s, &s.state.selection);
let min = sel.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.start)).min().unwrap_or_default();
let max = sel.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.end())).max().unwrap_or_default();
// Copy Paste Includes Sequence Markers: the markers inside the copied span go along
let markers: Vec<Marker> = if s.state.copy_paste_sequence_markers {
q.markers.iter().filter(|m| m.start >= min && m.start < max).map(|m| Marker { start: m.start - min, ..m.clone() }).collect()
} else {
Vec::new()
};
let mut clip = Vec::new();
for (kind, tracks) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] {
for (ti, t) in tracks.iter().enumerate() {
for i in t.items.iter().filter(|i| sel.contains(&i.id)) {
let mut c = i.clone();
c.start -= min;
clip.push((kind, ti, c));
}
}
}
s.state.clipboard = clip;
s.state.clipboard_markers = markers;
}
fn paste(s: &mut Session, insert: bool) -> Result<Value> {
let at = s.playhead();
let clip = s.state.clipboard.clone();
let markers = if s.state.copy_paste_sequence_markers { s.state.clipboard_markers.clone() } else { Vec::new() };
let end = s.edit_sequence(if insert { "Paste Insert" } else { "Paste" }, |q, ctx, st| {
let mut placements = Vec::new();
let mut links = std::collections::HashMap::new();
for (kind, ti, item) in clip {
let tracks = q.tracks(kind);
let Some(t) = tracks.get(ti).or(tracks.last()) else { continue };
let mut it = item.clone();
it.id = ClipId(ctx.alloc());
it.start += at;
if let Some(l) = it.link {
it.link = Some(*links.entry(l).or_insert_with(|| ctx.alloc()));
}
placements.push((t.id, it));
}
let end = placements.iter().map(|p| p.1.end()).max().unwrap_or(at);
let ids = if insert { edit::insert(q, placements, ctx)? } else { edit::overwrite(q, placements, ctx)? };
if insert && st.ripple_sequence_markers {
crate::sequence_tools::ripple_markers(&mut q.markers, at, end - at);
}
for m in &markers {
q.markers.push(Marker { id: MarkerId(ctx.alloc()), start: m.start + at, ..m.clone() });
}
q.markers.sort_by_key(|m| m.start);
st.selection = ids;
Ok(end)
})?;
s.set_playhead(end);
Ok(Value::Null)
}
/// Select `item` in the Project panel and ask the UI to show it there.
fn reveal_in_project(s: &mut Session, item: ItemId) -> Result<Value> {
s.project.item(item).ok_or_else(|| EngineError::Other("that clip's source is not in the project".into()))?;
s.state.project_selection = vec![item];
s.events.push(crate::Event::RevealInProject(item));
Ok(json!({"item": item.0}))
}
/// The name Nest… offers: the first unused "Nested Sequence NN".
pub fn next_nested_name(s: &Session) -> String {
(1..=s.project.items.len().saturating_add(1))
.map(|k| format!("Nested Sequence {k:02}"))
.find(|n| !s.project.items.values().any(|i| &i.name == n))
.unwrap_or_else(|| "Nested Sequence".to_string())
}
fn nest(s: &mut Session, p: &Value) -> Result<Value> {
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let sel = with_links(s, &s.state.selection.clone());
let q = s.active_sequence().ok_or(EngineError::NoSequence)?.clone();
let name = str_p(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or_else(|| next_nested_name(s));
let mut items: Vec<(TrackKind, usize, filmcraft_project::TrackItem)> = Vec::new();
// transitions go into the nest with their clips: those whose clips are all nested
let mut transitions: Vec<(TrackKind, usize, filmcraft_project::Transition)> = Vec::new();
for (k, ts) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] {
// clips on a locked track stay where they are (they cannot be removed from it)
for (ti, t) in ts.iter().enumerate().filter(|(_, t)| !t.locked) {
for i in t.items.iter().filter(|i| sel.contains(&i.id)) {
items.push((k, ti, i.clone()));
}
let nested = |c: Option<ClipId>| c.is_none_or(|c| sel.contains(&c));
for trn in t.transitions.iter().filter(|x| (x.from.is_some() || x.to.is_some()) && nested(x.from) && nested(x.to)) {
transitions.push((k, ti, trn.clone()));
}
}
}
if items.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
let start = items.iter().map(|i| i.2.start).min().unwrap_or_default();
let end = items.iter().map(|i| i.2.end()).max().unwrap_or_default();
let span = TimeRange::new(start, end - start);
let tracks_of = |kind: TrackKind| -> Vec<usize> {
let mut v: Vec<usize> = items.iter().filter(|i| i.0 == kind).map(|i| i.1).collect();
v.sort_unstable();
v.dedup();
v
};
let (video_tracks, audio_tracks) = (tracks_of(TrackKind::Video), tracks_of(TrackKind::Audio));
// Premiere (26.5.2): the nest is a linked picture + sound pair only when the selected sound is
// on one track and that track is free for the nest's whole length. With sound on several
// tracks, or another clip in the way on its track, the nest is picture only: the selected sound
// stays where it is in this sequence, and a copy of it goes into the nest.
let sound_blocked = |ai: usize| q.audio_tracks.get(ai).is_none_or(|t| t.items.iter().any(|i| !sel.contains(&i.id) && i.range().overlaps(&span)));
let video_only = !video_tracks.is_empty() && (audio_tracks.len() > 1 || audio_tracks.first().is_some_and(|ai| sound_blocked(*ai)));
let (lowest_v, lowest_a) = (video_tracks.first().copied(), audio_tracks.first().copied().filter(|_| !video_only));
// In the nest, picture tracks keep their positions (V1 up to the highest one used). Sound
// tracks do not: A1 is always there, and the other tracks used follow it in order
// (A1 + A3 become A1, A2; A3 alone becomes an empty A1 and A2).
let nv = video_tracks.last().map_or(1, |i| i + 1);
let audio_at: std::collections::HashMap<usize, usize> =
audio_tracks.iter().filter(|i| **i != 0).enumerate().map(|(n, i)| (*i, n + 1)).chain(std::iter::once((0, 0))).collect();
let na = audio_at.values().max().map_or(1, |i| i + 1);
let nested_track = |k: TrackKind, ti: usize| if k == TrackKind::Audio { audio_at.get(&ti).copied().unwrap_or(ti) } else { ti };
// what leaves this sequence: everything nested, less the sound that stays behind
let removed: Vec<ClipId> = items.iter().filter(|i| !(video_only && i.0 == TrackKind::Audio)).map(|i| i.2.id).collect();
let stays = |k: TrackKind| video_only && k == TrackKind::Audio;
let r = s.edit("Nest", |pr, st| {
let nid = pr.new_sequence(&name, q.settings.clone(), nv, na, None);
// copies of clips and transitions that also stay behind need ids of their own
let fresh_clips: std::collections::HashMap<ClipId, ClipId> = items.iter().filter(|i| stays(i.0)).map(|i| (i.2.id, ClipId(pr.alloc_id()))).collect();
let fresh_transitions: Vec<Option<u64>> = transitions.iter().map(|t| stays(t.0).then(|| pr.alloc_id())).collect();
let copy = |c: ClipId| fresh_clips.get(&c).copied().unwrap_or(c);
{
let nq = pr.sequence_mut(nid).ok_or(EngineError::NoSequence)?;
// tracks take the name and channel layout of the track their clips came from
for (tr, src) in nq.video_tracks.iter_mut().zip(&q.video_tracks) {
tr.name = src.name.clone();
}
for (from, to) in &audio_at {
if let (Some(tr), Some(src)) = (nq.audio_tracks.get_mut(*to), q.audio_tracks.get(*from)) {
tr.channels = src.channels;
if from == to {
tr.name = src.name.clone();
}
}
}
for (k, ti, it) in &items {
let mut it = it.clone();
it.id = copy(it.id);
it.start -= start;
nq.tracks_mut(*k).get_mut(nested_track(*k, *ti)).ok_or(EngineError::NoSequence)?.items.push(it);
}
for ((k, ti, trn), fresh) in transitions.iter().zip(&fresh_transitions) {
let mut trn = trn.clone();
if let Some(id) = fresh {
trn.id = filmcraft_project::TransitionId(*id);
}
(trn.from, trn.to) = (trn.from.map(copy), trn.to.map(copy));
trn.start -= start;
nq.tracks_mut(*k).get_mut(nested_track(*k, *ti)).ok_or(EngineError::NoSequence)?.transitions.push(trn);
}
for t in nq.all_tracks_mut() {
t.sort();
edit::remove_orphan_transitions(t);
}
nq.check().map_err(EngineError::Other)?;
}
let rate = q.settings.frame_rate;
// the nest shows its sequence from the beginning, for the length of what was nested
let whole = TimeRange::new(Tick::ZERO, span.duration);
let mut v =
pr.make_track_item(nid, TrackKind::Video, start, whole, rate).ok_or_else(|| EngineError::Other("cannot place the nested sequence".into()))?;
for e in &mut v.effects {
resolve_auto_points(e, (q.settings.width, q.settings.height), (q.settings.width, q.settings.height));
}
let a = pr.make_track_item(nid, TrackKind::Audio, start, whole, rate).ok_or_else(|| EngineError::Other("cannot place the nested sequence".into()))?;
let (link, new_video, new_audio) = (pr.alloc_id(), pr.alloc_id(), pr.alloc_id());
let seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
edit::delete_items(seq, &removed);
// sound left behind has lost its picture: it is no longer linked
if video_only {
let links: Vec<u64> = items.iter().filter(|i| i.0 == TrackKind::Video).filter_map(|i| i.2.link).collect();
for it in seq.audio_tracks.iter_mut().flat_map(|t| t.items.iter_mut()) {
if it.link.is_some_and(|l| links.contains(&l)) {
it.link = None;
}
}
}
// Where the nest goes, as Premiere Pro 26.5.2 places it (about 35 nests observed). With L
// the lowest video track of the selection and `a` the number of the highest audio track
// holding selected sound, the picture takes the lowest track T from L up such that
// 1. T is free of other clips for the nest's whole length, and
// 2. so is every video track numbered from T up to a - 1 that the selection had clips on.
// If no track will do, a new one is added on top. So the nest never lands on another clip,
// and it can be pushed above an unselected clip that shares a track with the selection
// when the sound sits on a higher-numbered track. The sound of a linked nest is on the one
// track its clips came from, which is free (a selection of sound alone moves up to the
// first free track).
let highest_sound = audio_tracks.last().map_or(0, |i| i + 1);
let mut ids = Vec::new();
for (kind, lowest, mut clip, new_track) in [(TrackKind::Video, lowest_v, v, new_video), (TrackKind::Audio, lowest_a, a, new_audio)] {
let Some(lowest) = lowest else { continue };
let tracks = seq.tracks_mut(kind);
let clear = |i: usize| tracks.get(i).is_some_and(|t| !t.locked && edit::track_range_empty(t, span));
let selection_clear = |t: usize| kind != TrackKind::Video || video_tracks.iter().filter(|i| **i >= t && **i + 1 < highest_sound).all(|i| clear(*i));
let free = (lowest..tracks.len()).find(|t| clear(*t) && selection_clear(*t));
let at = match free {
Some(i) => i,
None => {
let (word, n) = (if kind == TrackKind::Video { "Video" } else { "Audio" }, tracks.len() + 1);
tracks.push(filmcraft_project::Track::new(TrackId(new_track), kind, format!("{word} {n}")));
tracks.len() - 1
}
};
clip.link = (lowest_v.is_some() && lowest_a.is_some()).then_some(link);
ids.push(clip.id);
let t = tracks.get_mut(at).ok_or(EngineError::NoSequence)?;
t.items.push(clip);
t.sort();
}
seq.check().map_err(EngineError::Other)?;
// like Premiere: nothing stays selected in the timeline; the new sequence is selected in the Project panel
st.selection.clear();
st.project_selection = vec![nid];
Ok((nid, ids))
});
r.map(|(n, ids)| json!({"sequence": n.0, "clips": ids.iter().map(|c| c.0).collect::<Vec<_>>()}))
}
/// `effects.list`: every effect definition with its Effects-panel folder (optionally filtered by
/// kind or folder prefix); `detail` adds each parameter's label, type, default and choices.
fn list_effects(p: &Value) -> Value {
let kind = str_p(p, "kind");
let folder = str_p(p, "folder");
let detail = p.get("detail").and_then(Value::as_bool).unwrap_or(false);
let out = filmcraft_project::effect_defs()
.iter()
.filter(|d| kind.is_none_or(|k| format!("{:?}", d.kind).eq_ignore_ascii_case(k)))
.filter(|d| folder.is_none_or(|f| d.category.join("/").starts_with(f)))
.map(|d| {
let mut v = json!({
"id": d.id,
"name": d.name,
"kind": format!("{:?}", d.kind),
"category": d.category,
"folder": d.category.get(1),
"path": d.category.join("/"),
"params": d.params.iter().map(|p| p.id).collect::<Vec<_>>(),
"badges": {"accelerated": d.accelerated, "float32": d.float32, "yuv": d.yuv},
});
if detail {
v["paramInfo"] = Value::Array(d.params.iter().map(param_info).collect());
}
v
})
.collect();
Value::Array(out)
}
fn param_info(p: &filmcraft_project::ParamDef) -> Value {
use filmcraft_project::ParamKind as K;
let mut j = json!({"id": p.id, "label": p.label, "animatable": p.animatable, "default": serde_json::to_value(&p.default).unwrap_or_default()});
let ty = match &p.kind {
K::Choice(opts) => {
j["options"] = json!(opts);
"choice"
}
K::Float { min, max, unit, .. } => {
j["min"] = json!(min);
j["max"] = json!(max);
j["unit"] = json!(unit);
"float"
}
K::Point => "point",
K::Color => "color",
K::Bool => "bool",
K::Angle => "angle",
K::Text => "text",
K::Curve { .. } => "curve",
K::Wheel => "wheel",
K::Path => "path",
};
j["type"] = json!(ty);
j
}
/// Set transition parameters from a JSON object (`{"direction": 1}` or `{"direction": "From East"}`,
/// colours as `"#rrggbb"` or `[r,g,b]`, points as `[x,y]`). Floats are clamped to their range.
fn set_transition_params(e: &mut filmcraft_project::EffectInstance, params: &Value, cmd: &str) -> Result<()> {
use filmcraft_project::{ParamKind as K, ParamValue as V};
let Some(obj) = params.as_object() else { return Err(bad(cmd, "`params` must be an object")) };
let def = e.def().ok_or_else(|| bad(cmd, "unknown transition"))?;
for (k, v) in obj {
let pd = def.param(k).ok_or_else(|| bad(cmd, format!("`{}` has no param `{k}`", def.name)))?;
let val = match (&pd.kind, v) {
(K::Choice(opts), Value::String(sv)) => {
V::Choice(opts.iter().position(|o| o.eq_ignore_ascii_case(sv)).ok_or_else(|| bad(cmd, format!("`{k}` must be one of {opts:?}")))? as u32)
}
_ => json_to_param(&pd.default, v).ok_or_else(|| bad(cmd, format!("`{k}`: value has the wrong type")))?,
};
let val = match (&pd.kind, val) {
(K::Choice(opts), V::Choice(c)) if c as usize >= opts.len() => return Err(bad(cmd, format!("`{k}` must be < {}", opts.len()))),
(K::Float { min, max, .. }, V::Float(f)) => V::Float(f.clamp(*min, *max)),
(_, v) => v,
};
match e.param_mut(k) {
Some(prm) => prm.value = val,
None => {
e.params.insert(k.clone(), filmcraft_project::Param::new(val));
}
}
}
Ok(())
}
/// `sequence.setTransition`: edit an applied transition's settings (Effect Controls).
fn set_transition(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "sequence.setTransition";
let id = p.get("transition").and_then(Value::as_u64).ok_or_else(|| bad(CMD, "need `transition`"))?;
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let found = q.video_tracks.iter().chain(q.audio_tracks.iter()).find_map(|tr| tr.transitions.iter().find(|x| x.id.0 == id)).cloned();
let mut cur = found.ok_or_else(|| bad(CMD, format!("no transition {id}")))?;
if p.get("reset").and_then(Value::as_bool).unwrap_or(false)
&& let Some(def) = cur.effect.def()
{
cur.effect = def.instance();
}
if let Some(params) = p.get("params") {
set_transition_params(&mut cur.effect, params, CMD)?;
}
if let Some(r) = p.get("reverse").and_then(Value::as_bool) {
cur.reverse = r;
}
let name = cur.effect.def().map_or("Transition", |d| d.name).to_string();
s.edit_sequence(&format!("Edit {name}"), |q, _, _| {
for tr in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) {
if let Some(x) = tr.transitions.iter_mut().find(|x| x.id.0 == id) {
x.effect = cur.effect.clone();
x.reverse = cur.reverse;
}
}
Ok(())
})?;
Ok(json!({"transition": id, "effect": cur.effect.effect, "reverse": cur.reverse}))
}
fn apply_transition(s: &mut Session, p: &Value, kind: TrackKind) -> Result<Value> {
let (track, tr) = preview_transition(s, p, kind)?;
let label = format!("Apply {}", tr.effect.def().map_or("Transition", |d| d.name));
let id = s.edit_sequence(&label, |q, ctx, _| Ok(edit::add_transition(q, track, tr, ctx)?))?;
Ok(json!({"transition": id.0}))
}
/// Plan an applied transition without editing the project or allocating an id.
/// Uses the same `effect`, `clip`, `edge`, `frames` and settings as transition application,
/// so a drag preview shows the exact range that release will commit.
pub fn preview_transition(s: &Session, p: &Value, kind: TrackKind) -> Result<(TrackId, Transition)> {
let eff_id = str_p(p, "effect")
.map(str::to_string)
.unwrap_or_else(|| if kind == TrackKind::Video { s.state.default_video_transition.clone() } else { s.state.default_audio_transition.clone() });
let ekind = if kind == TrackKind::Video { filmcraft_project::EffectKind::VideoTransition } else { filmcraft_project::EffectKind::AudioTransition };
let def = filmcraft_project::vtransition::find_transition(&eff_id, ekind).ok_or_else(|| bad("transition", format!("unknown transition `{eff_id}`")))?;
let mut instance = def.instance();
if let Some(params) = p.get("params") {
set_transition_params(&mut instance, params, "transition")?;
}
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
let rate = s.sequence_rate();
// Settings ▸ Timeline ▸ Video / Audio Transition Default Duration
let dur = match p.get("frames").and_then(Value::as_i64) {
Some(frames) => rate.tick_of(frames),
None if kind == TrackKind::Video => s.prefs.timeline.video_transition_duration(rate),
None => s.prefs.timeline.audio_transition_duration(rate),
};
let t = s.playhead();
let clip = clip_p(p, "clip");
let edge = str_p(p, "edge").map(str::to_string);
let targeted = s.targeting().targeted;
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
// Find (track, from, to, cut) at the playhead (nearest edit on targeted tracks) or at a clip edge.
let mut found = None;
for tr in q.tracks(kind).iter().filter(|tr| clip.is_some() || targeted.contains(&tr.id)) {
let items = &tr.items;
for (i, it) in items.iter().enumerate() {
if let Some(c) = clip
&& it.id != c
{
continue;
}
let prev = i.checked_sub(1).map(|j| &items[j]).filter(|pv| pv.end() == it.start);
let next = items.get(i + 1).filter(|nx| nx.start == it.end());
let use_in = match edge.as_deref() {
Some("in") => true,
Some("out") => false,
_ => (t - it.start).abs() <= (t - it.end()).abs(),
};
if clip.is_none() && !(it.start == t || it.end() == t || (it.start - t).abs() < rate.tick_of(3) || (it.end() - t).abs() < rate.tick_of(3)) {
continue;
}
found = Some(if use_in { (tr.id, prev.map(|x| x.id), Some(it.id), it.start) } else { (tr.id, Some(it.id), next.map(|x| x.id), it.end()) });
break;
}
if found.is_some() {
break;
}
}
let (track, from, to, cut) = found.ok_or_else(|| EngineError::Other("no edit point at the playhead on targeted tracks".into()))?;
if q.track(track).is_some_and(|tr| tr.locked) {
return Err(edit::EditError::Locked.into());
}
let start = if from.is_some() && to.is_some() {
cut - dur.mul_ratio(1, 2)
} else if to.is_some() {
cut
} else {
cut - dur
};
let tr = Transition { id: TransitionId(0), effect: instance, start: rate.snap(start), duration: dur, from, to, align: Default::default(), reverse };
Ok((track, tr))
}
/// JSON description of the project (bins, items) for agents.
pub fn inspect_project(s: &Session) -> Value {
let p = &s.project;
fn bin(b: &filmcraft_project::Bin, p: &filmcraft_project::Project) -> Value {
json!({
"id": b.id.0,
"name": b.name,
"children": b.children.iter().map(|c| match c {
filmcraft_project::BinEntry::Item(i) => {
let it = p.item(*i);
json!({"item": i.0, "name": it.map(|x| x.name.clone()), "type": it.map(|x| x.type_label()), "duration": it.map(|x| x.duration().0), "label": it.map(|x| x.label.name())})
}
filmcraft_project::BinEntry::Bin(b) => bin(b, p),
}).collect::<Vec<_>>()
})
}
json!({
"name": p.name,
"path": s.path,
"dirty": s.is_dirty(),
"revision": s.revision,
"root": bin(&p.root, p),
"activeSequence": s.state.active_sequence.map(|i| i.0),
"sourceItem": s.state.source_item.map(|i| i.0),
})
}
pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence) -> Value {
let rate = q.settings.frame_rate;
let tr = |t: &filmcraft_project::Track| {
json!({
"id": t.id.0, "name": t.name, "locked": t.locked, "syncLock": t.sync_lock, "enabled": t.enabled, "muted": t.muted, "solo": t.solo,
"items": t.items.iter().map(|i| json!({
"clip": i.id.0, "item": i.item.0, "name": i.name, "start": i.start.0, "duration": i.duration.0,
"startFrame": rate.frame_at(i.start), "durationFrames": rate.frame_at(i.duration), "sourceIn": i.source_in.0, "speed": i.speed, "reverse": i.reverse,
"end": i.end().0, "endFrame": rate.frame_at(i.end()), "sourceOut": i.source_out().0, "gainDb": i.gain_db,
"enabled": i.enabled, "link": i.link, "label": i.label.name(),
"effects": i.effects.iter().map(|e| json!({"effect": e.effect, "enabled": e.enabled, "masks": e.masks.len(), "params": e.params.iter().map(|(k, p)| (k.clone(), json!({"value": format!("{:?}", p.value), "keyframes": p.keyframes.len()}))).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"transitions": t.transitions.iter().map(|x| json!({"id": x.id.0, "effect": x.effect.effect, "start": x.start.0, "duration": x.duration.0, "from": x.from.map(|c| c.0), "to": x.to.map(|c| c.0), "reverse": x.reverse, "params": x.effect.params.iter().map(|(k, v)| (k.clone(), serde_json::to_value(&v.value).unwrap_or_default())).collect::<serde_json::Map<_, _>>()})).collect::<Vec<_>>(),
})
};
json!({
"id": id.0,
"name": s.project.item(id).map(|i| i.name.clone()),
"settings": q.settings,
"duration": q.duration().0,
"durationFrames": rate.frame_at(q.duration()),
"playhead": s.state.playheads.get(&id).map(|t| t.0),
"in": q.mark_in.map(|t| t.0),
"out": q.mark_out.map(|t| t.0),
"split": q.split,
"markers": q.markers.iter().map(|m| json!({"id": m.id.0, "start": m.start.0, "duration": m.duration.0, "kind": format!("{:?}", m.kind), "name": m.name, "comment": m.comment, "color": m.color.name()})).collect::<Vec<_>>(),
"video": q.video_tracks.iter().map(tr).collect::<Vec<_>>(),
"audio": q.audio_tracks.iter().map(tr).collect::<Vec<_>>(),
"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>(),
})
}
/// Seconds → ticks helper for callers.
pub fn secs(s: f64) -> Tick {
Tick((s * TICKS_PER_SECOND as f64).round() as i64)
}
// ---------- project files ----------
/// Serialize the project in the current schema and write it (atomically) to `path`. `adopt` =
/// Save / Save As (the file becomes the project's path and the project is clean); otherwise Save a
/// Copy. The first save over a file upgraded from an older schema keeps the original as
/// `<name> (schema vN backup).fcproj`.
/// A project is named after its file (as in Premiere): `/a/b/Trailer v2.fcproj` → `Trailer v2`.
fn project_name_for(path: &str) -> Option<String> {
let stem = std::path::Path::new(path).file_stem()?.to_string_lossy().to_string();
(!stem.is_empty()).then_some(stem)
}
fn name_project(p: &mut filmcraft_project::Project, name: String) {
p.root.name.clone_from(&name);
p.name = name;
}
fn write_project(s: &mut Session, path: &str, adopt: bool) -> Result<Value> {
let t0 = web_time::Instant::now();
// Save As renames the project after its new file (not an edit: no undo step, not dirty).
if adopt
&& let Some(name) = project_name_for(path)
&& s.project.name != name
{
name_project(std::sync::Arc::make_mut(&mut s.project), name);
}
// what is open (sequence tabs, how each is shown) goes into the file beside the project
let view = s.project_view();
let bytes = filmcraft_format::encode_with_view(&s.project, Some(&view), false);
let mut backup = None;
if adopt && s.path.as_deref() == Some(path) && s.loaded_schema < filmcraft_format::SCHEMA_VERSION {
let b = schema_backup_path(path, s.loaded_schema);
if s.services.read_file(&b).is_err()
&& let Ok(old) = s.services.read_file(path)
{
s.services.write_file(&b, &old).map_err(|e| EngineError::Other(format!("{b}: {e}")))?;
backup = Some(b);
}
}
s.services.write_file(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if adopt {
s.path = Some(path.to_string());
s.previews_follow_path();
s.note_recent_project();
s.saved_revision = s.revision;
s.loaded_schema = filmcraft_format::SCHEMA_VERSION;
}
Ok(json!({"path": path, "bytes": bytes.len(), "ms": t0.elapsed().as_secs_f64() * 1000.0, "backup": backup}))
}
fn schema_backup_path(path: &str, schema: u32) -> String {
let stem = path.strip_suffix(".fcproj").unwrap_or(path);
format!("{stem} (schema v{schema} backup).fcproj")
}
/// Make `proj` the session's project (fresh history, media pool and editor state).
fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Option<String>, clean: bool) {
let proxies = s.media.use_proxies();
s.media = std::sync::Arc::new(crate::MediaPool::default());
s.media.set_use_proxies(proxies);
s.offline = Default::default();
let first = proj.sequences().next().map(|i| i.id);
s.project = std::sync::Arc::new(proj);
s.history = Default::default();
s.history.limit = 200;
s.state = crate::Session::default().state;
s.state.active_sequence = first;
s.state.open_sequences = first.into_iter().collect();
if let Some(p) = &path
&& !cfg!(target_arch = "wasm32")
{
s.previews.set_dir(Some(crate::project_tools::previews_dir_for(&s.project, p)));
}
s.path = path;
s.revision += 1;
// A recovered project is unsaved: its saved revision is one that never existed.
s.saved_revision = if clean { s.revision } else { 0 };
s.loaded_schema = filmcraft_format::SCHEMA_VERSION;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
}
fn open_project(s: &mut Session, path: &str) -> Result<Value> {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let loaded = filmcraft_format::decode(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let (from, migrated) = (loaded.schema_version, loaded.migrated());
install_project(s, loaded.project, Some(path.to_string()), true);
if let Some(view) = loaded.view.filter(|_| s.prefs.timeline.restore_open_sequences) {
s.restore_project_view(view);
}
// the frontend shows the project's active sequence (with its own view, when one was restored)
if let Some(seq) = s.state.active_sequence {
s.events.push(crate::Event::OpenSequence(seq));
}
s.note_recent_project();
s.loaded_schema = from;
if migrated {
s.toast(format!("Upgraded project from schema v{from} to v{}; the original is kept as a backup when you save", filmcraft_format::SCHEMA_VERSION));
}
let missing = crate::relink::on_open(s);
Ok(json!({"path": path, "schemaVersion": from, "migrated": migrated, "missingMedia": missing}))
}
/// Load a recovery candidate (newest when `id` is None) as the current, unsaved project.
fn recover(s: &mut Session, id: Option<&str>) -> Result<Value> {
let per = s.persistence.as_ref().ok_or_else(|| EngineError::Other("recovery is not running".into()))?;
let idx = match id {
Some(id) => per.candidates.iter().position(|c| c.id == id).ok_or_else(|| bad("file.recover", format!("no recovery session `{id}`")))?,
None => 0,
};
let c = per.candidates[idx].clone();
let saved_at = per.local_time(c.meta.saved_unix);
let loaded = crate::autosave::load_candidate(&c).map_err(EngineError::Other)?;
install_project(s, loaded.project, c.meta.project_path.clone(), false);
if let Some(seq) = s.state.active_sequence {
s.events.push(crate::Event::OpenSequence(seq));
}
crate::relink::on_open(s);
// Our own journal must hold the recovered state before the old one is deleted.
s.sync_persistence();
if let Some(per) = s.persistence.as_mut() {
per.flush();
per.candidates.remove(idx);
}
crate::autosave::discard_candidate(&c).map_err(|e| EngineError::Other(e.to_string()))?;
s.toast(format!("Recovered unsaved changes to {} from {saved_at}", c.meta.project_name));
Ok(json!({"id": c.id, "name": c.meta.project_name, "path": c.meta.project_path, "savedAt": saved_at, "revision": c.meta.revision}))
}
/// Where auto-saves of the current project go.
fn auto_save_dir(s: &Session) -> std::path::PathBuf {
if let Some(d) = s.project.settings.scratch.auto_save.as_deref().filter(|d| !d.is_empty() && s.path.is_some()) {
return std::path::PathBuf::from(d);
}
match (&s.path, &s.persistence) {
(Some(p), _) => filmcraft_format::autosave::auto_save_dir(std::path::Path::new(p)),
(None, Some(per)) => per.data_dir.join(filmcraft_format::autosave::AUTO_SAVE_DIR),
(None, None) => std::path::PathBuf::from(filmcraft_format::autosave::AUTO_SAVE_DIR),
}
}
fn auto_save_name(s: &Session) -> String {
s.path.as_deref().and_then(|p| std::path::Path::new(p).file_stem()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| s.project.name.clone())
}