Files
filmcraft/crates/engine/src/keyboard.rs
T
vangel76andClaude Sonnet 5.5 1d62db0172 #1368: nudge clip selection up/down no longer fails for linked video+audio
Problem: timeline.nudgeUp/nudgeDown returned an error for the whole selection when any selected clip was already on the outermost track in that direction. A video clip with its linked audio (A1 has no track above) therefore could never be nudged.

Fix: clips with no destination track stay where they are; the command only errors when nothing could move. Regression test covers a linked selection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:35:54 +02:00

847 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Keyboard-only editing commands from Premiere's default keyboard (M3.12): commands that have a
//! default shortcut in Premiere but no menu item, so they exist only to be pressed.
//!
//! | Id | Premiere command | Default key (scope) |
//! |---|---|---|
//! | `playhead.nextEditAnyTrack` / `playhead.prevEditAnyTrack` | Go to Next / Previous Edit Point on Any Track | Shift+Down / Shift+Up |
//! | `playhead.selectedClipStart` / `playhead.selectedClipEnd` | Go to Selected Clip Start / End | Shift+Home / Shift+End |
//! | `sequence.revealNested` | Reveal Nested Sequence | Cmd+Alt+F |
//! | `timeline.selectClipAtPlayhead` | Select Clip at Playhead | D |
//! | `timeline.selectNextClip` / `timeline.selectPrevClip` | Select Next / Previous Clip | Cmd+Down / Cmd+Up |
//! | `trim.extendPreviousEdit` / `trim.extendNextEdit` | Extend Previous / Next Edit To Playhead | Shift+Q / Shift+W |
//! | `timeline.nudgeLeft` … `timeline.nudgeRight5` | Nudge Clip Selection Left / Right One / Five Frames | Cmd+Left, Cmd+Right, Cmd+Shift+Left, Cmd+Shift+Right (Timeline) |
//! | `timeline.nudgeUp` / `timeline.nudgeDown` | Nudge Clip Selection Up / Down | Alt+Up / Alt+Down (Timeline) |
//! | `timeline.slipLeft` … `timeline.slipRight5` | Slip Clip Selection Left / Right One / Five Frames | Cmd+Alt+Left … Cmd+Alt+Shift+Right (Timeline) |
//! | `timeline.slideLeft` … `timeline.slideRight5` | Slide Clip Selection Left / Right One / Five Frames | Alt+, … Alt+Shift+. (Timeline) |
//! | `timeline.toggleAllVideoTargets` / `timeline.toggleAllAudioTargets` | Toggle All Video / Audio Targets | Cmd+0 / Cmd+9 |
//! | `timeline.toggleAllSourceVideo` / `timeline.toggleAllSourceAudio` | Toggle All Source Video / Audio | Cmd+Alt+0 / Cmd+Alt+9 |
//! | `timeline.toggleTargetV1` … `timeline.toggleTargetA8` | Toggle Target Video 1–8 / Audio 1–8 | |
//! | `timeline.moveVideoTargetsUp` … `timeline.moveAudioTargetsDown` | Move All Video / Audio Targets Up / Down | |
//! | `timeline.toggleMuteTargetedAudio` / `timeline.toggleSoloTargetedAudio` | Toggle Mute / Solo for All Targeted Audio Tracks | |
//! | `timeline.toggleOutputTargetedVideo` | Toggle Track Output for All Targeted Video Tracks | |
//! | `clip.volumeUp` / `clip.volumeDown` | Increase / Decrease Clip Volume (1 dB) | ] / [ |
//! | `clip.volumeUpMany` / `clip.volumeDownMany` | Increase / Decrease Clip Volume Many (Settings ▸ Audio ▸ Large Volume Adjustment) | Shift+] / Shift+[ |
//! | `clip.nudgeVolumeUp1` … `clip.nudgeVolumeDown3` | Nudge Volume ±1 dB / ±3 dB | |
//! | `audio.toggleScrubbing` | Toggle Audio During Scrubbing | Shift+S |
//! | `graphics.fontSizeUp` … `graphics.fontSizeDown5` | Increase / Decrease Font Size by One / Five Units | Cmd+Alt+Right … Cmd+Alt+Shift+Left |
//! | `graphics.leadingUp` … `graphics.leadingDown5` | Increase / Decrease Leading by One / Five Units | Alt+Up … Alt+Shift+Down |
//! | `graphics.alignTextLeft` / `…Center` / `…Right` | Left / Center / Right align text | Cmd+Shift+L / C / R |
//! | `graphics.nudgeLeft` … `graphics.nudgeDown5` | Nudge Selected Object by one / five | Cmd+arrows, Cmd+Shift+arrows (Program, Properties) |
//! | `file.exportFrame` | Export Frame | Shift+E |
//! | `clip.setPosterFrame` / `clip.clearPosterFrame` | Set / Clear Poster Frame | Cmd+P / Alt+P |
//!
//! Navigation and targeting are editor state (not undoable, as in Premiere); everything that
//! changes the project is one undo step.
use filmcraft_edit as edit;
pub use filmcraft_export::still::StillFormat;
use filmcraft_geom::Vec2;
use filmcraft_project::{ClipId, ItemKind, ParamValue, Sequence, TrackId, TrackKind};
use filmcraft_time::Tick;
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, has_selection, has_seq, item_p, slip_together, time_p, with_links};
use crate::{EngineError, Result, Session};
type Run = fn(&mut Session, &Value) -> Result<Value>;
type Enabled = fn(&Session) -> std::result::Result<(), String>;
fn spec(id: &'static str, label: &'static str, shortcut: Option<&'static str>, params: &'static str, enabled: Enabled, run: Run) -> CommandSpec {
CommandSpec { id, label, menu: &[], shortcut, params, enabled, run, journal: true }
}
/// Metadata key holding an item's poster frame (media ticks).
pub const POSTER_FRAME_KEY: &str = "Poster Frame";
/// An item's poster frame (Set Poster Frame), if one is set.
pub fn poster_frame(item: &filmcraft_project::ProjectItem) -> Option<Tick> {
item.metadata.get(POSTER_FRAME_KEY).and_then(|v| v.parse::<i64>().ok()).map(Tick)
}
// ------------------------------------------------------------------ navigation
/// Edit points on the given tracks (all tracks when `tracks` is None), sorted and unique.
fn edits_on(seq: &Sequence, tracks: Option<&[TrackId]>) -> Vec<Tick> {
let mut v: Vec<Tick> =
seq.all_tracks().filter(|t| tracks.is_none_or(|ts| ts.contains(&t.id))).flat_map(|t| t.items.iter().flat_map(|i| [i.start, i.end()])).collect();
v.sort_unstable();
v.dedup();
v
}
/// Go to the next / previous edit point: on the targeted tracks (`any` = false; all tracks when
/// none is targeted) or on any track.
pub fn go_to_edit(s: &mut Session, next: bool, any: bool) -> Result<Value> {
let t = s.playhead();
let tg = s.targeting().targeted;
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let only = (!any && !tg.is_empty()).then_some(tg.as_slice());
let edits = edits_on(seq, only);
let e = if next { edits.into_iter().find(|e| *e > t) } else { edits.into_iter().rev().find(|e| *e < t) };
if let Some(e) = e {
s.set_playhead(e);
}
Ok(json!({"time": s.playhead().0}))
}
fn selected_span(s: &Session) -> Option<(Tick, Tick)> {
let seq = s.active_sequence()?;
let items: Vec<_> = s.state.selection.iter().filter_map(|c| seq.find_item(*c).map(|(_, i)| i)).collect();
let a = items.iter().map(|i| i.start).min()?;
let b = items.iter().map(|i| i.end()).max()?;
Some((a, b))
}
fn go_to_selected(s: &mut Session, end: bool) -> Result<Value> {
let (a, b) = selected_span(s).ok_or_else(|| EngineError::Other("no clips selected".into()))?;
// Premiere parks on the clip's last frame for End (the frame before the Out edge)
let t = if end { b - s.sequence_rate().frame_duration() } else { a };
s.set_playhead(t.max(a));
Ok(json!({"time": s.playhead().0}))
}
/// Reveal Nested Sequence: open the nested sequence of the selected clip (or the topmost nested
/// clip under the playhead on a targeted track) with its playhead on the matching frame.
fn reveal_nested(s: &mut Session) -> Result<Value> {
let ph = s.playhead();
let tg = s.targeting().targeted;
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let is_nest = |it: &filmcraft_project::TrackItem| s.project.sequence(it.item).is_some();
let pick = s
.state
.selection
.iter()
.filter_map(|c| seq.find_item(*c).map(|(_, i)| i))
.find(|i| is_nest(i))
.or_else(|| {
seq.video_tracks.iter().rev().chain(seq.audio_tracks.iter()).filter(|t| tg.contains(&t.id)).filter_map(|t| t.item_at(ph)).find(|i| is_nest(i))
})
.ok_or_else(|| EngineError::Other("select a nested sequence clip".into()))?;
let (nested, t) = (pick.item, pick.source_time_at(ph.clamp(pick.start, pick.end() - Tick(1))));
s.execute("sequence.open", json!({"item": nested.0}))?;
s.set_playhead(t);
Ok(json!({"sequence": nested.0, "time": s.playhead().0}))
}
// ------------------------------------------------------------------ selection
fn select_clip_at_playhead(s: &mut Session) -> Result<Value> {
let t = s.playhead();
let tg = s.targeting().targeted;
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let ids: Vec<ClipId> = seq.all_tracks().filter(|tr| tg.contains(&tr.id)).filter_map(|tr| tr.item_at(t).map(|i| i.id)).collect();
s.state.selection = with_links(s, &ids);
s.state.edit_points.clear();
s.state.caption_selection.clear();
Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>()}))
}
/// Select Next / Previous Clip: the clip after (before) the selection on each selected clip's
/// track; with nothing selected, the clips at the playhead on the targeted tracks.
fn select_step(s: &mut Session, next: bool) -> Result<Value> {
if s.state.selection.is_empty() {
return select_clip_at_playhead(s);
}
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
// one anchor per track: the last (first) selected item
let mut picks: Vec<ClipId> = Vec::new();
for tr in seq.all_tracks() {
let sel: Vec<usize> = tr.items.iter().enumerate().filter(|(_, i)| s.state.selection.contains(&i.id)).map(|(k, _)| k).collect();
let Some(&anchor) = (if next { sel.last() } else { sel.first() }) else { continue };
let k = if next { Some(anchor + 1) } else { anchor.checked_sub(1) };
if let Some(it) = k.and_then(|k| tr.items.get(k)) {
picks.push(it.id);
}
}
// linked partners already follow their own track's step; keep the old selection when at the end
if picks.is_empty() {
return Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>()}));
}
s.state.selection = with_links(s, &picks);
Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::<Vec<_>>()}))
}
// ------------------------------------------------------------------ trimming
/// Extend Previous / Next Edit To Playhead: on each targeted, unlocked track, move the nearest edit
/// point before (after) the playhead to the playhead. A cut between two clips rolls; an edge
/// next to a gap is trimmed (no ripple).
fn extend_edit(s: &mut Session, next: bool) -> Result<Value> {
let ph = s.playhead();
let tg = s.targeting().targeted;
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let mut ops: Vec<(Option<ClipId>, Option<ClipId>, Tick)> = Vec::new();
for tr in seq.all_tracks().filter(|t| tg.contains(&t.id) && !t.locked) {
let edges = tr.items.iter().flat_map(|i| [i.start, i.end()]);
let e = if next { edges.filter(|e| *e > ph).min() } else { edges.filter(|e| *e < ph).max() };
let Some(e) = e else { continue };
let left = tr.items.iter().find(|i| i.end() == e).map(|i| i.id);
let right = tr.items.iter().find(|i| i.start == e).map(|i| i.id);
ops.push((left, right, ph - e));
}
if ops.is_empty() {
return Err(EngineError::Other("no edit point on the targeted tracks".into()));
}
let label = if next { "Extend Next Edit To Playhead" } else { "Extend Previous Edit To Playhead" };
s.edit_sequence(label, |q, ctx, _| {
for (l, r, d) in &ops {
match (l, r) {
(Some(l), Some(r)) => {
edit::roll(q, *l, *r, *d, ctx)?;
}
(Some(l), None) => {
edit::trim(q, *l, edit::Edge::Out, edit::TrimMode::Regular, *d, ctx)?;
}
(None, Some(r)) => {
edit::trim(q, *r, edit::Edge::In, edit::TrimMode::Regular, *d, ctx)?;
}
(None, None) => {}
}
}
Ok(())
})?;
Ok(json!({"edits": ops.len()}))
}
/// Nudge the selection (with linked partners) by `frames` along the timeline (overwrite).
fn nudge_time(s: &mut Session, frames: i64) -> Result<Value> {
let d = s.sequence_rate().tick_of(frames);
let clips = with_links(s, &s.state.selection.clone());
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let items: Vec<(ClipId, TrackId, Tick)> = clips.iter().filter_map(|c| seq.find_item(*c).map(|(t, i)| (*c, t, i.start))).collect();
let min_start = items.iter().map(|m| m.2).min().unwrap_or_default();
// never past the sequence start
let d = d.max(-min_start);
if d == Tick::ZERO {
return Ok(json!({"delta": 0}));
}
let moves: Vec<(ClipId, TrackId, Tick)> = items.into_iter().map(|(c, t, st)| (c, t, st + d)).collect();
s.edit_sequence("Nudge", |q, ctx, _| Ok(edit::move_items(q, &moves, false, ctx)?))?;
Ok(json!({"delta": d.0}))
}
/// Nudge the selected clips one track up (`up`) or down, as the timeline shows them: video up is
/// V(n+1), audio up is A(n-1).
fn nudge_track(s: &mut Session, up: bool) -> Result<Value> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let mut moves = Vec::new();
let mut blocked = 0usize;
for c in &s.state.selection {
let Some((tid, it)) = seq.find_item(*c) else { continue };
let Some(tr) = seq.track(tid) else { continue };
let list = seq.tracks(tr.kind);
let idx = list.iter().position(|t| t.id == tid).unwrap_or(0) as i64;
let step = match (tr.kind, up) {
(TrackKind::Video, true) | (TrackKind::Audio, false) => 1,
_ => -1,
};
// a clip already on the outermost track stays put (a linked video+audio selection must not
// fail because its audio has nowhere to go)
let Some(dest) = usize::try_from(idx + step).ok().and_then(|i| list.get(i)) else {
blocked += 1;
continue;
};
moves.push((*c, dest.id, it.start));
}
if moves.is_empty() {
let why = if blocked > 0 { "no track to move to" } else { "no clips selected" };
return Err(EngineError::Other(why.into()));
}
s.edit_sequence("Nudge", |q, ctx, _| Ok(edit::move_items(q, &moves, false, ctx)?))?;
Ok(json!({"moved": moves.len()}))
}
fn slip_selection(s: &mut Session, frames: i64) -> Result<Value> {
let rate = s.sequence_rate();
let clips = with_links(s, &s.state.selection.clone());
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let linked = s.state.linked_selection;
// media delta per clip (source frames run at the clip's speed); linked partners slip as one group
let mut groups: Vec<(Option<u64>, Vec<(ClipId, Tick)>)> = Vec::new();
for c in &clips {
let Some((_, i)) = seq.find_item(*c) else { continue };
let d = Tick((rate.tick_of(frames).0 as f64 * i.speed.abs().max(1e-9)).round() as i64);
let link = i.link.filter(|_| linked);
match groups.iter_mut().find(|(l, _)| link.is_some() && *l == link) {
Some((_, g)) => g.push((*c, d)),
None => groups.push((link, vec![(*c, d)])),
}
}
s.edit_sequence("Slip", |q, ctx, _| {
for (_, g) in &groups {
slip_together(q, ctx, g)?;
}
Ok(())
})?;
Ok(Value::Null)
}
fn slide_selection(s: &mut Session, frames: i64) -> Result<Value> {
let d = s.sequence_rate().tick_of(frames);
let clips = with_links(s, &s.state.selection.clone());
// one common delta, clamped by the most restrictive member, keeps linked partners in sync
s.edit_sequence("Slide", |q, ctx, _| Ok(edit::slide_items(q, &clips, d, ctx)?))?;
Ok(Value::Null)
}
// ------------------------------------------------------------------ targeting
fn set_targeting(s: &mut Session, tg: crate::Targeting) -> Result<Value> {
let seq = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let out = json!({
"targeted": tg.targeted.iter().map(|t| t.0).collect::<Vec<_>>(),
"videoDest": tg.video_dest.map(|t| t.0),
"audioDest": tg.audio_dest.map(|t| t.0),
});
s.state.targeting.insert(seq, tg);
Ok(out)
}
fn track_ids(s: &Session, kind: TrackKind) -> Vec<TrackId> {
s.active_sequence().map(|q| q.tracks(kind).iter().map(|t| t.id).collect()).unwrap_or_default()
}
fn toggle_all_targets(s: &mut Session, kind: TrackKind) -> Result<Value> {
let ids = track_ids(s, kind);
let mut tg = s.targeting();
if ids.iter().all(|t| tg.targeted.contains(t)) {
tg.targeted.retain(|t| !ids.contains(t));
} else {
for t in ids {
if !tg.targeted.contains(&t) {
tg.targeted.push(t);
}
}
}
set_targeting(s, tg)
}
fn toggle_all_source(s: &mut Session, kind: TrackKind) -> Result<Value> {
let first = track_ids(s, kind).first().copied();
let mut tg = s.targeting();
let slot = if kind == TrackKind::Video { &mut tg.video_dest } else { &mut tg.audio_dest };
*slot = if slot.is_some() { None } else { first };
set_targeting(s, tg)
}
fn toggle_target(s: &mut Session, kind: TrackKind, n: usize) -> Result<Value> {
let t = *track_ids(s, kind)
.get(n - 1)
.ok_or_else(|| EngineError::Other(format!("there is no track {}{n}", if kind == TrackKind::Video { "V" } else { "A" })))?;
let mut tg = s.targeting();
if let Some(i) = tg.targeted.iter().position(|x| *x == t) {
tg.targeted.remove(i);
} else {
tg.targeted.push(t);
}
set_targeting(s, tg)
}
/// Move All Video / Audio Targets Up / Down: shift every targeted track of `kind` by one.
fn move_targets(s: &mut Session, kind: TrackKind, up: bool) -> Result<Value> {
let ids = track_ids(s, kind);
let mut tg = s.targeting();
let idx: Vec<usize> = ids.iter().enumerate().filter(|(_, t)| tg.targeted.contains(t)).map(|(i, _)| i).collect();
// visually up: video n+1, audio n-1
let step: i64 = if (kind == TrackKind::Video) == up { 1 } else { -1 };
if idx.iter().any(|i| *i as i64 + step < 0 || *i as i64 + step >= ids.len() as i64) {
return Err(EngineError::Other("the targets are already at the edge".into()));
}
tg.targeted.retain(|t| !ids.contains(t));
tg.targeted.extend(idx.iter().map(|i| ids[(*i as i64 + step) as usize]));
set_targeting(s, tg)
}
fn toggle_targeted_tracks(s: &mut Session, kind: TrackKind, what: &'static str) -> Result<Value> {
let tg = s.targeting().targeted;
let ids: Vec<TrackId> = track_ids(s, kind).into_iter().filter(|t| tg.contains(t)).collect();
if ids.is_empty() {
return Err(EngineError::Other("no targeted tracks".into()));
}
let label = match what {
"mute" => "Toggle Mute",
"solo" => "Toggle Solo",
_ => "Toggle Track Output",
};
s.edit_sequence(label, |q, _, _| {
// all on → all off, else all on
let get = |t: &filmcraft_project::Track| match what {
"mute" => t.muted,
"solo" => t.solo,
_ => !t.enabled,
};
let all = ids.iter().filter_map(|i| q.track(*i)).all(get);
for i in &ids {
if let Some(t) = q.track_mut(*i) {
match what {
"mute" => t.muted = !all,
"solo" => t.solo = !all,
_ => t.enabled = all,
}
}
}
Ok(())
})?;
Ok(Value::Null)
}
// ------------------------------------------------------------------ audio
const VOLUME_MIN_DB: f64 = -287.5;
const VOLUME_MAX_DB: f64 = 15.0;
/// Change the Volume level of the selected audio clips by `db` (keyframed levels shift as a whole).
pub fn adjust_clip_volume(s: &mut Session, db: f64) -> Result<Value> {
let clips = with_links(s, &s.state.selection.clone());
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let audio: Vec<ClipId> =
clips.into_iter().filter(|c| seq.find_item(*c).and_then(|(t, _)| seq.track(t)).is_some_and(|t| t.kind == TrackKind::Audio)).collect();
if audio.is_empty() {
return Err(EngineError::Other("select audio clips".into()));
}
let shift = |v: &ParamValue| match v {
ParamValue::Float(x) => ParamValue::Float((x + db).clamp(VOLUME_MIN_DB, VOLUME_MAX_DB)),
o => o.clone(),
};
let label = if db > 0.0 { "Increase Clip Volume" } else { "Decrease Clip Volume" };
s.edit_sequence(label, |q, _, _| {
for c in &audio {
let Some((_, it)) = q.find_item_mut(*c) else { continue };
let Some(p) = it.effect_mut("volume").and_then(|e| e.param_mut("level")) else { continue };
p.value = shift(&p.value);
for k in &mut p.keyframes {
k.value = shift(&k.value);
}
}
Ok(())
})?;
let levels: Vec<Value> = s
.active_sequence()
.map(|q| {
audio
.iter()
.filter_map(|c| q.find_item(*c))
.filter_map(|(_, it)| it.effect("volume").and_then(|e| e.params.get("level")).and_then(|p| p.value.as_f64()))
.map(|v| json!(v))
.collect()
})
.unwrap_or_default();
Ok(json!({"levels": levels}))
}
// ------------------------------------------------------------------ graphics
fn text_layer_targets(s: &Session) -> Result<(ClipId, Vec<usize>)> {
let clip = crate::graphics::target_clip(s, &Value::Null).ok_or_else(|| EngineError::Other("select a graphic clip".into()))?;
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (_, it) = seq.find_item(clip).ok_or_else(|| EngineError::Other("no such clip".into()))?;
let idx = filmcraft_project::graphic::layer_indices(&it.effects);
if idx.is_empty() {
return Err(EngineError::Other("the graphic has no layers".into()));
}
let chosen: Vec<usize> = if s.state.graphic_layers.is_empty() {
vec![*idx.last().unwrap_or(&0)]
} else {
s.state.graphic_layers.iter().filter_map(|l| idx.get(*l).copied()).collect()
};
Ok((clip, chosen))
}
/// Apply `f` to parameter `param` of the targeted graphic layers at the playhead (keyframe-aware).
fn edit_layers(s: &mut Session, label: &str, param: &'static str, text_only: bool, f: impl Fn(&ParamValue) -> Option<ParamValue>) -> Result<Value> {
let (clip, layers) = text_layer_targets(s)?;
let tl = s.playhead();
let n = s.edit_sequence(label, |q, _, _| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1)));
let mut n = 0;
for ei in &layers {
let Some(e) = it.effects.get_mut(*ei) else { continue };
if text_only && !e.params.contains_key("text") {
continue;
}
let Some(p) = e.params.get_mut(param) else { continue };
if let Some(v) = f(&p.value_at(mt)) {
p.set_at(mt, v);
n += 1;
}
}
if n == 0 {
return Err(EngineError::Other(if text_only { "select a text layer".into() } else { "nothing to change".into() }));
}
Ok(n)
})?;
Ok(json!({"layers": n}))
}
fn font_size(s: &mut Session, d: f64) -> Result<Value> {
edit_layers(s, "Font Size", "size", true, |v| match v {
ParamValue::Float(x) => Some(ParamValue::Float((x + d).clamp(1.0, 2000.0))),
_ => None,
})
}
fn leading(s: &mut Session, d: f64) -> Result<Value> {
edit_layers(s, "Leading", "leading", true, |v| match v {
ParamValue::Float(x) => Some(ParamValue::Float((x + d).clamp(-5000.0, 5000.0))),
_ => None,
})
}
fn align_text(s: &mut Session, choice: u32) -> Result<Value> {
edit_layers(s, "Text Alignment", "align", true, move |_| Some(ParamValue::Choice(choice)))
}
/// Nudge Selected Object: graphic layers when a graphic is targeted, else the Motion position of
/// the selected video clips.
fn nudge_object(s: &mut Session, dx: f64, dy: f64) -> Result<Value> {
let shift = move |v: &ParamValue| match v {
ParamValue::Vec2(p) => Some(ParamValue::Vec2(Vec2::new(p.x + dx, p.y + dy))),
_ => None,
};
if crate::graphics::target_clip(s, &Value::Null).is_some()
&& s.state.selection.iter().all(|c| s.active_sequence().is_some_and(|q| crate::graphics::is_graphic(s, q, *c)))
{
return edit_layers(s, "Nudge", "position", false, shift);
}
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let clips: Vec<ClipId> =
s.state.selection.iter().copied().filter(|c| seq.find_item(*c).and_then(|(t, _)| seq.track(t)).is_some_and(|t| t.kind == TrackKind::Video)).collect();
if clips.is_empty() {
return Err(EngineError::Other("select a video clip or a graphic".into()));
}
let tl = s.playhead();
s.edit_sequence("Nudge", |q, _, _| {
for c in &clips {
let Some((_, it)) = q.find_item_mut(*c) else { continue };
let mt = it.source_time_at(tl.clamp(it.start, it.end() - Tick(1)));
if let Some(p) = it.effect_mut("motion").and_then(|e| e.param_mut("position"))
&& let Some(v) = shift(&p.value_at(mt))
{
p.set_at(mt, v);
}
}
Ok(())
})?;
Ok(json!({"clips": clips.len()}))
}
fn has_graphic_or_selection(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if crate::graphics::target_clip(s, &Value::Null).is_some() || !s.state.selection.is_empty() { Ok(()) } else { Err("select a clip or a graphic".into()) }
}
fn has_graphic(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
crate::graphics::target_clip(s, &Value::Null).map(|_| ()).ok_or_else(|| "select a graphic clip".into())
}
// ------------------------------------------------------------------ export frame, poster frame
fn sanitize(name: &str) -> String {
name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect()
}
/// A monitor frame captured when Export Frame opens. Export uses original media, never proxies.
#[derive(Clone, Debug, PartialEq)]
pub struct FrameExportTarget {
pub source: bool,
pub item: filmcraft_project::ItemId,
pub time: Tick,
pub name: String,
pub width: u32,
pub height: u32,
}
/// Resolve the requested monitor, item and exact frame without changing either playhead.
pub fn frame_export_target(s: &Session, p: &Value) -> Result<FrameExportTarget> {
const CMD: &str = "file.exportFrame";
let source = match p.get("target") {
None => false,
Some(Value::String(t)) if t == "program" => false,
Some(Value::String(t)) if t == "source" => true,
_ => return Err(bad(CMD, "target must be source or program")),
};
let key = if source { "item" } else { "sequence" };
let item = match p.get(key) {
Some(v) => filmcraft_project::ItemId(v.as_u64().ok_or_else(|| bad(CMD, format!("{key} must be an item id")))?),
None => if source { s.state.source_item } else { s.state.active_sequence }
.ok_or_else(|| bad(CMD, "open a video clip or sequence in the requested monitor"))?,
};
let pi = s.project.item(item).ok_or_else(|| bad(CMD, "the requested item is unavailable"))?;
let (width, height) = s.project.source_size(item).ok_or_else(|| bad(CMD, "the requested Source clip has no video frame"))?;
filmcraft_project::validate_frame_size(width, height).map_err(|e| bad(CMD, e.to_string()))?;
let (rate, current, bounds) = if source {
let v = crate::clip_ops::source_view(s, item).ok_or_else(|| bad(CMD, "Source clip is unavailable"))?;
if v.start.0 < 0 || v.end <= v.start {
return Err(bad(CMD, "Source clip has invalid bounds"));
}
(v.rate, s.state.source_playhead, Some((v.start, v.end)))
} else {
let q = s.project.sequence(item).ok_or(EngineError::NoSequence)?;
(q.settings.frame_rate, s.state.playheads.get(&item).copied().unwrap_or_default(), None)
};
let requested = match p.get("time") {
None => current,
Some(v) => Tick(v.as_i64().ok_or_else(|| bad(CMD, "time must be integer ticks"))?),
};
if rate.num <= 0 || rate.den <= 0 || requested.0 < 0 {
return Err(bad(CMD, "frame rate must be positive and time non-negative"));
}
let invalid = || bad(CMD, "frame time exceeds the supported range");
let unit = i128::from(filmcraft_time::TICKS_PER_SECOND).checked_mul(i128::from(rate.den)).ok_or_else(invalid)?;
let fd = unit / i128::from(rate.num);
if fd <= 0 || fd > i128::from(i64::MAX) {
return Err(invalid());
}
let t = bounds.map(|(a, b)| requested.clamp(a, Tick(b.0.saturating_sub(1)).max(a))).unwrap_or(requested);
let frame = i128::from(t.0).checked_mul(i128::from(rate.num)).ok_or_else(invalid)? / unit;
let snapped = frame.checked_mul(unit).ok_or_else(invalid)? / i128::from(rate.num);
let mut time = Tick(i64::try_from(snapped).map_err(|_| invalid())?);
if let Some((a, _)) = bounds {
time = time.max(a);
}
Ok(FrameExportTarget { source, item, time, name: pi.name.clone(), width, height })
}
/// Export the captured Source or Program frame as a full-resolution SDR still.
fn export_frame(s: &mut Session, p: &Value) -> Result<Value> {
let target = frame_export_target(s, p)?;
let fmt = match p.get("format") {
None => "png".to_string(),
Some(v) => v.as_str().ok_or_else(|| bad("file.exportFrame", "format must be a string"))?.to_ascii_lowercase(),
};
let format = filmcraft_export::still::StillFormat::parse(&fmt).map_err(|e| bad("file.exportFrame", e.to_string()))?;
let depth = match p.get("depth") {
None => 8,
Some(v) => u8::try_from(v.as_u64().ok_or_else(|| bad("file.exportFrame", "depth must be 8 or 16"))?)
.map_err(|_| bad("file.exportFrame", "depth must be 8 or 16"))?,
};
if !matches!(depth, 8 | 16) || (depth == 16 && !format.supports_16()) {
return Err(bad("file.exportFrame", "16-bit output requires PNG or TIFF; JPEG and BMP require 8-bit output"));
}
let import = match p.get("import") {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad("file.exportFrame", "import must be true or false"))?,
};
let ext = format.extension();
let path = match p.get("path") {
Some(v) => {
let path = v.as_str().filter(|v| !v.trim().is_empty()).ok_or_else(|| bad("file.exportFrame", "path must be a non-empty string"))?;
crate::export_tools::expand_home(path)
}
None => {
let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).unwrap_or_else(|| std::path::Path::new(""));
let base = sanitize(&target.name);
let candidate =
(1..=9999).map(|n| dir.join(format!("{base}.Still{n:03}.{ext}")).to_string_lossy().into_owned()).find(|p| s.services.file_size(p).is_err());
candidate.ok_or_else(|| bad("file.exportFrame", "choose a path: all default still names already exist"))?
}
};
let provider = s.media.full_res_provider(s.project.clone(), s.services.clone());
// On the web a frame can still be decoding (WebCodecs) or its bytes loading (Blob reads): the
// render then leaves that clip out, so saving it would pass a blank or partial picture off as
// the requested frame (#228). Report it instead and write nothing; a later call succeeds.
let _ = filmcraft_media::pending::take();
let img = if target.source {
filmcraft_render::render_item(&s.project, target.item, target.time, 1.0, &provider)
} else {
let opts = filmcraft_render::RenderOptions { scale: 1.0, captions: true, ..Default::default() };
Some(filmcraft_render::render_sequence(&s.project, target.item, target.time, opts, &provider))
};
if filmcraft_media::pending::take() {
return Err(EngineError::Other("the frame is still loading; export it again in a moment".into()));
}
let img = img.ok_or_else(|| bad("file.exportFrame", "cannot decode the Source video frame"))?;
let (w, h) = (
u32::try_from(img.w).map_err(|_| bad("file.exportFrame", "image width exceeds limits"))?,
u32::try_from(img.h).map_err(|_| bad("file.exportFrame", "image height exceeds limits"))?,
);
let bytes = filmcraft_export::still::encode(&img, format, depth).map_err(|e| EngineError::Other(e.to_string()))?;
s.services.write_file(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut out =
json!({"path": path, "width": w, "height": h, "depth": depth, "target": if target.source { "source" } else { "program" }, "time": target.time.0});
if import {
out["import"] = s.execute("file.import", json!({"paths": [path]}))?;
}
s.toast(format!("Exported frame to {path}"));
Ok(out)
}
/// The item Set / Clear Poster Frame acts on, and the frame: `item` + time, else the Source
/// monitor clip at its playhead, else the first Project panel selection (its current poster).
fn poster_target(s: &Session, p: &Value) -> Option<(filmcraft_project::ItemId, Tick)> {
if let Some(i) = item_p(p, "item") {
return Some((i, time_p(s, p, "").unwrap_or_default()));
}
let sel = s.state.project_selection.first().copied();
match (s.state.source_item, sel) {
(Some(src), None) => Some((src, s.state.source_playhead)),
(Some(src), Some(sel)) if src == sel => Some((src, s.state.source_playhead)),
(_, Some(sel)) => Some((sel, time_p(s, p, "").unwrap_or_default())),
(None, None) => None,
}
}
fn set_poster(s: &mut Session, p: &Value, clear: bool) -> Result<Value> {
let (item, t) = poster_target(s, p).ok_or_else(|| EngineError::Other("open a clip in the Source monitor or select one in the Project panel".into()))?;
let it = s.project.item(item).ok_or_else(|| bad("clip.setPosterFrame", "no such item"))?;
if matches!(it.kind, ItemKind::AdjustmentLayer { .. }) {
return Err(EngineError::Other("adjustment layers have no poster frame".into()));
}
let snapped = it.frame_rate().snap(t);
s.edit(if clear { "Clear Poster Frame" } else { "Set Poster Frame" }, |pr, _| {
let it = pr.item_mut(item).ok_or_else(|| bad("clip.setPosterFrame", "no such item"))?;
if clear {
it.metadata.remove(POSTER_FRAME_KEY);
} else {
it.metadata.insert(POSTER_FRAME_KEY.into(), snapped.0.to_string());
}
Ok(())
})?;
Ok(json!({"item": item.0, "posterFrame": (!clear).then_some(snapped.0)}))
}
fn has_poster_target(s: &Session) -> std::result::Result<(), String> {
if s.state.source_item.is_some() || !s.state.project_selection.is_empty() {
Ok(())
} else {
Err("open a clip in the Source monitor or select one in the Project panel".into())
}
}
// ------------------------------------------------------------------ registry
pub fn commands() -> Vec<CommandSpec> {
use TrackKind::{Audio, Video};
let mut v = vec![
// ---- navigation
spec("playhead.nextEditAnyTrack", "Go to Next Edit Point on Any Track", Some("Shift+Down"), "{}", has_seq, |s, _| go_to_edit(s, true, true)),
spec("playhead.prevEditAnyTrack", "Go to Previous Edit Point on Any Track", Some("Shift+Up"), "{}", has_seq, |s, _| go_to_edit(s, false, true)),
spec("playhead.selectedClipStart", "Go to Selected Clip Start", Some("Shift+Home"), "{}", has_selection, |s, _| go_to_selected(s, false)),
spec("playhead.selectedClipEnd", "Go to Selected Clip End", Some("Shift+End"), "{}", has_selection, |s, _| go_to_selected(s, true)),
spec("sequence.revealNested", "Reveal Nested Sequence", Some("Cmd+Alt+F"), "{}", has_seq, |s, _| reveal_nested(s)),
// ---- selection
spec("timeline.selectClipAtPlayhead", "Select Clip at Playhead", Some("D"), "{}", has_seq, |s, _| select_clip_at_playhead(s)),
spec("timeline.selectNextClip", "Select Next Clip", Some("Cmd+Down"), "{}", has_seq, |s, _| select_step(s, true)),
spec("timeline.selectPrevClip", "Select Previous Clip", Some("Cmd+Up"), "{}", has_seq, |s, _| select_step(s, false)),
// ---- trimming
spec("trim.extendPreviousEdit", "Extend Previous Edit To Playhead", Some("Shift+Q"), "{}", has_seq, |s, _| extend_edit(s, false)),
spec("trim.extendNextEdit", "Extend Next Edit To Playhead", Some("Shift+W"), "{}", has_seq, |s, _| extend_edit(s, true)),
spec("timeline.nudgeLeft", "Nudge Clip Selection Left One Frame", None, "{}", has_selection, |s, _| nudge_time(s, -1)),
spec("timeline.nudgeRight", "Nudge Clip Selection Right One Frame", None, "{}", has_selection, |s, _| nudge_time(s, 1)),
spec("timeline.nudgeLeft5", "Nudge Clip Selection Left Five Frames", None, "{}", has_selection, |s, _| nudge_time(s, -5)),
spec("timeline.nudgeRight5", "Nudge Clip Selection Right Five Frames", None, "{}", has_selection, |s, _| nudge_time(s, 5)),
spec("timeline.nudgeUp", "Nudge Clip Selection Up", None, "{}", has_selection, |s, _| nudge_track(s, true)),
spec("timeline.nudgeDown", "Nudge Clip Selection Down", None, "{}", has_selection, |s, _| nudge_track(s, false)),
spec("timeline.slipLeft", "Slip Clip Selection Left One Frame", None, "{}", has_selection, |s, _| slip_selection(s, 1)),
spec("timeline.slipRight", "Slip Clip Selection Right One Frame", None, "{}", has_selection, |s, _| slip_selection(s, -1)),
spec("timeline.slipLeft5", "Slip Clip Selection Left Five Frames", None, "{}", has_selection, |s, _| slip_selection(s, 5)),
spec("timeline.slipRight5", "Slip Clip Selection Right Five Frames", None, "{}", has_selection, |s, _| slip_selection(s, -5)),
spec("timeline.slideLeft", "Slide Clip Selection Left One Frame", None, "{}", has_selection, |s, _| slide_selection(s, -1)),
spec("timeline.slideRight", "Slide Clip Selection Right One Frame", None, "{}", has_selection, |s, _| slide_selection(s, 1)),
spec("timeline.slideLeft5", "Slide Clip Selection Left Five Frames", None, "{}", has_selection, |s, _| slide_selection(s, -5)),
spec("timeline.slideRight5", "Slide Clip Selection Right Five Frames", None, "{}", has_selection, |s, _| slide_selection(s, 5)),
// ---- targeting
spec("timeline.toggleAllVideoTargets", "Toggle All Video Targets", Some("Cmd+0"), "{}", has_seq, |s, _| toggle_all_targets(s, Video)),
spec("timeline.toggleAllAudioTargets", "Toggle All Audio Targets", Some("Cmd+9"), "{}", has_seq, |s, _| toggle_all_targets(s, Audio)),
spec("timeline.toggleAllSourceVideo", "Toggle All Source Video", Some("Cmd+Alt+0"), "{}", has_seq, |s, _| toggle_all_source(s, Video)),
spec("timeline.toggleAllSourceAudio", "Toggle All Source Audio", Some("Cmd+Alt+9"), "{}", has_seq, |s, _| toggle_all_source(s, Audio)),
spec("timeline.moveVideoTargetsUp", "Move All Video Targets Up", None, "{}", has_seq, |s, _| move_targets(s, Video, true)),
spec("timeline.moveVideoTargetsDown", "Move All Video Targets Down", None, "{}", has_seq, |s, _| move_targets(s, Video, false)),
spec("timeline.moveAudioTargetsUp", "Move All Audio Targets Up", None, "{}", has_seq, |s, _| move_targets(s, Audio, true)),
spec("timeline.moveAudioTargetsDown", "Move All Audio Targets Down", None, "{}", has_seq, |s, _| move_targets(s, Audio, false)),
spec("timeline.toggleMuteTargetedAudio", "Toggle Mute for All Targeted Audio Tracks", None, "{}", has_seq, |s, _| {
toggle_targeted_tracks(s, Audio, "mute")
}),
spec("timeline.toggleSoloTargetedAudio", "Toggle Solo for All Targeted Audio Tracks", None, "{}", has_seq, |s, _| {
toggle_targeted_tracks(s, Audio, "solo")
}),
spec("timeline.toggleOutputTargetedVideo", "Toggle Track Output for All Targeted Video Tracks", None, "{}", has_seq, |s, _| {
toggle_targeted_tracks(s, Video, "output")
}),
// ---- audio
spec("clip.volumeUp", "Increase Clip Volume", Some("]"), "{}", has_selection, |s, _| adjust_clip_volume(s, 1.0)),
spec("clip.volumeDown", "Decrease Clip Volume", Some("["), "{}", has_selection, |s, _| adjust_clip_volume(s, -1.0)),
spec("clip.volumeUpMany", "Increase Clip Volume Many", Some("Shift+]"), "{}", has_selection, |s, _| {
let d = s.prefs.audio.large_volume_adjustment;
adjust_clip_volume(s, d)
}),
spec("clip.volumeDownMany", "Decrease Clip Volume Many", Some("Shift+["), "{}", has_selection, |s, _| {
let d = s.prefs.audio.large_volume_adjustment;
adjust_clip_volume(s, -d)
}),
spec("clip.nudgeVolumeUp1", "Nudge Volume +1dB", None, "{}", has_selection, |s, _| adjust_clip_volume(s, 1.0)),
spec("clip.nudgeVolumeUp3", "Nudge Volume +3dB", None, "{}", has_selection, |s, _| adjust_clip_volume(s, 3.0)),
spec("clip.nudgeVolumeDown1", "Nudge Volume -1dB", None, "{}", has_selection, |s, _| adjust_clip_volume(s, -1.0)),
spec("clip.nudgeVolumeDown3", "Nudge Volume -3dB", None, "{}", has_selection, |s, _| adjust_clip_volume(s, -3.0)),
spec("audio.toggleScrubbing", "Toggle Audio During Scrubbing", Some("Shift+S"), "{}", always, |s, _| {
let on = !s.prefs.audio.scrub_audio;
s.execute("prefs.set", json!({"key": "audio.scrubAudio", "value": on}))?;
s.toast(if on { "Audio during scrubbing on" } else { "Audio during scrubbing off" });
Ok(json!({"scrubAudio": on}))
}),
// ---- graphics and titles
spec("graphics.fontSizeUp", "Increase Font Size by One Unit", Some("Cmd+Alt+Right"), "{}", has_graphic, |s, _| font_size(s, 1.0)),
spec("graphics.fontSizeDown", "Decrease Font Size by One Unit", Some("Cmd+Alt+Left"), "{}", has_graphic, |s, _| font_size(s, -1.0)),
spec("graphics.fontSizeUp5", "Increase Font Size by Five Units", Some("Cmd+Alt+Shift+Right"), "{}", has_graphic, |s, _| font_size(s, 5.0)),
spec("graphics.fontSizeDown5", "Decrease Font Size by Five Units", Some("Cmd+Alt+Shift+Left"), "{}", has_graphic, |s, _| font_size(s, -5.0)),
spec("graphics.leadingUp", "Increase Leading by One Unit", Some("Alt+Up"), "{}", has_graphic, |s, _| leading(s, 1.0)),
spec("graphics.leadingDown", "Decrease Leading by One Unit", Some("Alt+Down"), "{}", has_graphic, |s, _| leading(s, -1.0)),
spec("graphics.leadingUp5", "Increase Leading by Five Units", Some("Alt+Shift+Up"), "{}", has_graphic, |s, _| leading(s, 5.0)),
spec("graphics.leadingDown5", "Decrease Leading by Five Units", Some("Alt+Shift+Down"), "{}", has_graphic, |s, _| leading(s, -5.0)),
spec("graphics.alignTextLeft", "Left align text", Some("Cmd+Shift+L"), "{}", has_graphic, |s, _| align_text(s, 0)),
spec("graphics.alignTextCenter", "Center align text", Some("Cmd+Shift+C"), "{}", has_graphic, |s, _| align_text(s, 1)),
spec("graphics.alignTextRight", "Right align text", Some("Cmd+Shift+R"), "{}", has_graphic, |s, _| align_text(s, 2)),
spec("graphics.nudgeLeft", "Nudge Selected Object to left by one", None, "{}", has_graphic_or_selection, |s, _| nudge_object(s, -1.0, 0.0)),
spec("graphics.nudgeRight", "Nudge Selected Object to right by one", None, "{}", has_graphic_or_selection, |s, _| nudge_object(s, 1.0, 0.0)),
spec("graphics.nudgeUp", "Nudge Selected Object up by one", None, "{}", has_graphic_or_selection, |s, _| nudge_object(s, 0.0, -1.0)),
spec("graphics.nudgeDown", "Nudge Selected Object down by one", None, "{}", has_graphic_or_selection, |s, _| nudge_object(s, 0.0, 1.0)),
spec("graphics.nudgeLeft5", "Nudge Selected Object to left by five", None, "{}", has_graphic_or_selection, |s, _| nudge_object(s, -5.0, 0.0)),
spec("graphics.nudgeRight5", "Nudge Selected Object to right by five", None, "{}", has_graphic_or_selection, |s, _| nudge_object(s, 5.0, 0.0)),
spec("graphics.nudgeUp5", "Nudge Selected Object up by five", None, "{}", has_graphic_or_selection, |s, _| nudge_object(s, 0.0, -5.0)),
spec("graphics.nudgeDown5", "Nudge Selected Object down by five", None, "{}", has_graphic_or_selection, |s, _| nudge_object(s, 0.0, 5.0)),
// ---- export frame, poster frame
// Shift+E is Clip > Enable in FilmCraft Default; the Premiere preset moves it here
spec(
"file.exportFrame",
"Export Frame",
None,
r#"{"path":str?,"format":"png|jpeg|tiff|bmp"?,"depth":8|16?,"import":bool?,"target":"source|program"?,"item":id?,"sequence":id?,"time":ticks?}"#,
always,
export_frame,
),
spec("clip.setPosterFrame", "Set Poster Frame", Some("Cmd+P"), r#"{"item":id?,"time":ticks?}"#, has_poster_target, |s, p| set_poster(s, p, false)),
spec("clip.clearPosterFrame", "Clear Poster Frame", Some("Alt+P"), r#"{"item":id?}"#, has_poster_target, |s, p| set_poster(s, p, true)),
];
v.extend(target_toggles());
v
}
/// Toggle Target Video 1–8 / Audio 1–8.
fn target_toggles() -> Vec<CommandSpec> {
macro_rules! tt {
($id:literal, $label:literal, $kind:expr, $n:literal) => {
spec($id, $label, None, "{}", has_seq, |s, _| toggle_target(s, $kind, $n))
};
}
use TrackKind::{Audio, Video};
vec![
tt!("timeline.toggleTargetV1", "Toggle Target Video 1", Video, 1),
tt!("timeline.toggleTargetV2", "Toggle Target Video 2", Video, 2),
tt!("timeline.toggleTargetV3", "Toggle Target Video 3", Video, 3),
tt!("timeline.toggleTargetV4", "Toggle Target Video 4", Video, 4),
tt!("timeline.toggleTargetV5", "Toggle Target Video 5", Video, 5),
tt!("timeline.toggleTargetV6", "Toggle Target Video 6", Video, 6),
tt!("timeline.toggleTargetV7", "Toggle Target Video 7", Video, 7),
tt!("timeline.toggleTargetV8", "Toggle Target Video 8", Video, 8),
tt!("timeline.toggleTargetA1", "Toggle Target Audio 1", Audio, 1),
tt!("timeline.toggleTargetA2", "Toggle Target Audio 2", Audio, 2),
tt!("timeline.toggleTargetA3", "Toggle Target Audio 3", Audio, 3),
tt!("timeline.toggleTargetA4", "Toggle Target Audio 4", Audio, 4),
tt!("timeline.toggleTargetA5", "Toggle Target Audio 5", Audio, 5),
tt!("timeline.toggleTargetA6", "Toggle Target Audio 6", Audio, 6),
tt!("timeline.toggleTargetA7", "Toggle Target Audio 7", Audio, 7),
tt!("timeline.toggleTargetA8", "Toggle Target Audio 8", Audio, 8),
]
}