mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
7264594494
Fixes #343. Co-Authored-By: OpenAI Codex <noreply@openai.com>
1417 lines
59 KiB
Rust
1417 lines
59 KiB
Rust
//! Audio Track Mixer commands and the automation recorder.
|
|
//!
|
|
//! Strips are addressed by `strip` (or `track`): `"A1"` … (audio tracks), `"S1"` … (submixes),
|
|
//! `"Mix"` (the master), a track id, or a strip name. Lanes are `volume`, `pan`, `mute`,
|
|
//! `send.<i>.level` and `fx.<slot>.<param>` (see [`filmcraft_project::mixer`]).
|
|
//!
|
|
//! **Recording.** `mixer.recordStart` begins an automation pass at the playhead (the UI calls it
|
|
//! when playback starts). While it runs, `mixer.touch` / `mixer.release` are the fader gestures:
|
|
//! the audio follows the control immediately (through [`filmcraft_render::mixer::LiveMix`]) and the
|
|
//! gesture is recorded as a stream of (time, value) points. `mixer.recordStop` (playback stops)
|
|
//! thins every stream into keyframes and writes them as one undoable step, following the strip's
|
|
//! automation mode:
|
|
//!
|
|
//! | mode | records | after release |
|
|
//! |---|---|---|
|
|
//! | Off / Read | nothing (Read plays the automation) | the control returns to the automation |
|
|
//! | Latch | from the first touch | keeps writing the last value until playback stops |
|
|
//! | Touch | while touched | ramps back to the existing automation over the automatch time |
|
|
//! | Write | every control from the start of playback | keeps writing the last value until playback stops |
|
|
//!
|
|
//! With "Switch to Touch after Write" (on by default) a Write strip changes to Touch after the
|
|
//! pass.
|
|
//!
|
|
//! **Audio Clip Mixer.** Each audio track also has a Clip Mixer mode (`clipMixer.setMode`, session
|
|
//! state, Read by default). Its fader and pan knob are the lanes `clip.volume` / `clip.pan` of the
|
|
//! track (`clipMixer.touch` / `clipMixer.release`); they are heard live (the clip under the
|
|
//! playhead plays the held value) and recorded with the same Latch / Touch / Write rules, but the
|
|
//! pass writes **clip keyframes**: the Volume `level` and Panner `balance` of every clip the
|
|
//! gesture covers, in media time, thinned like track automation, with boundary keyframes that keep
|
|
//! each clip's values outside the recorded range. Without a recording pass, `mixer.touch` holds the control live and `mixer.release` commits
|
|
//! it once (a static value, or a keyframe at the playhead when the lane is automated).
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use filmcraft_project::mixer::{
|
|
self as pm, FADER_MAX_DB, FADER_MIN_DB, LANE_MUTE, LANE_PAN, LANE_VOLUME, MASTER_STRIP, lane_info, parse_fx_lane, parse_send_lane, thin_points, write_lane,
|
|
};
|
|
use filmcraft_project::{AudioChannels, AutomationMode, InputMap, ItemId, Param, ParamValue, Sequence, Track, TrackId, TrackKind, TrackSend, find_effect};
|
|
use filmcraft_time::{TICKS_PER_SECOND, Tick};
|
|
|
|
use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p};
|
|
use crate::{EngineError, Result, Session};
|
|
use filmcraft_project::mixer::{LANE_PAN51_X, LANE_PAN51_Y, PAN51_LANES};
|
|
use filmcraft_render::audio::{CLIP_LANE_PAN, CLIP_LANE_VOLUME};
|
|
|
|
type Run = fn(&mut Session, &Value) -> Result<Value>;
|
|
type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
|
|
|
// ------------------------------------------------------------------------------------- recorder
|
|
|
|
/// A held value ends this long before the next move (one sample at 48 kHz).
|
|
const HOLD_EPS: Tick = Tick(TICKS_PER_SECOND / 48_000);
|
|
|
|
/// One recorded control gesture.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Gesture {
|
|
pub strip: TrackId,
|
|
pub lane: String,
|
|
pub mode: AutomationMode,
|
|
/// Recorded (time, value) points.
|
|
pub points: Vec<(Tick, f64)>,
|
|
pub touching: bool,
|
|
/// Touch: released at.
|
|
pub released: Option<Tick>,
|
|
last: (Tick, f64),
|
|
}
|
|
|
|
impl Gesture {
|
|
fn new(strip: TrackId, lane: &str, mode: AutomationMode, t: Tick, v: f64) -> Self {
|
|
Gesture { strip, lane: lane.to_string(), mode, points: vec![(t, v)], touching: true, released: None, last: (t, v) }
|
|
}
|
|
/// A new value at `t`. Repeats of the same value extend the hold instead of adding points, so a
|
|
/// fader held still records a flat line, not a ramp to the next move.
|
|
fn push(&mut self, t: Tick, v: f64) {
|
|
if t < self.last.0 {
|
|
return;
|
|
}
|
|
if v == self.last.1 {
|
|
self.last.0 = t;
|
|
return;
|
|
}
|
|
if self.points.last().is_some_and(|p| self.last.0 > p.0) {
|
|
self.points.push(self.last);
|
|
}
|
|
self.points.push((t, v));
|
|
self.last = (t, v);
|
|
}
|
|
fn flush(&mut self) {
|
|
if self.points.last().is_some_and(|p| self.last.0 > p.0) {
|
|
self.points.push(self.last);
|
|
}
|
|
}
|
|
pub fn value(&self) -> f64 {
|
|
self.last.1
|
|
}
|
|
}
|
|
|
|
/// State of the current automation pass.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Recorder {
|
|
/// Sequence being recorded (None = no pass running).
|
|
pub seq: Option<ItemId>,
|
|
pub start: Tick,
|
|
pub gestures: Vec<Gesture>,
|
|
}
|
|
|
|
impl Recorder {
|
|
pub fn active(&self) -> bool {
|
|
self.seq.is_some()
|
|
}
|
|
fn find(&mut self, strip: TrackId, lane: &str) -> Option<&mut Gesture> {
|
|
self.gestures.iter_mut().find(|g| g.strip == strip && g.lane == lane)
|
|
}
|
|
}
|
|
|
|
/// Lanes a Write pass records on a strip (the 5.1 puck instead of pan when it feeds a 5.1 bus).
|
|
fn write_lanes(seq: &Sequence, strip: TrackId) -> &'static [&'static str] {
|
|
if strip == MASTER_STRIP {
|
|
&[LANE_VOLUME]
|
|
} else if seq.pans_51(strip) {
|
|
&[LANE_VOLUME, LANE_PAN51_X, LANE_PAN51_Y, LANE_MUTE]
|
|
} else {
|
|
&[LANE_VOLUME, LANE_PAN, LANE_MUTE]
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------- clip lanes
|
|
|
|
/// Whether `lane` is an Audio Clip Mixer lane (`clip.volume`, `clip.pan`).
|
|
pub fn is_clip_lane(lane: &str) -> bool {
|
|
lane == CLIP_LANE_VOLUME || lane == CLIP_LANE_PAN
|
|
}
|
|
|
|
/// The clip effect parameter behind a Clip Mixer lane.
|
|
fn clip_target(lane: &str) -> (&'static str, &'static str) {
|
|
if lane == CLIP_LANE_PAN { ("panner", "balance") } else { ("volume", "level") }
|
|
}
|
|
|
|
/// The Clip Mixer mode of an audio track (Read unless set).
|
|
pub fn clip_mode(s: &Session, track: TrackId) -> AutomationMode {
|
|
s.state.clip_mixer_modes.get(&track.0).copied().unwrap_or_default()
|
|
}
|
|
|
|
/// The clip lane value at `t`: the clip under `t` on the track (its keyframes or static value).
|
|
fn clip_underlying(tr: &Track, lane: &str, t: Tick) -> f64 {
|
|
let (eff, par) = clip_target(lane);
|
|
tr.item_at(t).and_then(|c| c.effect(eff).map(|e| e.f64_at(par, c.source_time_at(t)))).unwrap_or(0.0)
|
|
}
|
|
|
|
fn lane_underlying(tr: &Track, lane: &str, t: Tick) -> f64 {
|
|
if is_clip_lane(lane) { clip_underlying(tr, lane, t) } else { underlying(tr, lane, t) }
|
|
}
|
|
|
|
/// Parse a Clip Mixer lane from `lane` / `param` (`volume`/`level`, `pan`/`balance`).
|
|
fn clip_lane_p(p: &Value) -> String {
|
|
match str_p(p, "lane").or_else(|| str_p(p, "param")).unwrap_or("volume") {
|
|
"pan" | "balance" | "clip.pan" => CLIP_LANE_PAN.to_string(),
|
|
_ => CLIP_LANE_VOLUME.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Write recorded clip-lane points (sequence time) as keyframes into every clip of `tr` they cover.
|
|
fn write_clip_points(tr: &mut Track, lane: &str, pts: &[(Tick, f64)], sr: i64) -> usize {
|
|
let (eff, par) = clip_target(lane);
|
|
let (Some(t0), Some(t1)) = (pts.first().map(|p| p.0), pts.last().map(|p| p.0)) else { return 0 };
|
|
let mut written = 0;
|
|
for c in tr.items.iter_mut().filter(|c| c.end() > t0 && c.start <= t1) {
|
|
let c0 = t0.max(c.start);
|
|
let c1 = t1.min(c.end() - Tick(1));
|
|
if c1 < c0 {
|
|
continue;
|
|
}
|
|
// points inside the clip, plus the interpolated value at its edges of the range
|
|
let at = |t: Tick| -> f64 {
|
|
let i = pts.partition_point(|p| p.0 <= t);
|
|
match (i.checked_sub(1).map(|j| pts[j]), pts.get(i)) {
|
|
(Some(a), Some(b)) if b.0 > a.0 => a.1 + (b.1 - a.1) * ((t - a.0).0 as f64 / (b.0 - a.0).0 as f64),
|
|
(Some(a), _) => a.1,
|
|
(None, Some(b)) => b.1,
|
|
(None, None) => 0.0,
|
|
}
|
|
};
|
|
let mut local: Vec<(Tick, f64)> = vec![(c0, at(c0))];
|
|
local.extend(pts.iter().copied().filter(|p| p.0 > c0 && p.0 < c1));
|
|
if c1 > c0 {
|
|
local.push((c1, at(c1)));
|
|
}
|
|
let mt: Vec<(Tick, f64)> = local.iter().map(|&(t, v)| (c.source_time_at(t), v)).collect();
|
|
let (m0, m1) = (mt[0].0.min(mt[mt.len() - 1].0), mt[0].0.max(mt[mt.len() - 1].0));
|
|
let mut mt = mt;
|
|
mt.sort_by_key(|p| p.0);
|
|
let eps = Tick::from_units(1, sr).max(Tick(1));
|
|
let starts_inside = c0 > c.start;
|
|
let ends_inside = c1 < c.end() - Tick(1);
|
|
let Some(e) = c.effect_mut(eff) else { continue };
|
|
let Some(param) = e.param_mut(par) else { continue };
|
|
let before = starts_inside.then(|| param.f64_at(m0 - eps));
|
|
let after = ends_inside.then(|| param.f64_at(m1 + eps));
|
|
write_lane(param, m0, m1, &mt, before, after, eps, false);
|
|
written += mt.len();
|
|
}
|
|
written
|
|
}
|
|
|
|
/// The lane value as automation plays it (no live overrides).
|
|
fn underlying(tr: &Track, lane: &str, t: Tick) -> f64 {
|
|
let v = tr.lane_value(lane, t);
|
|
if lane_info(lane).is_some_and(|i| i.hold) {
|
|
// hold lanes: last keyframe at or before t
|
|
if let Some(p) = tr.lane(lane).filter(|p| p.is_animated() && tr.mixer.mode.reads()) {
|
|
let i = p.keyframes.partition_point(|k| k.time <= t);
|
|
return p.keyframes[i.saturating_sub(1)].value.as_f64().unwrap_or(v);
|
|
}
|
|
}
|
|
v
|
|
}
|
|
|
|
fn clamp_lane(lane: &str, v: f64) -> f64 {
|
|
if lane == CLIP_LANE_VOLUME {
|
|
return v.clamp(FADER_MIN_DB, FADER_MAX_DB);
|
|
}
|
|
if lane == CLIP_LANE_PAN {
|
|
return v.clamp(-100.0, 100.0);
|
|
}
|
|
match lane_info(lane) {
|
|
Some(i) if i.hold => {
|
|
if v >= 0.5 {
|
|
1.0
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
Some(i) => v.clamp(i.min, i.max),
|
|
None => v,
|
|
}
|
|
}
|
|
|
|
fn seq_of(s: &Session, id: ItemId) -> Result<&Sequence> {
|
|
s.project.sequence(id).ok_or(EngineError::NoSequence)
|
|
}
|
|
|
|
/// Start an automation pass at `t` (playback start).
|
|
pub fn record_start(s: &mut Session, t: Tick) -> Result<Value> {
|
|
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
|
let seq = seq_of(s, seq_id)?;
|
|
let mut gestures = Vec::new();
|
|
for id in seq.strip_ids() {
|
|
let Some(tr) = seq.strip(id) else { continue };
|
|
if tr.mixer.mode != AutomationMode::Write {
|
|
continue;
|
|
}
|
|
for lane in write_lanes(seq, id) {
|
|
let v = underlying(&tr, lane, t);
|
|
let mut g = Gesture::new(id, lane, AutomationMode::Write, t, v);
|
|
g.touching = false;
|
|
gestures.push(g);
|
|
}
|
|
}
|
|
// Audio Clip Mixer tracks in Write mode
|
|
for tr in &seq.audio_tracks {
|
|
if clip_mode(s, tr.id) != AutomationMode::Write {
|
|
continue;
|
|
}
|
|
for lane in [CLIP_LANE_VOLUME, CLIP_LANE_PAN] {
|
|
let mut g = Gesture::new(tr.id, lane, AutomationMode::Write, t, clip_underlying(tr, lane, t));
|
|
g.touching = false;
|
|
gestures.push(g);
|
|
}
|
|
}
|
|
s.previews.live.clear_all();
|
|
for g in &gestures {
|
|
s.previews.live.hold(g.strip, &g.lane, g.value());
|
|
}
|
|
let n = gestures.len();
|
|
s.mixrec = Recorder { seq: Some(seq_id), start: t, gestures };
|
|
Ok(json!({"recording": true, "writing": n}))
|
|
}
|
|
|
|
/// A control moved to `v` at `t`.
|
|
pub fn touch(s: &mut Session, strip: TrackId, lane: &str, v: f64, t: Tick) -> Result<Value> {
|
|
let v = clamp_lane(lane, v);
|
|
let seq_id = s.mixrec.seq.or(s.state.active_sequence).ok_or(EngineError::NoSequence)?;
|
|
let strip_mode = seq_of(s, seq_id)?.strip(strip).ok_or_else(|| bad("mixer.touch", "no such strip"))?.mixer.mode;
|
|
let mode = if is_clip_lane(lane) { clip_mode(s, strip) } else { strip_mode };
|
|
s.previews.live.hold(strip, lane, v);
|
|
if !s.mixrec.active() || !mode.writes() {
|
|
return Ok(json!({"recording": false, "value": v}));
|
|
}
|
|
match s.mixrec.find(strip, lane) {
|
|
Some(g) => {
|
|
if !g.touching {
|
|
if g.mode == AutomationMode::Touch {
|
|
// a new touch after a release: the old ramp is overwritten from here
|
|
g.released = None;
|
|
} else if t > g.last.0 {
|
|
// Write / Latch held the last value until this touch
|
|
g.last.0 = (t - HOLD_EPS).max(g.last.0);
|
|
}
|
|
}
|
|
g.touching = true;
|
|
g.push(t, v);
|
|
}
|
|
None => s.mixrec.gestures.push(Gesture::new(strip, lane, mode, t, v)),
|
|
}
|
|
Ok(json!({"recording": true, "value": v}))
|
|
}
|
|
|
|
/// A control let go at `t`.
|
|
pub fn release(s: &mut Session, strip: TrackId, lane: &str, t: Tick) -> Result<Value> {
|
|
let automatch = Tick::from_seconds_f64(s.prefs.audio.automatch_time);
|
|
if s.mixrec.active() {
|
|
if let Some(g) = s.mixrec.find(strip, lane) {
|
|
g.touching = false;
|
|
g.push(t, g.value());
|
|
if g.mode == AutomationMode::Touch {
|
|
g.released = Some(t);
|
|
s.previews.live.release(strip, lane, t, automatch);
|
|
}
|
|
// Latch / Write keep holding the last value until playback stops.
|
|
return Ok(json!({"recording": true}));
|
|
}
|
|
s.previews.live.clear(strip, lane);
|
|
return Ok(json!({"recording": false}));
|
|
}
|
|
// no pass: commit the held value once
|
|
let held = s.previews.live.get(strip, lane);
|
|
s.previews.live.clear(strip, lane);
|
|
match held {
|
|
Some(o) if is_clip_lane(lane) => {
|
|
set_clip_value(s, strip, lane, o.value, t)?;
|
|
Ok(json!({"recording": false, "value": o.value}))
|
|
}
|
|
Some(o) => {
|
|
set_value(s, strip, lane, o.value, t)?;
|
|
Ok(json!({"recording": false, "value": o.value}))
|
|
}
|
|
None => Ok(json!({"recording": false})),
|
|
}
|
|
}
|
|
|
|
/// End the pass at `t`: write every gesture as keyframes (one undo step).
|
|
pub fn record_stop(s: &mut Session, t_stop: Tick) -> Result<Value> {
|
|
let rec = std::mem::take(&mut s.mixrec);
|
|
let Some(seq_id) = rec.seq.filter(|_| !rec.gestures.is_empty()) else {
|
|
s.previews.live.clear_all();
|
|
return Ok(json!({"recording": false, "lanes": 0}));
|
|
};
|
|
let automatch = Tick::from_seconds_f64(s.prefs.audio.automatch_time);
|
|
let prefs = s.prefs.audio.clone();
|
|
let rate = seq_of(s, seq_id)?.settings.sample_rate.max(1) as i64;
|
|
let eps = Tick::from_units(1, rate).max(Tick(1));
|
|
let min_interval = if prefs.minimum_time_interval_thinning { Tick(prefs.minimum_time_ms as i64 * TICKS_PER_SECOND / 1000) } else { Tick::ZERO };
|
|
let mut written = Vec::new();
|
|
let mut keyframes = 0usize;
|
|
let result = s.edit("Write Automation", |p, _| {
|
|
let seq = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
|
for mut g in rec.gestures {
|
|
if g.points.is_empty() {
|
|
continue;
|
|
}
|
|
let Some(tr) = seq.strip(g.strip).map(|c| c.into_owned()) else { continue };
|
|
let clip_lane = is_clip_lane(&g.lane);
|
|
let hold = !clip_lane && lane_info(&g.lane).is_some_and(|i| i.hold);
|
|
let held = g.value();
|
|
let end = match g.mode {
|
|
AutomationMode::Touch => g.released.unwrap_or(t_stop).min(t_stop.max(g.points[0].0)),
|
|
_ => t_stop,
|
|
};
|
|
g.push(end.max(g.last.0), held);
|
|
g.flush();
|
|
let mut pts = g.points.clone();
|
|
if g.mode == AutomationMode::Touch && automatch.0 > 0 && !hold {
|
|
let back = end + automatch;
|
|
pts.push((back, lane_underlying(&tr, &g.lane, back)));
|
|
}
|
|
let t0 = pts[0].0;
|
|
let t1 = pts.last().map(|p| p.0).unwrap_or(t0);
|
|
let tol = if hold {
|
|
0.0
|
|
} else if prefs.linear_keyframe_thinning {
|
|
if g.lane == LANE_PAN || g.lane == CLIP_LANE_PAN || PAN51_LANES.contains(&g.lane.as_str()) { 0.25 } else { 0.05 }
|
|
} else {
|
|
0.0
|
|
};
|
|
let thinned = thin_points(&pts, tol, min_interval, hold);
|
|
if clip_lane {
|
|
let lane = g.lane.clone();
|
|
let n = seq.mix_track_mut(g.strip).map(|tr| write_clip_points(tr, &lane, &thinned, rate)).unwrap_or(0);
|
|
keyframes += n;
|
|
written.push(json!({"strip": g.strip.0, "lane": lane, "from": t0.0, "to": t1.0, "keyframes": n, "points": pts.len(), "clips": true}));
|
|
continue;
|
|
}
|
|
let before = (t0.0 > 0).then(|| underlying(&tr, &g.lane, t0 - eps));
|
|
let after = tr.lane(&g.lane).filter(|p| p.keyframes.iter().any(|k| k.time > t1)).map(|_| underlying(&tr, &g.lane, t1 + eps));
|
|
keyframes += thinned.len();
|
|
let lane = g.lane.clone();
|
|
seq.with_strip_mut(g.strip, |tr| {
|
|
if let Some(param) = tr.lane_mut(&lane) {
|
|
write_lane(param, t0, t1, &thinned, before, after, eps, hold);
|
|
}
|
|
});
|
|
written.push(json!({"strip": g.strip.0, "lane": lane, "from": t0.0, "to": t1.0, "keyframes": thinned.len(), "points": pts.len()}));
|
|
}
|
|
if prefs.switch_to_touch_after_write {
|
|
for id in seq.strip_ids() {
|
|
seq.with_strip_mut(id, |tr| {
|
|
if tr.mixer.mode == AutomationMode::Write {
|
|
tr.mixer.mode = AutomationMode::Touch;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
});
|
|
s.previews.live.clear_all();
|
|
s.previews.live.publish_project(s.project.clone());
|
|
result?;
|
|
Ok(json!({"recording": false, "lanes": written.len(), "keyframes": keyframes, "written": written}))
|
|
}
|
|
|
|
/// Commit a Clip Mixer value outside a pass: the clip under `t` on the track gets the value (a
|
|
/// keyframe at `t` when the parameter is animated, else its static value).
|
|
pub fn set_clip_value(s: &mut Session, track: TrackId, lane: &str, v: f64, t: Tick) -> Result<()> {
|
|
let (eff, par) = clip_target(lane);
|
|
let v = clamp_lane(lane, v);
|
|
s.edit_sequence("Audio Clip Mixer", move |q, _, _| {
|
|
let tr = q.mix_track_mut(track).ok_or_else(|| EngineError::Other("no such track".into()))?;
|
|
let idx = tr.items.iter().position(|c| c.start <= t && t < c.end()).ok_or_else(|| EngineError::Other("no clip under the playhead".into()))?;
|
|
let c = &mut tr.items[idx];
|
|
let mt = c.source_time_at(t);
|
|
let p = c.effect_mut(eff).and_then(|e| e.param_mut(par)).ok_or_else(|| EngineError::Other(format!("the clip has no {eff} effect")))?;
|
|
if p.is_animated() {
|
|
p.put_keyframe(mt, ParamValue::Float(v));
|
|
} else {
|
|
p.value = ParamValue::Float(v);
|
|
}
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn clip_track_p(s: &Session, p: &Value, cmd: &str) -> Result<TrackId> {
|
|
let id = strip_p(s, p, cmd)?;
|
|
if !active_seq(s)?.audio_tracks.iter().any(|t| t.id == id) {
|
|
return Err(bad(cmd, "the Audio Clip Mixer has audio tracks only"));
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------- helpers
|
|
|
|
fn active_seq(s: &Session) -> Result<&Sequence> {
|
|
s.active_sequence().ok_or(EngineError::NoSequence)
|
|
}
|
|
|
|
/// Parse a strip reference: id, "A1", "S1", "Mix", or a strip name.
|
|
pub fn strip_ref(seq: &Sequence, v: &Value) -> Option<TrackId> {
|
|
if let Some(id) = v.as_u64() {
|
|
let id = TrackId(id);
|
|
return seq.strip(id).map(|_| id);
|
|
}
|
|
let name = v.as_str()?.trim();
|
|
if name.eq_ignore_ascii_case("mix") || name.eq_ignore_ascii_case("master") {
|
|
return Some(MASTER_STRIP);
|
|
}
|
|
let idx = |pre: &str| {
|
|
name.strip_prefix(pre).or_else(|| name.strip_prefix(&pre.to_lowercase())).and_then(|n| n.parse::<usize>().ok()).and_then(|n| n.checked_sub(1))
|
|
};
|
|
if let Some(i) = idx("A") {
|
|
return seq.audio_tracks.get(i).map(|t| t.id);
|
|
}
|
|
if let Some(i) = idx("S") {
|
|
return seq.submix_tracks.get(i).map(|t| t.id);
|
|
}
|
|
seq.audio_tracks.iter().chain(&seq.submix_tracks).find(|t| t.name.eq_ignore_ascii_case(name)).map(|t| t.id)
|
|
}
|
|
|
|
/// Display reference of a strip ("A1", "S2", "Mix").
|
|
pub fn strip_label(seq: &Sequence, id: TrackId) -> String {
|
|
if id == MASTER_STRIP {
|
|
return "Mix".into();
|
|
}
|
|
if let Some(i) = seq.audio_tracks.iter().position(|t| t.id == id) {
|
|
return format!("A{}", i + 1);
|
|
}
|
|
if let Some(i) = seq.submix_tracks.iter().position(|t| t.id == id) {
|
|
return format!("S{}", i + 1);
|
|
}
|
|
id.0.to_string()
|
|
}
|
|
|
|
fn strip_p(s: &Session, p: &Value, cmd: &str) -> Result<TrackId> {
|
|
let seq = active_seq(s)?;
|
|
let v = p.get("strip").or_else(|| p.get("track")).ok_or_else(|| bad(cmd, "need `strip` (\"A1\", \"S1\", \"Mix\" or an id)"))?;
|
|
strip_ref(seq, v).ok_or_else(|| bad(cmd, format!("no strip {v}")))
|
|
}
|
|
|
|
fn lane_p(s: &Session, p: &Value, strip: TrackId, cmd: &str) -> Result<String> {
|
|
let lane = str_p(p, "lane").unwrap_or(LANE_VOLUME).to_string();
|
|
let seq = active_seq(s)?;
|
|
let tr = seq.strip(strip).ok_or_else(|| bad(cmd, "no such strip"))?;
|
|
let ok = match parse_fx_lane(&lane) {
|
|
Some((slot, param)) => tr.effects.get(slot).and_then(|e| e.param(param)).is_some_and(|p| p.value.as_f64().is_some()),
|
|
None => lane_info(&lane).is_some() && (parse_send_lane(&lane).is_none_or(|i| i < tr.mixer.sends.len())),
|
|
};
|
|
if !ok {
|
|
return Err(bad(cmd, format!("no lane `{lane}` on this strip (volume, pan, mute, send.<i>.level, fx.<slot>.<param>)")));
|
|
}
|
|
if strip == MASTER_STRIP && (lane == LANE_PAN || lane == LANE_MUTE || PAN51_LANES.contains(&lane.as_str()) || parse_send_lane(&lane).is_some()) {
|
|
return Err(bad(cmd, "the Mix track has volume and effect lanes only"));
|
|
}
|
|
Ok(lane)
|
|
}
|
|
|
|
fn value_p(p: &Value, lane: &str, cmd: &str) -> Result<f64> {
|
|
let v = p.get("value").ok_or_else(|| bad(cmd, "need `value`"))?;
|
|
let x = v.as_f64().or_else(|| v.as_bool().map(|b| if b { 1.0 } else { 0.0 })).ok_or_else(|| bad(cmd, "`value` must be a number"))?;
|
|
Ok(clamp_lane(lane, x))
|
|
}
|
|
|
|
/// Set a control outside a recording pass: the static value, or a keyframe at `t` when the lane
|
|
/// is automated and the strip reads automation.
|
|
pub fn set_value(s: &mut Session, strip: TrackId, lane: &str, v: f64, t: Tick) -> Result<()> {
|
|
let lane = lane.to_string();
|
|
let hold = lane_info(&lane).is_some_and(|i| i.hold);
|
|
s.edit_sequence("Track Mixer Adjust", move |q, _, _| {
|
|
q.with_strip_mut(strip, |tr| {
|
|
let animated = tr.lane(&lane).is_some_and(Param::is_animated) && tr.mixer.mode.reads();
|
|
if animated {
|
|
if let Some(p) = tr.lane_mut(&lane) {
|
|
set_keyframe(p, t, v, hold);
|
|
}
|
|
} else {
|
|
tr.set_lane_static(&lane, v);
|
|
}
|
|
})
|
|
.ok_or_else(|| EngineError::Other("no such strip".into()))
|
|
})
|
|
}
|
|
|
|
fn set_keyframe(p: &mut Param, t: Tick, v: f64, hold: bool) {
|
|
p.put_keyframe(t, ParamValue::Float(v));
|
|
if hold {
|
|
for k in p.keyframes.iter_mut() {
|
|
k.interp = filmcraft_project::Interpolation::Hold;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mode_p(p: &Value) -> Option<AutomationMode> {
|
|
str_p(p, "mode").and_then(AutomationMode::from_name)
|
|
}
|
|
|
|
pub(crate) fn channels_name(c: AudioChannels) -> &'static str {
|
|
match c {
|
|
AudioChannels::Mono => "Mono",
|
|
AudioChannels::Stereo => "Stereo",
|
|
AudioChannels::Surround51 => "5.1",
|
|
AudioChannels::Adaptive => "Adaptive",
|
|
}
|
|
}
|
|
|
|
/// Parse a channel format / track type name (`Mono`, `Stereo` or `Standard`, `5.1`, `Adaptive`).
|
|
pub(crate) fn channels_from(s: &str) -> Option<AudioChannels> {
|
|
match s.to_ascii_lowercase().as_str() {
|
|
"mono" => Some(AudioChannels::Mono),
|
|
"stereo" | "standard" => Some(AudioChannels::Stereo),
|
|
"5.1" | "surround51" => Some(AudioChannels::Surround51),
|
|
"adaptive" => Some(AudioChannels::Adaptive),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// JSON view of one strip at time `t`.
|
|
pub fn strip_json(s: &Session, seq: &Sequence, id: TrackId, t: Tick) -> Value {
|
|
let Some(tr) = seq.strip(id) else { return Value::Null };
|
|
let live = &s.previews.live;
|
|
let shown = |lane: &str| live.get(id, lane).map(|o| o.value).unwrap_or_else(|| underlying(&tr, lane, t));
|
|
let lanes: serde_json::Map<String, Value> = tr
|
|
.automated_lanes()
|
|
.into_iter()
|
|
.map(|k| {
|
|
let kf: Vec<Value> = tr.lane_keyframes(&k).iter().map(|kf| json!([kf.time.0, kf.value.as_f64()])).collect();
|
|
(k, Value::Array(kf))
|
|
})
|
|
.collect();
|
|
let kind = if id == MASTER_STRIP {
|
|
"mix"
|
|
} else if seq.submix_tracks.iter().any(|t| t.id == id) {
|
|
"submix"
|
|
} else {
|
|
"audio"
|
|
};
|
|
json!({
|
|
"id": id.0,
|
|
"ref": strip_label(seq, id),
|
|
"name": tr.name,
|
|
"kind": kind,
|
|
"channels": channels_name(tr.channels),
|
|
"mode": tr.mixer.mode.label(),
|
|
"volumeDb": tr.volume_db,
|
|
"pan": tr.pan,
|
|
"muted": tr.muted,
|
|
"solo": tr.solo,
|
|
"recordArm": tr.mixer.record_arm,
|
|
"soloSafe": tr.mixer.solo_safe,
|
|
"inputMap": tr.mixer.input_map.label(),
|
|
"output": tr.mixer.output.map(|o| strip_label(seq, o)).unwrap_or_else(|| if id == MASTER_STRIP { String::new() } else { "Mix".into() }),
|
|
"inserts": tr.effects.iter().enumerate().map(|(i, e)| json!({"slot": i, "effect": e.effect, "name": e.def().map(|d| d.name).unwrap_or(""), "enabled": e.enabled, "postFader": e.post_fader, "params": insert_params(e)})).collect::<Vec<_>>(),
|
|
"sends": tr.mixer.sends.iter().enumerate().map(|(i, sd)| json!({"send": i, "target": strip_label(seq, sd.target), "levelDb": sd.level_db, "pan": sd.pan, "preFader": sd.pre_fader, "muted": sd.muted})).collect::<Vec<_>>(),
|
|
"at": {"time": t.0, "volumeDb": shown(LANE_VOLUME), "pan": shown(LANE_PAN), "mute": shown(LANE_MUTE) >= 0.5},
|
|
"outputChannels": channels_name(seq.output_channels(id)),
|
|
"pans51": seq.pans_51(id),
|
|
"pan51": {"x": shown(LANE_PAN51_X), "y": shown(LANE_PAN51_Y), "center": shown(pm::LANE_PAN51_CENTER), "lfeDb": shown(pm::LANE_PAN51_LFE)},
|
|
"lanes": lanes,
|
|
})
|
|
}
|
|
|
|
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
|
let seq = active_seq(s)?;
|
|
let strips: Vec<Value> = seq.strip_ids().into_iter().map(|id| strip_json(s, seq, id, t)).collect();
|
|
Ok(json!({
|
|
"strips": strips,
|
|
"recording": s.mixrec.active(),
|
|
"gestures": s.mixrec.gestures.iter().map(|g| json!({"strip": strip_label(seq, g.strip), "lane": g.lane, "mode": g.mode.label(), "touching": g.touching, "points": g.points.len(), "value": g.value()})).collect::<Vec<_>>(),
|
|
"latencySamples": filmcraft_render::mixer::graph_latency(seq),
|
|
"automatchTime": s.prefs.audio.automatch_time,
|
|
"clipModes": seq.audio_tracks.iter().map(|t| json!({"track": strip_label(seq, t.id), "mode": clip_mode(s, t.id).label()})).collect::<Vec<_>>(),
|
|
}))
|
|
}
|
|
|
|
fn set_strip(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.setStrip")?;
|
|
let seq = active_seq(s)?;
|
|
let output = match p.get("output") {
|
|
None => None,
|
|
Some(Value::Null) => Some(None),
|
|
Some(v) => {
|
|
let o = strip_ref(seq, v).ok_or_else(|| bad("mixer.setStrip", format!("no output {v}")))?;
|
|
if o == MASTER_STRIP {
|
|
Some(None)
|
|
} else if o == id || !seq.submix_tracks.iter().any(|t| t.id == o) {
|
|
return Err(bad("mixer.setStrip", "output must be the Mix or a submix"));
|
|
} else if seq
|
|
.submix_tracks
|
|
.iter()
|
|
.position(|t| t.id == id)
|
|
.is_some_and(|me| seq.submix_tracks.iter().position(|t| t.id == o).is_some_and(|it| it <= me))
|
|
{
|
|
return Err(bad("mixer.setStrip", "a submix can only feed a submix after it (no feedback)"));
|
|
} else {
|
|
Some(Some(o))
|
|
}
|
|
}
|
|
};
|
|
let p = p.clone();
|
|
s.edit_sequence("Track Mixer Settings", move |q, _, _| {
|
|
q.with_strip_mut(id, |t| {
|
|
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.clamp(FADER_MIN_DB, FADER_MAX_DB);
|
|
}
|
|
if let Some(v) = f64_p(&p, "pan") {
|
|
t.pan = v.clamp(-100.0, 100.0);
|
|
}
|
|
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) = bool_p(&p, "recordArm") {
|
|
t.mixer.record_arm = v;
|
|
}
|
|
if let Some(v) = bool_p(&p, "soloSafe") {
|
|
t.mixer.solo_safe = v;
|
|
}
|
|
if let Some(m) = mode_p(&p) {
|
|
t.mixer.mode = m;
|
|
}
|
|
if let Some(m) = str_p(&p, "inputMap").and_then(InputMap::from_name) {
|
|
t.mixer.input_map = m;
|
|
}
|
|
if let Some(c) = str_p(&p, "channels").and_then(channels_from) {
|
|
t.channels = c;
|
|
}
|
|
if let Some(o) = output {
|
|
t.mixer.output = o;
|
|
}
|
|
})
|
|
.ok_or_else(|| EngineError::Other("no such strip".into()))
|
|
})?;
|
|
let seq = active_seq(s)?;
|
|
Ok(strip_json(s, seq, id, s.playhead()))
|
|
}
|
|
|
|
fn add_submix(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let name = str_p(p, "name").map(str::to_string);
|
|
let channels = str_p(p, "channels").and_then(channels_from).unwrap_or(AudioChannels::Stereo);
|
|
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
|
let id = s.edit("Add Submix Track", |pr, _| {
|
|
let id = TrackId(pr.alloc_id());
|
|
let seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
|
let n = seq.submix_tracks.len() + 1;
|
|
let mut t = Track::new(id, TrackKind::Audio, name.unwrap_or_else(|| format!("Submix {n}")));
|
|
t.channels = channels;
|
|
seq.submix_tracks.push(t);
|
|
Ok(id)
|
|
})?;
|
|
let seq = active_seq(s)?;
|
|
Ok(json!({"id": id.0, "ref": strip_label(seq, id)}))
|
|
}
|
|
|
|
fn delete_submix(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.deleteSubmix")?;
|
|
if !active_seq(s)?.submix_tracks.iter().any(|t| t.id == id) {
|
|
return Err(bad("mixer.deleteSubmix", "not a submix"));
|
|
}
|
|
s.edit_sequence("Delete Submix Track", move |q, _, _| {
|
|
q.submix_tracks.retain(|t| t.id != id);
|
|
// re-route everything that fed it to the Mix and drop sends to it
|
|
for sid in q.strip_ids() {
|
|
q.with_strip_mut(sid, |t| {
|
|
if t.mixer.output == Some(id) {
|
|
t.mixer.output = None;
|
|
}
|
|
while let Some(i) = t.mixer.sends.iter().position(|sd| sd.target == id) {
|
|
t.remove_send(i);
|
|
}
|
|
});
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn slot_p(p: &Value, cmd: &str) -> Result<usize> {
|
|
u64_p(p, "slot").map(|v| v as usize).ok_or_else(|| bad(cmd, "need `slot` (0-based)"))
|
|
}
|
|
|
|
/// An insert's scalar parameter values (numbers; toggles as booleans) for `mixer.inspect`.
|
|
fn insert_params(e: &filmcraft_project::EffectInstance) -> Value {
|
|
let mut m = serde_json::Map::new();
|
|
for (k, p) in &e.params {
|
|
let v = match &p.value {
|
|
ParamValue::Bool(b) => json!(b),
|
|
v => v.as_f64().map_or(Value::Null, |x| json!(x)),
|
|
};
|
|
m.insert(k.clone(), v);
|
|
}
|
|
Value::Object(m)
|
|
}
|
|
|
|
fn add_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.addInsert")?;
|
|
let eid = str_p(p, "effect").ok_or_else(|| bad("mixer.addInsert", "need `effect` (an audio effect id)"))?;
|
|
let def = find_effect(eid)
|
|
.filter(|d| d.kind == filmcraft_project::EffectKind::Audio && !d.intrinsic)
|
|
.ok_or_else(|| bad("mixer.addInsert", format!("`{eid}` is not an audio effect")))?;
|
|
let mut inst = def.instance();
|
|
inst.post_fader = bool_p(p, "postFader").unwrap_or(false);
|
|
let slot = u64_p(p, "slot").map(|v| v as usize);
|
|
let n = active_seq(s)?.strip(id).map(|t| t.effects.len()).unwrap_or(0);
|
|
if n >= pm::MAX_INSERTS {
|
|
return Err(bad("mixer.addInsert", format!("all {} effect slots are in use", pm::MAX_INSERTS)));
|
|
}
|
|
let at = slot.unwrap_or(n).min(n);
|
|
s.edit_sequence(&format!("Add Track Effect {}", def.name), move |q, _, _| {
|
|
q.with_strip_mut(id, |t| t.effects.insert(at, inst)).ok_or_else(|| EngineError::Other("no such strip".into()))
|
|
})?;
|
|
Ok(json!({"slot": at}))
|
|
}
|
|
|
|
fn remove_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.removeInsert")?;
|
|
let slot = slot_p(p, "mixer.removeInsert")?;
|
|
let ok = s.edit_sequence("Remove Track Effect", move |q, _, _| Ok(q.with_strip_mut(id, |t| t.remove_insert(slot)).unwrap_or(false)))?;
|
|
if !ok {
|
|
return Err(bad("mixer.removeInsert", "no effect in that slot"));
|
|
}
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn set_insert(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.setInsert")?;
|
|
let slot = slot_p(p, "mixer.setInsert")?;
|
|
let p = p.clone();
|
|
s.edit_sequence("Track Effect Settings", move |q, _, _| {
|
|
q.with_strip_mut(id, |t| -> Result<()> {
|
|
let e = t.effects.get_mut(slot).ok_or_else(|| bad("mixer.setInsert", "no effect in that slot"))?;
|
|
if let Some(v) = bool_p(&p, "enabled") {
|
|
e.enabled = v;
|
|
}
|
|
if let Some(v) = bool_p(&p, "postFader") {
|
|
e.post_fader = v;
|
|
}
|
|
if let Some(obj) = p.get("params").and_then(Value::as_object) {
|
|
for (k, v) in obj {
|
|
// instances saved before a parameter existed get it from the definition
|
|
if e.param(k).is_none()
|
|
&& let Some(d) = e.def().and_then(|d| d.param(k))
|
|
{
|
|
e.params.insert(k.clone(), filmcraft_project::Param::new(d.default.clone()));
|
|
}
|
|
let par = e.param_mut(k).ok_or_else(|| bad("mixer.setInsert", format!("no parameter `{k}`")))?;
|
|
par.value = match (&par.value, v) {
|
|
(ParamValue::Bool(_), Value::Bool(b)) => ParamValue::Bool(*b),
|
|
(ParamValue::Choice(_), v) => ParamValue::Choice(v.as_u64().unwrap_or(0) as u32),
|
|
(_, v) => ParamValue::Float(v.as_f64().ok_or_else(|| bad("mixer.setInsert", format!("`{k}` needs a number")))?),
|
|
};
|
|
}
|
|
}
|
|
Ok(())
|
|
})
|
|
.ok_or_else(|| EngineError::Other("no such strip".into()))?
|
|
})?;
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn send_target(s: &Session, p: &Value, id: TrackId, cmd: &str) -> Result<TrackId> {
|
|
let seq = active_seq(s)?;
|
|
let v = p.get("target").ok_or_else(|| bad(cmd, "need `target` (a submix)"))?;
|
|
let t = strip_ref(seq, v).filter(|t| seq.submix_tracks.iter().any(|x| x.id == *t)).ok_or_else(|| bad(cmd, "`target` must be a submix"))?;
|
|
let pos = |x: TrackId| seq.submix_tracks.iter().position(|y| y.id == x);
|
|
if t == id || pos(id).is_some_and(|me| pos(t).is_some_and(|it| it <= me)) {
|
|
return Err(bad(cmd, "a submix can only send to a submix after it (no feedback)"));
|
|
}
|
|
Ok(t)
|
|
}
|
|
|
|
fn add_send(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.addSend")?;
|
|
if id == MASTER_STRIP {
|
|
return Err(bad("mixer.addSend", "the Mix track has no sends"));
|
|
}
|
|
let target = send_target(s, p, id, "mixer.addSend")?;
|
|
let mut snd = TrackSend::new(target);
|
|
snd.level_db = f64_p(p, "levelDb").unwrap_or(0.0).clamp(FADER_MIN_DB, FADER_MAX_DB);
|
|
snd.pre_fader = bool_p(p, "preFader").unwrap_or(false);
|
|
snd.pan = f64_p(p, "pan").unwrap_or(0.0).clamp(-100.0, 100.0);
|
|
if active_seq(s)?.strip(id).is_some_and(|t| t.mixer.sends.len() >= pm::MAX_SENDS) {
|
|
return Err(bad("mixer.addSend", format!("all {} send slots are in use", pm::MAX_SENDS)));
|
|
}
|
|
let i = s.edit_sequence("Add Send", move |q, _, _| {
|
|
q.with_strip_mut(id, |t| {
|
|
t.mixer.sends.push(snd);
|
|
t.mixer.sends.len() - 1
|
|
})
|
|
.ok_or_else(|| EngineError::Other("no such strip".into()))
|
|
})?;
|
|
Ok(json!({"send": i}))
|
|
}
|
|
|
|
fn set_send(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.setSend")?;
|
|
let i = u64_p(p, "send").ok_or_else(|| bad("mixer.setSend", "need `send` (0-based)"))? as usize;
|
|
let target = if p.get("target").is_some() { Some(send_target(s, p, id, "mixer.setSend")?) } else { None };
|
|
let p = p.clone();
|
|
s.edit_sequence("Send Settings", move |q, _, _| {
|
|
q.with_strip_mut(id, |t| -> Result<()> {
|
|
let sd = t.mixer.sends.get_mut(i).ok_or_else(|| bad("mixer.setSend", "no such send"))?;
|
|
if let Some(v) = f64_p(&p, "levelDb") {
|
|
sd.level_db = v.clamp(FADER_MIN_DB, FADER_MAX_DB);
|
|
}
|
|
if let Some(v) = f64_p(&p, "pan") {
|
|
sd.pan = v.clamp(-100.0, 100.0);
|
|
}
|
|
if let Some(v) = bool_p(&p, "preFader") {
|
|
sd.pre_fader = v;
|
|
}
|
|
if let Some(v) = bool_p(&p, "muted") {
|
|
sd.muted = v;
|
|
}
|
|
if let Some(t) = target {
|
|
sd.target = t;
|
|
}
|
|
Ok(())
|
|
})
|
|
.ok_or_else(|| EngineError::Other("no such strip".into()))?
|
|
})?;
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn remove_send(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.removeSend")?;
|
|
let i = u64_p(p, "send").ok_or_else(|| bad("mixer.removeSend", "need `send` (0-based)"))? as usize;
|
|
let ok = s.edit_sequence("Remove Send", move |q, _, _| Ok(q.with_strip_mut(id, |t| t.remove_send(i)).unwrap_or(false)))?;
|
|
if !ok {
|
|
return Err(bad("mixer.removeSend", "no such send"));
|
|
}
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn set_kf(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.setKeyframe")?;
|
|
let lane = lane_p(s, p, id, "mixer.setKeyframe")?;
|
|
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
|
let v = value_p(p, &lane, "mixer.setKeyframe")?;
|
|
let hold = lane_info(&lane).is_some_and(|i| i.hold);
|
|
s.edit_sequence("Add Track Keyframe", move |q, _, _| {
|
|
q.with_strip_mut(id, |tr| {
|
|
if let Some(par) = tr.lane_mut(&lane) {
|
|
if par.keyframes.is_empty() {
|
|
// the first keyframe keeps the current value everywhere else
|
|
let cur = par.value.as_f64().unwrap_or(v);
|
|
par.value = ParamValue::Float(cur);
|
|
}
|
|
set_keyframe(par, t, v, hold);
|
|
}
|
|
})
|
|
.ok_or_else(|| EngineError::Other("no such strip".into()))
|
|
})?;
|
|
Ok(json!({"time": t.0, "value": v}))
|
|
}
|
|
|
|
fn delete_kf(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.deleteKeyframe")?;
|
|
let lane = lane_p(s, p, id, "mixer.deleteKeyframe")?;
|
|
let t = time_p(s, p, "").ok_or_else(|| bad("mixer.deleteKeyframe", "need `time`"))?;
|
|
let ok = s.edit_sequence("Delete Track Keyframe", move |q, _, _| {
|
|
Ok(q.with_strip_mut(id, |tr| tr.lane_mut(&lane).is_some_and(|par| par.remove_keyframe_at(t))).unwrap_or(false))
|
|
})?;
|
|
if !ok {
|
|
return Err(bad("mixer.deleteKeyframe", "no keyframe at that time"));
|
|
}
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn move_kf(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.moveKeyframe")?;
|
|
let lane = lane_p(s, p, id, "mixer.moveKeyframe")?;
|
|
let t = time_p(s, p, "").ok_or_else(|| bad("mixer.moveKeyframe", "need `time` (the keyframe)"))?;
|
|
let nt = time_p(s, p, "new");
|
|
let nv = p.get("value").map(|_| value_p(p, &lane, "mixer.moveKeyframe")).transpose()?;
|
|
let ok = s.edit_sequence("Move Track Keyframe", move |q, _, _| {
|
|
Ok(q.with_strip_mut(id, |tr| {
|
|
let Some(par) = tr.lane_mut(&lane) else { return false };
|
|
let Ok(i) = par.keyframes.binary_search_by_key(&t, |k| k.time) else { return false };
|
|
let mut k = par.keyframes.remove(i);
|
|
if let Some(nt) = nt {
|
|
// keep keyframe order: clamp between the neighbours
|
|
let lo = i.checked_sub(1).map(|j| par.keyframes[j].time + Tick(1)).unwrap_or(Tick::ZERO);
|
|
let hi = par.keyframes.get(i).map(|n| n.time - Tick(1)).unwrap_or(Tick(i64::MAX));
|
|
k.time = nt.clamp(lo, hi);
|
|
}
|
|
if let Some(v) = nv {
|
|
k.value = ParamValue::Float(v);
|
|
}
|
|
let j = par.keyframes.partition_point(|x| x.time < k.time);
|
|
par.keyframes.insert(j, k);
|
|
true
|
|
})
|
|
.unwrap_or(false))
|
|
})?;
|
|
if !ok {
|
|
return Err(bad("mixer.moveKeyframe", "no keyframe at that time"));
|
|
}
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn clear_lane(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.clearLane")?;
|
|
let lane = lane_p(s, p, id, "mixer.clearLane")?;
|
|
let t = s.playhead();
|
|
s.edit_sequence("Clear Track Keyframes", move |q, _, _| {
|
|
q.with_strip_mut(id, |tr| {
|
|
let v = tr.lane_value(&lane, t);
|
|
if let Some(par) = tr.lane_mut(&lane) {
|
|
par.keyframes.clear();
|
|
}
|
|
tr.set_lane_static(&lane, v);
|
|
if parse_fx_lane(&lane).is_none() {
|
|
tr.mixer.lanes.remove(&lane);
|
|
}
|
|
})
|
|
.ok_or_else(|| EngineError::Other("no such strip".into()))
|
|
})?;
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
fn write_automation(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = strip_p(s, p, "mixer.writeAutomation")?;
|
|
let lane = lane_p(s, p, id, "mixer.writeAutomation")?;
|
|
let pts: Vec<(Tick, f64)> = p
|
|
.get("points")
|
|
.and_then(Value::as_array)
|
|
.ok_or_else(|| bad("mixer.writeAutomation", "need `points`: [[ticks, value], …]"))?
|
|
.iter()
|
|
.filter_map(|v| Some((Tick(v.get(0)?.as_i64()?), clamp_lane(&lane, v.get(1)?.as_f64()?))))
|
|
.collect();
|
|
if pts.is_empty() {
|
|
return Err(bad("mixer.writeAutomation", "no points"));
|
|
}
|
|
let hold = lane_info(&lane).is_some_and(|i| i.hold);
|
|
let tol = f64_p(p, "tolerance").unwrap_or(if hold { 0.0 } else { 0.05 });
|
|
let thinned = thin_points(&pts, tol, Tick::ZERO, hold);
|
|
let (t0, t1) = (thinned[0].0, thinned[thinned.len() - 1].0);
|
|
let rate = active_seq(s)?.settings.sample_rate.max(1) as i64;
|
|
let eps = Tick::from_units(1, rate).max(Tick(1));
|
|
let n = thinned.len();
|
|
s.edit_sequence("Write Automation", move |q, _, _| {
|
|
q.with_strip_mut(id, |tr| {
|
|
let before = (t0.0 > 0).then(|| underlying(tr, &lane, t0 - eps));
|
|
let after = tr.lane(&lane).filter(|p| p.keyframes.iter().any(|k| k.time > t1)).map(|_| underlying(tr, &lane, t1 + eps));
|
|
if let Some(par) = tr.lane_mut(&lane) {
|
|
write_lane(par, t0, t1, &thinned, before, after, eps, hold);
|
|
}
|
|
})
|
|
.ok_or_else(|| EngineError::Other("no such strip".into()))
|
|
})?;
|
|
Ok(json!({"keyframes": n, "points": pts.len()}))
|
|
}
|
|
|
|
/// Audio Clip Mixer: set the clip's Volume level / Panner balance (or any scalar clip effect
|
|
/// parameter) at the playhead. With `keyframe` (or when already animated) it writes a keyframe at the
|
|
/// playhead; a drag (same clip and parameter, no other edit between) is one undo step.
|
|
fn clip_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let clip = filmcraft_project::ClipId(u64_p(p, "clip").ok_or_else(|| bad("clipMixer.set", "need `clip`"))?);
|
|
let effect = str_p(p, "effect").unwrap_or("volume").to_string();
|
|
let param = str_p(p, "param").unwrap_or("level").to_string();
|
|
let v = f64_p(p, "value").ok_or_else(|| bad("clipMixer.set", "need `value`"))?;
|
|
let kf = bool_p(p, "keyframe").unwrap_or(false);
|
|
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
|
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
|
let key = format!("clipMixer:{}:{effect}:{param}", clip.0);
|
|
if bool_p(p, "begin").unwrap_or(false) {
|
|
s.history.merge_key = None;
|
|
}
|
|
s.edit_merged("Audio Clip Mixer", &key, |pr, _| {
|
|
let seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
|
let (_, it) = seq.find_item_mut(clip).ok_or_else(|| bad("clipMixer.set", "no such clip"))?;
|
|
let mt = it.source_time_at(t.clamp(it.start, it.end()));
|
|
let e = it.effect_mut(&effect).ok_or_else(|| bad("clipMixer.set", format!("the clip has no `{effect}` effect")))?;
|
|
let par = e.param_mut(¶m).ok_or_else(|| bad("clipMixer.set", format!("no parameter `{param}`")))?;
|
|
let v = if param == "level" { v.clamp(FADER_MIN_DB, FADER_MAX_DB) } else { v.clamp(-100.0, 100.0) };
|
|
if kf || par.is_animated() {
|
|
par.put_keyframe(mt, ParamValue::Float(v));
|
|
} else {
|
|
par.value = ParamValue::Float(v);
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------- audio gain
|
|
|
|
/// Peak (dBFS, before clip gain) of each selected audio clip's source range.
|
|
fn clip_peaks(s: &Session, clips: &[filmcraft_project::ClipId]) -> Vec<(filmcraft_project::ClipId, f64)> {
|
|
let Some(seq) = s.active_sequence() else { return Vec::new() };
|
|
let sr = seq.settings.sample_rate.max(1);
|
|
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
|
let mut out = Vec::new();
|
|
for t in &seq.audio_tracks {
|
|
for it in t.items.iter().filter(|i| clips.contains(&i.id)) {
|
|
let Some(src) = filmcraft_render::SourceProvider::source(&provider, it.item) else { continue };
|
|
if !src.info().has_audio() {
|
|
continue;
|
|
}
|
|
let s0 = it.source_in.to_units_floor(sr as i64);
|
|
let len = ((it.duration.to_units_floor(sr as i64) as f64) * it.speed.abs().max(1e-6)).ceil() as i64;
|
|
let mut peak = 0f32;
|
|
let mut pos = s0;
|
|
while pos < s0 + len {
|
|
let n = (s0 + len - pos).min(sr as i64) as usize;
|
|
if let Ok(b) = src.audio(pos, n, sr) {
|
|
peak = b.peaks().into_iter().fold(peak, f32::max);
|
|
}
|
|
pos += n as i64;
|
|
}
|
|
out.push((it.id, 20.0 * (peak.max(1e-9) as f64).log10()));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn gain_targets(s: &Session, p: &Value) -> Vec<filmcraft_project::ClipId> {
|
|
let sel: Vec<filmcraft_project::ClipId> = match p.get("clips").and_then(Value::as_array) {
|
|
Some(a) => a.iter().filter_map(|v| v.as_u64().map(filmcraft_project::ClipId)).collect(),
|
|
None => s.state.selection.clone(),
|
|
};
|
|
crate::commands::with_links(s, &sel)
|
|
}
|
|
|
|
/// `clip.audioPeak`: the peak amplitude of the selection (with its current clip gain), as the Audio
|
|
/// Gain dialog shows it.
|
|
pub fn audio_peak(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let clips = gain_targets(s, p);
|
|
let seq = active_seq(s)?;
|
|
let gain = |c: filmcraft_project::ClipId| seq.find_item(c).map(|(_, i)| i.gain_db).unwrap_or(0.0);
|
|
let peaks = clip_peaks(s, &clips);
|
|
let max = peaks.iter().map(|(c, pk)| pk + gain(*c)).fold(f64::NEG_INFINITY, f64::max);
|
|
Ok(json!({
|
|
"peakDb": if max.is_finite() { json!(max) } else { Value::Null },
|
|
"clips": peaks.iter().map(|(c, pk)| json!({"clip": c.0, "sourcePeakDb": pk, "gainDb": gain(*c)})).collect::<Vec<_>>(),
|
|
}))
|
|
}
|
|
|
|
/// `clip.audioGain`: Set Gain to / Adjust Gain by / Normalize Max Peak to / Normalize All Peaks to.
|
|
pub fn audio_gain(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let db = f64_p(p, "db").unwrap_or(0.0);
|
|
let mode = match str_p(p, "mode") {
|
|
Some(m) => m.to_string(),
|
|
None => if bool_p(p, "relative").unwrap_or(true) { "adjust" } else { "set" }.to_string(),
|
|
};
|
|
let clips = gain_targets(s, p);
|
|
let new_gain: Vec<(filmcraft_project::ClipId, f64)> = match mode.as_str() {
|
|
"set" => clips.iter().map(|c| (*c, db)).collect(),
|
|
"adjust" => {
|
|
let seq = active_seq(s)?;
|
|
clips.iter().filter_map(|c| seq.find_item(*c).map(|(_, i)| (*c, i.gain_db + db))).collect()
|
|
}
|
|
"normalizeMax" | "normalizeAll" => {
|
|
let peaks = clip_peaks(s, &clips);
|
|
if peaks.is_empty() {
|
|
return Err(bad("clip.audioGain", "no audio clips with audio in the selection"));
|
|
}
|
|
let seq = active_seq(s)?;
|
|
let gain = |c: filmcraft_project::ClipId| seq.find_item(c).map(|(_, i)| i.gain_db).unwrap_or(0.0);
|
|
if mode == "normalizeAll" {
|
|
peaks.iter().map(|(c, pk)| (*c, db - pk)).collect()
|
|
} else {
|
|
let loudest = peaks.iter().map(|(c, pk)| pk + gain(*c)).fold(f64::NEG_INFINITY, f64::max);
|
|
let delta = db - loudest;
|
|
peaks.iter().map(|(c, _)| (*c, gain(*c) + delta)).collect()
|
|
}
|
|
}
|
|
m => return Err(bad("clip.audioGain", format!("unknown mode `{m}` (set, adjust, normalizeMax, normalizeAll)"))),
|
|
};
|
|
let applied = s.edit_sequence("Audio Gain", move |q, _, _| {
|
|
let mut applied = Vec::new();
|
|
for t in q.audio_tracks.iter_mut() {
|
|
for i in t.items.iter_mut() {
|
|
if let Some((_, g)) = new_gain.iter().find(|(c, _)| *c == i.id) {
|
|
i.gain_db = g.clamp(-96.0, 96.0);
|
|
applied.push((i.id, i.gain_db));
|
|
}
|
|
}
|
|
}
|
|
Ok(applied)
|
|
})?;
|
|
Ok(json!({"mode": mode, "clips": applied.len(), "gainDb": applied.iter().map(|(c, g)| json!({"clip": c.0, "gainDb": g})).collect::<Vec<_>>()}))
|
|
}
|
|
|
|
/// Effects panel ▸ Set Selected as Default Transition (video or audio, from the effect's kind).
|
|
fn set_default_transition(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = str_p(p, "effect").ok_or_else(|| bad("effects.setDefaultTransition", "need `effect`"))?;
|
|
use filmcraft_project::vtransition::find_transition;
|
|
let def = find_effect(id)
|
|
.or_else(|| find_transition(id, filmcraft_project::EffectKind::VideoTransition))
|
|
.or_else(|| find_transition(id, filmcraft_project::EffectKind::AudioTransition))
|
|
.or_else(|| filmcraft_project::effect_defs().iter().find(|d| d.name.eq_ignore_ascii_case(id)))
|
|
.ok_or_else(|| bad("effects.setDefaultTransition", format!("no effect `{id}`")))?;
|
|
match def.kind {
|
|
filmcraft_project::EffectKind::AudioTransition => s.state.default_audio_transition = def.id.to_string(),
|
|
filmcraft_project::EffectKind::VideoTransition => s.state.default_video_transition = def.id.to_string(),
|
|
_ => return Err(bad("effects.setDefaultTransition", format!("`{}` is not a transition", def.name))),
|
|
}
|
|
Ok(json!({"video": s.state.default_video_transition, "audio": s.state.default_audio_transition}))
|
|
}
|
|
|
|
fn not_recording(s: &Session) -> std::result::Result<(), String> {
|
|
has_seq(s)?;
|
|
if s.mixrec.active() { Err("an automation pass is already recording".into()) } else { Ok(()) }
|
|
}
|
|
|
|
fn spec(
|
|
id: &'static str,
|
|
label: &'static str,
|
|
menu: &'static [&'static str],
|
|
shortcut: Option<&'static str>,
|
|
params: &'static str,
|
|
enabled: Enabled,
|
|
run: Run,
|
|
journal: bool,
|
|
) -> CommandSpec {
|
|
CommandSpec { id, label, menu, shortcut, params, enabled, run, journal }
|
|
}
|
|
|
|
pub fn commands() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec("mixer.inspect", "Inspect Audio Track Mixer", &[], None, r#"{"time":ticks?}"#, always, inspect, false),
|
|
spec(
|
|
"mixer.setStrip",
|
|
"Track Mixer Settings",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|"Mix"|id,"name":str?,"volumeDb":f64?,"pan":f64?,"muted":bool?,"solo":bool?,"recordArm":bool?,"soloSafe":bool?,"mode":"Off|Read|Latch|Touch|Write"?,"output":"Mix"|"S1"?,"inputMap":"Stereo|Left|Right|Swap|Mono"?,"channels":"Mono|Stereo|5.1"?}"#,
|
|
has_seq,
|
|
set_strip,
|
|
true,
|
|
),
|
|
spec(
|
|
"mixer.setValue",
|
|
"Set Mixer Control",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":"volume|pan|mute|send.<i>.level|fx.<slot>.<param>","value":f64,"time":ticks?}"#,
|
|
has_seq,
|
|
|s, p| {
|
|
let id = strip_p(s, p, "mixer.setValue")?;
|
|
let lane = lane_p(s, p, id, "mixer.setValue")?;
|
|
let v = value_p(p, &lane, "mixer.setValue")?;
|
|
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
|
if s.mixrec.active() {
|
|
touch(s, id, &lane, v, t)?;
|
|
return release(s, id, &lane, t);
|
|
}
|
|
set_value(s, id, &lane, v, t)?;
|
|
Ok(json!({"value": v}))
|
|
},
|
|
true,
|
|
),
|
|
spec(
|
|
"mixer.touch",
|
|
"Touch Mixer Control",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":"volume|pan|mute|send.<i>.level|fx.<slot>.<param>","value":f64,"time":ticks?}"#,
|
|
has_seq,
|
|
|s, p| {
|
|
let id = strip_p(s, p, "mixer.touch")?;
|
|
let lane = lane_p(s, p, id, "mixer.touch")?;
|
|
let v = value_p(p, &lane, "mixer.touch")?;
|
|
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
|
touch(s, id, &lane, v, t)
|
|
},
|
|
false,
|
|
),
|
|
spec(
|
|
"mixer.release",
|
|
"Release Mixer Control",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?,"time":ticks?}"#,
|
|
has_seq,
|
|
|s, p| {
|
|
let id = strip_p(s, p, "mixer.release")?;
|
|
let lane = lane_p(s, p, id, "mixer.release")?;
|
|
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
|
release(s, id, &lane, t)
|
|
},
|
|
true,
|
|
),
|
|
spec(
|
|
"mixer.recordStart",
|
|
"Start Automation Pass",
|
|
&[],
|
|
None,
|
|
r#"{"time":ticks?}"#,
|
|
not_recording,
|
|
|s, p| {
|
|
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
|
record_start(s, t)
|
|
},
|
|
false,
|
|
),
|
|
spec(
|
|
"mixer.recordStop",
|
|
"Write Automation",
|
|
&[],
|
|
None,
|
|
r#"{"time":ticks?}"#,
|
|
has_seq,
|
|
|s, p| {
|
|
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
|
record_stop(s, t)
|
|
},
|
|
true,
|
|
),
|
|
spec(
|
|
"clipMixer.set",
|
|
"Audio Clip Mixer Adjust",
|
|
&[],
|
|
None,
|
|
r#"{"clip":id,"effect":"volume"|"panner","param":"level"|"balance","value":f64,"keyframe":bool?,"time":ticks?,"begin":bool?}"#,
|
|
has_seq,
|
|
clip_set,
|
|
true,
|
|
),
|
|
spec(
|
|
"clipMixer.setMode",
|
|
"Audio Clip Mixer Automation Mode",
|
|
&[],
|
|
None,
|
|
r#"{"track":"A1"|id,"mode":"Off|Read|Latch|Touch|Write"}"#,
|
|
has_seq,
|
|
|s, p| {
|
|
let id = clip_track_p(s, p, "clipMixer.setMode")?;
|
|
let m = mode_p(p).ok_or_else(|| bad("clipMixer.setMode", "need `mode` (Off, Read, Latch, Touch, Write)"))?;
|
|
s.state.clip_mixer_modes.insert(id.0, m);
|
|
Ok(json!({"track": id.0, "mode": m.label()}))
|
|
},
|
|
false,
|
|
),
|
|
spec(
|
|
"clipMixer.touch",
|
|
"Touch Clip Mixer Control",
|
|
&[],
|
|
None,
|
|
r#"{"track":"A1"|id,"lane":"volume|pan","value":f64,"time":ticks?}"#,
|
|
has_seq,
|
|
|s, p| {
|
|
let id = clip_track_p(s, p, "clipMixer.touch")?;
|
|
let lane = clip_lane_p(p);
|
|
let v = f64_p(p, "value").ok_or_else(|| bad("clipMixer.touch", "need `value`"))?;
|
|
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
|
touch(s, id, &lane, v, t)
|
|
},
|
|
false,
|
|
),
|
|
spec(
|
|
"clipMixer.release",
|
|
"Release Clip Mixer Control",
|
|
&[],
|
|
None,
|
|
r#"{"track":"A1"|id,"lane":"volume|pan","time":ticks?}"#,
|
|
has_seq,
|
|
|s, p| {
|
|
let id = clip_track_p(s, p, "clipMixer.release")?;
|
|
let lane = clip_lane_p(p);
|
|
let t = time_p(s, p, "").unwrap_or(s.playhead());
|
|
release(s, id, &lane, t)
|
|
},
|
|
true,
|
|
),
|
|
spec(
|
|
"effects.setDefaultTransition",
|
|
"Set Selected as Default Transition",
|
|
&[],
|
|
None,
|
|
r#"{"effect":"constant_power|constant_gain|exponential_fade|<video transition>"}"#,
|
|
always,
|
|
set_default_transition,
|
|
true,
|
|
),
|
|
spec("mixer.addSubmix", "Add Audio Submix Track", &["Sequence"], None, r#"{"name":str?,"channels":"Mono|Stereo|5.1"?}"#, has_seq, add_submix, true),
|
|
spec("mixer.deleteSubmix", "Delete Submix Track", &[], None, r#"{"strip":"S1"|id}"#, has_seq, delete_submix, true),
|
|
spec(
|
|
"mixer.addInsert",
|
|
"Add Track Effect",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|"Mix"|id,"effect":str,"slot":n?,"postFader":bool?}"#,
|
|
has_seq,
|
|
add_insert,
|
|
true,
|
|
),
|
|
spec("mixer.removeInsert", "Remove Track Effect", &[], None, r#"{"strip":"A1"|"S1"|"Mix"|id,"slot":n}"#, has_seq, remove_insert, true),
|
|
spec(
|
|
"mixer.setInsert",
|
|
"Track Effect Settings",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|"Mix"|id,"slot":n,"enabled":bool?,"postFader":bool?,"params":{id:value}?}"#,
|
|
has_seq,
|
|
set_insert,
|
|
true,
|
|
),
|
|
spec(
|
|
"mixer.addSend",
|
|
"Add Send",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|id,"target":"S1"|id,"levelDb":f64?,"preFader":bool?,"pan":f64?}"#,
|
|
has_seq,
|
|
add_send,
|
|
true,
|
|
),
|
|
spec(
|
|
"mixer.setSend",
|
|
"Send Settings",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|id,"send":n,"levelDb":f64?,"pan":f64?,"preFader":bool?,"muted":bool?,"target":"S1"?}"#,
|
|
has_seq,
|
|
set_send,
|
|
true,
|
|
),
|
|
spec("mixer.removeSend", "Remove Send", &[], None, r#"{"strip":"A1"|"S1"|id,"send":n}"#, has_seq, remove_send, true),
|
|
spec(
|
|
"mixer.setKeyframe",
|
|
"Add Track Keyframe",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?,"time":ticks?,"value":f64}"#,
|
|
has_seq,
|
|
set_kf,
|
|
true,
|
|
),
|
|
spec("mixer.deleteKeyframe", "Delete Track Keyframe", &[], None, r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?,"time":ticks}"#, has_seq, delete_kf, true),
|
|
spec(
|
|
"mixer.moveKeyframe",
|
|
"Move Track Keyframe",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?,"time":ticks,"newTime":ticks?,"value":f64?}"#,
|
|
has_seq,
|
|
move_kf,
|
|
true,
|
|
),
|
|
spec("mixer.clearLane", "Clear Track Keyframes", &[], None, r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?}"#, has_seq, clear_lane, true),
|
|
spec(
|
|
"mixer.writeAutomation",
|
|
"Write Automation Points",
|
|
&[],
|
|
None,
|
|
r#"{"strip":"A1"|"S1"|"Mix"|id,"lane":str?,"points":[[ticks,value]],"tolerance":f64?}"#,
|
|
has_seq,
|
|
write_automation,
|
|
true,
|
|
),
|
|
]
|
|
}
|