diff --git a/README.md b/README.md index e916b51..aac7209 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,22 @@ make install-app # Splice.app in /Applications - **Place:** drag a picture on the stage, or its corners to resize. It snaps to the centre lines. There's a nine-point grid, plus size, opacity and position sliders. + - **Keyframes:** the ◆ beside size, opacity and position keys that value at + the playhead; set another somewhere else and it glides between them + (linear, smooth, ease in, ease out, or hold). Dragging a keyframed picture + on the stage keys it where the playhead is. ⌥K keys place and size at once. + - **Transition:** the way into a clip from the one before it: dissolve, dip + to black or white, wipe or push four ways, or zoom, centred on the cut, + with a length. A tab on the timeline marks each one. ⇧⌘T adds a dissolve + at the nearest cut. - **Animate:** in and out with fade, slide (four ways), zoom or pop, with a length you choose. A slow zoom can run across the whole clip. + - **Colour:** a look (warm, cool, vivid, muted, faded, film, teal & orange, + black & white, noir) with exposure, contrast, saturation, warmth and tint + over it. *Use on every clip* grades the whole movie the same. + - **Speed:** 0.25× to 4× (0.1× to 16× from the assistant), or backwards. + A faster clip gets shorter and what follows on its track moves up; sound + keeps its pitch. *Freeze frame* (⌥F) holds the frame at the playhead. - **Green screen:** key out green or blue, with strength, softness and fringe clean-up. - **Crop:** cut any edge away. The picture keeps its scale, and what @@ -124,8 +138,8 @@ make install-app # Splice.app in /Applications The viewer and export share one renderer, so what you see is what you get. Titles are drawn once in Rust (cosmic-text) and that same picture is overlaid on export. Keys and masks are ffmpeg filters that the viewer's decoder runs -too. Animation is one expression, evaluated for the viewer and printed into -ffmpeg for export. +too, and so is colour. Animation, keyframes and transitions are one set of +expressions, evaluated for the viewer and printed into ffmpeg for export. ## Scores and scenes @@ -298,7 +312,10 @@ with `make install-skill`) teaches Claude Code to use it. | `splice ctl add / move / trim / split / delete / duplicate / volume` | timeline edits, each one undo step | | `splice ctl track`, `canvas`, `undo`, `redo` | layers, canvas shape, history | | `splice ctl text`, `color`, `style`, `fonts` | titles in system fonts, colour cards and tints | -| `splice ctl look` | position, size, opacity, animation, green screen, mask | +| `splice ctl look` | position, size, opacity, animation, green screen, mask, crop, colour | +| `splice ctl transition` | dissolves, dips, wipes, pushes and zooms at cuts | +| `splice ctl speed`, `freeze` | faster, slower, backwards; hold a frame | +| `splice ctl keyframe` | animate position, size and opacity between moments | | `splice ctl score new / read / write / instruments` | compose music: a score as JSON | | `splice ctl scene new / read / write / help` | animations and diagrams, described as JSON | | `splice ctl scene sheet / frame / patch / add / remove / check` | see a scene's motion as a contact sheet; edit layers by name; validate first | diff --git a/crates/splice-app/src/actions.rs b/crates/splice-app/src/actions.rs index f509331..d02b295 100644 --- a/crates/splice-app/src/actions.rs +++ b/crates/splice-app/src/actions.rs @@ -55,6 +55,10 @@ actions!( NewScene, MakeSymbol, BreakApart, + AddTransition, + FreezeFrame, + ReverseClip, + AddKeyframe, ShowSymbols, ShowMedia, ShowTextStyles, @@ -106,6 +110,9 @@ pub fn bind(cx: &mut App) { KeyBinding::new("cmd-shift-r", RecordScreen, G), KeyBinding::new("cmd-alt-k", MakeSymbol, G), KeyBinding::new("cmd-alt-b", BreakApart, G), + KeyBinding::new("cmd-shift-t", AddTransition, G), + KeyBinding::new("alt-f", FreezeFrame, W), + KeyBinding::new("alt-k", AddKeyframe, W), KeyBinding::new("b", AddBeat, W), KeyBinding::new("c", AddCue, W), KeyBinding::new("cmd-v", PasteReference, W), @@ -191,6 +198,11 @@ pub fn menus() -> Vec { MenuItem::action("Ripple Delete", RippleDelete), MenuItem::action("Duplicate", Duplicate), MenuItem::separator(), + MenuItem::action("Add Transition", AddTransition), + MenuItem::action("Freeze Frame", FreezeFrame), + MenuItem::action("Play Backwards", ReverseClip), + MenuItem::action("Add Keyframe", AddKeyframe), + MenuItem::separator(), MenuItem::action("Make Symbol…", MakeSymbol), MenuItem::action("Break Symbol Apart", BreakApart), MenuItem::separator(), @@ -289,6 +301,18 @@ pub fn commands() -> Vec<(&'static str, &'static str, Box)> { Box::new(MakeSymbol), ), ("Break a symbol apart into its clips", "⌥⌘B", Box::new(BreakApart)), + ( + "Add a transition (dissolve) at the cut nearest the playhead", + "⇧⌘T", + Box::new(AddTransition), + ), + ( + "Freeze frame: hold the frame at the playhead", + "⌥F", + Box::new(FreezeFrame), + ), + ("Play the selection backwards", "", Box::new(ReverseClip)), + ("Keyframe place and size at the playhead", "⌥K", Box::new(AddKeyframe)), ("Show symbols", "", Box::new(ShowSymbols)), ("Story view / edit view", "⌘3", Box::new(ToggleStory)), ("Add a story beat", "B", Box::new(AddBeat)), diff --git a/crates/splice-app/src/audio.rs b/crates/splice-app/src/audio.rs index cdcc575..36a91ff 100644 --- a/crates/splice-app/src/audio.rs +++ b/crates/splice-app/src/audio.rs @@ -13,8 +13,27 @@ pub struct Voice { pub length: u64, /// Frame in the PCM the clip begins at. pub source_in: u64, + /// PCM frames per frame of playback: 1 normally; a clip whose retimed + /// sound is not ready yet plays its own faster or slower (or, negative, + /// backwards), pitch and all. + pub step: f64, pub volume: f32, pub pcm: Arc, + /// Rising and falling across transitions: timeline frames, start and end. + pub fade_in: Option<(u64, u64)>, + pub fade_out: Option<(u64, u64)>, +} + +impl Voice { + /// Quarter-sine fades, as export makes them (`afade` with `qsin`). + fn gain(&self, t: u64) -> f32 { + let ramp = |(a, z): (u64, u64), rising: bool| { + let p = (t.saturating_sub(a) as f64 / (z.saturating_sub(a)).max(1) as f64).clamp(0.0, 1.0); + let p = if rising { p } else { 1.0 - p }; + (p * std::f64::consts::FRAC_PI_2).sin() as f32 + }; + self.fade_in.map_or(1.0, |f| ramp(f, true)) * self.fade_out.map_or(1.0, |f| ramp(f, false)) + } } #[derive(Default)] @@ -162,10 +181,16 @@ fn fill(mix: &Mutex, out: &mut [S], channels: usize, con if t < voice.start || t >= voice.start + voice.length { continue; } - let index = (voice.source_in + (t - voice.start)) as usize * 2; + let along = (t - voice.start) as f64 * voice.step; + let frame = voice.source_in as f64 + along; + if frame < 0.0 { + continue; + } + let index = frame as usize * 2; if index + 1 < voice.pcm.samples.len() { - left += voice.pcm.samples[index] as f32 / 32768.0 * voice.volume; - right += voice.pcm.samples[index + 1] as f32 / 32768.0 * voice.volume; + let gain = voice.volume * voice.gain(t); + left += voice.pcm.samples[index] as f32 / 32768.0 * gain; + right += voice.pcm.samples[index + 1] as f32 / 32768.0 * gain; } } let (left, right) = mix.limiter.apply(left, right); diff --git a/crates/splice-app/src/cli.rs b/crates/splice-app/src/cli.rs index 74e82d1..6e62abf 100644 --- a/crates/splice-app/src/cli.rs +++ b/crates/splice-app/src/cli.rs @@ -150,6 +150,11 @@ fn export_project(path: &Path, out: &Path, quality: Quality) -> anyhow::Result<( canvas: (project.settings.width, project.settings.height), }; let plan = export::plan(&project, &resolver, out, quality).map_err(|e| anyhow::anyhow!(e))?; + let prepared = ffmpeg::prepare(&plan, || true); + if prepared.is_err() { + ffmpeg::tidy(&plan); + } + prepared?; let mut child = ffmpeg::export_command()? .args(&plan.args) .stdout(Stdio::piped()) @@ -163,7 +168,9 @@ fn export_project(path: &Path, out: &Path, quality: Quality) -> anyhow::Result<( let _ = std::io::stdout().flush(); } } - let status = child.wait()?; + let status = child.wait(); + ffmpeg::tidy(&plan); + let status = status?; println!(); if !status.success() { anyhow::bail!("ffmpeg failed"); diff --git a/crates/splice-app/src/ctl.rs b/crates/splice-app/src/ctl.rs index 9108144..10482aa 100644 --- a/crates/splice-app/src/ctl.rs +++ b/crates/splice-app/src/ctl.rs @@ -56,8 +56,30 @@ Titles, colours and looks [--feather F] [--invert/--no-invert] (mask numbers are fractions of the clip's frame) --crop-left F --crop-right F --crop-top F --crop-bottom F | --crop none (cut edges away, fractions of the picture; it keeps its size and what remains stays put) + --grade none|warm|cool|vivid|muted|faded|film|teal-orange|mono|noir (a ready-made look) + --exposure F (stops, −1 to 1) --contrast F --saturation F --warmth F --tint F (each −1 to 1) + (colour, for video and pictures; `look --all` gives every video and picture the same) C is #rrggbb, #rrggbbaa or a name: white, black, paper, ink, vermilion, gold, green, blue, clear. +Transitions, speed and keyframes + transition --clip C --kind KIND [--for S] | --none + the way into clip C from the clip before it (centred on the cut), or, + with nothing just before, in over what is beneath. KIND: dissolve, + dip-black, dip-white, wipe-left/right/up/down, push-left/right/up/down, + zoom. Default length 0.6s + transition --all --kind KIND [--for S] [--track N] + the same at every cut between two clips (on track N) + speed --clip C [--rate R] [--reverse|--forwards] + play at R (2 is twice as fast, 0.5 half; 0.1 to 16): the clip gets + shorter or longer over the same source, and what follows on its track + moves to match. Sound keeps its pitch + freeze --clip C [--at T] [--for S] hold the frame at T (default: the playhead) for S (default 2) + keyframe --clip C --at T [--x F] [--y F] [--scale F] [--opacity F] [--ease EASE] + set values at timeline time T; between keyframes they glide. EASE + (from this keyframe to the next): linear, in, out, in-out (default), hold + keyframe --clip C --at T --drop [--prop x|y|scale|opacity] take keyframes at T away + keyframe --clip C --clear [--prop NAME] take them all away; values stay as they are at the playhead + Music (a score: instruments and notes, played by General MIDI sounds) score new [--file score.json] [--name N] [--at T] [--track N] [--wait] add a score to the media (the starter if no file); with --at, place it @@ -425,12 +447,13 @@ fn build(args: &[String]) -> Result { let mut next = |name: &str| rest.next().ok_or(format!("{name} needs a value")); match arg.as_str() { "--font" | "--color" | "--box" | "--align" | "--enter" | "--exit" | "--drift" | "--key" | "--mask" - | "--query" | "--crop" => { + | "--query" | "--crop" | "--grade" | "--ease" | "--prop" => { request.insert(arg[2..].into(), json!(value(next(arg)?)?)); } "--size" | "--x" | "--y" | "--scale" | "--opacity" | "--similarity" | "--blend" | "--feather" | "--mask-x" | "--mask-y" | "--mask-width" | "--mask-height" | "--enter-for" | "--exit-for" - | "--crop-left" | "--crop-top" | "--crop-right" | "--crop-bottom" => { + | "--crop-left" | "--crop-top" | "--crop-right" | "--crop-bottom" | "--exposure" | "--contrast" + | "--saturation" | "--warmth" | "--tint" | "--rate" => { let raw = next(arg)?; let v = match raw.parse::() { Ok(v) => json!(v), @@ -517,9 +540,12 @@ fn build(args: &[String]) -> Result { let v: f64 = next(arg)?.parse().map_err(|_| format!("{arg} expects points"))?; request.insert(arg[2..].into(), json!(v)); } - "--ripple" | "--wait" | "--keep" | "--none" | "--all" | "--reset" | "--clear" | "--seam" => { + "--ripple" | "--wait" | "--keep" | "--none" | "--all" | "--reset" | "--clear" | "--seam" | "--drop" => { request.insert(arg[2..].into(), json!(true)); } + "--reverse" | "--forwards" => { + request.insert("reverse".into(), json!(arg == "--reverse")); + } "--draft" | "--high" => { request.insert("quality".into(), json!(&arg[2..])); } @@ -539,7 +565,7 @@ fn build(args: &[String]) -> Result { let cmd = match command.as_str() { "status" | "read" | "versions" | "add" | "move" | "trim" | "split" | "delete" | "duplicate" | "volume" | "track" | "canvas" | "undo" | "redo" | "play" | "pause" | "select" | "save" | "say" | "wait" | "text" - | "color" | "style" | "look" | "fonts" => command.as_str(), + | "color" | "style" | "look" | "fonts" | "transition" | "speed" | "freeze" | "keyframe" => command.as_str(), "restore" => { if let Some(id) = words.first().and_then(|w| w.parse::().ok()) { request.insert("version".into(), json!(id)); diff --git a/crates/splice-app/src/editor.rs b/crates/splice-app/src/editor.rs index a9d3964..09ccf60 100644 --- a/crates/splice-app/src/editor.rs +++ b/crates/splice-app/src/editor.rs @@ -30,6 +30,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime}; mod agent; +mod effects; mod input; mod inspector; mod made; @@ -168,6 +169,7 @@ struct ExportJob { progress: f32, done: Option>, child: Option, + cancelled: bool, } struct Status { @@ -176,6 +178,18 @@ struct Status { at: Instant, } +/// Names the retimed sound of a clip: what it plays, how fast, which way. +fn retimed_key(clip: &splice_core::model::Clip, media: &Media) -> String { + format!( + "{}:{}:{}:{}:{}", + media.hash, + clip.source_in, + clip.span(), + clip.speed, + clip.reverse + ) +} + pub struct Editor { focus: FocusHandle, palette_focus: FocusHandle, @@ -183,6 +197,9 @@ pub struct Editor { library: Library, player: Player, audio: AudioOut, + /// The sound of clips played at another speed or backwards, by + /// `retimed_key`; None while it is decoded. + retimed_sound: Arc>>>>, selection: Vec, bin_selection: Option, view: View, @@ -350,6 +367,7 @@ impl Editor { library: Library::default(), player: Player::new(), audio: AudioOut::open(), + retimed_sound: Default::default(), selection: Vec::new(), bin_selection: None, view: View::default(), @@ -734,6 +752,7 @@ impl Editor { fn after_edit(&mut self) { let project = &self.doc.project; self.selection.retain(|id| project.slot(*id).is_some()); + self.prepare_retimed_sound(); if self.player.is_playing() { self.audio.stop(); self.player.seek(self.player.position); @@ -764,26 +783,112 @@ impl Editor { } fn start_sound(&mut self) { + self.prepare_retimed_sound(); let audio = &self.audio; - let voices = self - .doc - .project + let project = &self.doc.project; + let retimed = self.retimed_sound.lock().unwrap(); + let voices = project .audible_clips() .into_iter() + .filter(|(clip, _)| !clip.frozen) .filter_map(|(clip, media)| { + // When it plays and how it fades, as export makes it. + let sound = project.sound(clip); + let fades = |f: Option<(Time, Time)>| f.map(|(a, z)| (audio.frames(a), audio.frames(z))); + let (fade_in, fade_out) = (fades(sound.fade_in), fades(sound.fade_out)); + let volume = clip.volume; + // A clip played faster, slower or backwards sounds as export + // makes it, once that is decoded; until then, its own sound + // retimed on the fly. + if clip.is_retimed() { + let (start, length) = (audio.frames(clip.start), audio.frames(clip.duration)); + if let Some(Some(pcm)) = retimed.get(&retimed_key(clip, media)) { + return Some(Voice { + start, + length, + source_in: 0, + step: 1.0, + volume, + pcm: pcm.clone(), + fade_in, + fade_out, + }); + } + let pcm = self.library.get(&media.hash)?.pcm.clone()?; + let (source_in, step) = if clip.reverse { + (audio.frames(clip.source_out()).saturating_sub(1), -clip.speed) + } else { + (audio.frames(clip.source_in), clip.speed) + }; + return Some(Voice { + start, + length, + source_in, + step, + volume, + pcm, + fade_in, + fade_out, + }); + } let pcm = self.library.get(&media.hash)?.pcm.clone()?; Some(Voice { - start: audio.frames(clip.start), - length: audio.frames(clip.duration), - source_in: audio.frames(clip.source_in), - volume: clip.volume, + start: audio.frames(sound.from), + length: audio.frames(sound.to - sound.from), + source_in: audio.frames(sound.source), + step: 1.0, + volume, pcm, + fade_in, + fade_out, }) }) .collect(); + drop(retimed); self.audio.play(self.player.position, voices); } + /// Decodes, in the background, the sound of every clip played at + /// another speed or backwards, as export will make it. + fn prepare_retimed_sound(&mut self) { + let rate = self.audio.rate; + let mut wanted = Vec::new(); + for (clip, media) in self.doc.project.audible_clips() { + if !clip.is_retimed() || clip.frozen { + continue; + } + let key = retimed_key(clip, media); + let Some(path) = self.library.path(&media.hash) else { + continue; + }; + wanted.push(( + key, + path.to_path_buf(), + clip.source_in, + clip.span(), + clip.speed, + clip.reverse, + )); + } + let mut cache = self.retimed_sound.lock().unwrap(); + let keep: std::collections::HashSet<&String> = wanted.iter().map(|w| &w.0).collect(); + cache.retain(|k, _| keep.contains(k)); + for (key, path, from, span, speed, reverse) in wanted { + if cache.contains_key(&key) { + continue; + } + cache.insert(key.clone(), None); + let cache = self.retimed_sound.clone(); + std::thread::spawn(move || { + if let Ok(pcm) = ffmpeg::decode_audio_retimed(&path, rate, from, span, speed, reverse) + && let Some(slot) = cache.lock().unwrap().get_mut(&key) + { + *slot = Some(Arc::new(pcm)); + } + }); + } + } + fn say(&mut self, text: impl Into) { self.status = Some(Status { text: text.into(), @@ -879,6 +984,12 @@ impl Editor { }); } Hit::Gutter(_, None) => {} + // A transition's tab: the clip it leads into, its transition open. + Hit::Transition(id) => { + self.selection = vec![id]; + self.layout.inspector_open = true; + self.folds.insert((id, "Transition"), true); + } Hit::Beat(id) => { self.select_beat(Some(id), cx); if event.click_count >= 2 { @@ -1400,43 +1511,57 @@ impl Editor { Ok(plan) => plan, Err(e) => return Err(format!("Cannot export: {e}")), }; - let mut command = ffmpeg::export_command().map_err(|e| e.to_string())?; - let child = command - .args(&plan.args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn(); - let mut child = match child { - Ok(child) => child, - Err(e) => return Err(format!("Could not start ffmpeg: {e}")), - }; - let stdout = child.stdout.take().unwrap(); - let mut stderr = child.stderr.take().unwrap(); + // Started off the window's thread: reversed clips are made first. let job = Arc::new(Mutex::new(ExportJob { output: output.clone(), - child: Some(child), ..Default::default() })); let shared = job.clone(); let duration = plan.duration; std::thread::spawn(move || { - let errors = std::thread::spawn(move || { - let mut text = String::new(); - let _ = stderr.read_to_string(&mut text); - text - }); - for line in BufReader::new(stdout).lines().map_while(Result::ok) { - if let Some(t) = export::parse_progress(&line) { - shared.lock().unwrap().progress = (t as f32 / duration as f32).clamp(0.0, 1.0); + let result = (|| -> Result<(), String> { + let going = || !shared.lock().unwrap().cancelled; + ffmpeg::prepare(&plan, going).map_err(|e| e.to_string())?; + let mut command = ffmpeg::export_command().map_err(|e| e.to_string())?; + let mut child = command + .args(&plan.args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Could not start ffmpeg: {e}"))?; + let stdout = child.stdout.take().unwrap(); + let mut stderr = child.stderr.take().unwrap(); + { + let mut job = shared.lock().unwrap(); + if job.cancelled { + let _ = child.kill(); + return Err("cancelled".into()); + } + job.child = Some(child); } - } - let child = shared.lock().unwrap().child.take(); - let status = child.map(|mut c| c.wait()); - let errors = errors.join().unwrap_or_default(); - let result = match status { - Some(Ok(s)) if s.success() => Ok(()), - None => Err("cancelled".to_string()), - _ => Err(errors.lines().last().unwrap_or("ffmpeg failed").to_string()), + let errors = std::thread::spawn(move || { + let mut text = String::new(); + let _ = stderr.read_to_string(&mut text); + text + }); + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + if let Some(t) = export::parse_progress(&line) { + shared.lock().unwrap().progress = (t as f32 / duration as f32).clamp(0.0, 1.0); + } + } + let child = shared.lock().unwrap().child.take(); + let status = child.map(|mut c| c.wait()); + let errors = errors.join().unwrap_or_default(); + match status { + Some(Ok(s)) if s.success() => Ok(()), + None => Err("cancelled".to_string()), + _ => Err(errors.lines().last().unwrap_or("ffmpeg failed").to_string()), + } + })(); + ffmpeg::tidy(&plan); + let result = match result { + Err(_) if shared.lock().unwrap().cancelled => Err("cancelled".to_string()), + other => other, }; shared.lock().unwrap().done = Some(result); }); @@ -1488,6 +1613,7 @@ impl Editor { } let _ = std::fs::remove_file(&job.output); job.child = None; + job.cancelled = true; } cx.notify(); } diff --git a/crates/splice-app/src/editor/agent.rs b/crates/splice-app/src/editor/agent.rs index ee406d9..a0116ee 100644 --- a/crates/splice-app/src/editor/agent.rs +++ b/crates/splice-app/src/editor/agent.rs @@ -105,9 +105,38 @@ fn look_json(look: &splice_core::look::Look) -> Value { })), "mask": look.mask, "crop": look.crop, + "grade": (!look.grade.is_none()).then(|| json!({ + "look": look.grade.preset.name(), + "exposure": look.grade.exposure, + "contrast": look.grade.contrast, + "saturation": look.grade.saturation, + "warmth": look.grade.temperature, + "tint": look.grade.tint, + })), + "keyframes": (!look.keys.is_empty()).then(|| keyframes_json(look)), }) } +/// Keyframes per property: clip time (seconds from its start), value, ease. +fn keyframes_json(look: &splice_core::look::Look) -> Value { + let mut out = serde_json::Map::new(); + for prop in splice_core::look::Prop::ALL { + let keys = look.keys.of(prop); + if keys.is_empty() { + continue; + } + out.insert( + prop.name().into(), + json!( + keys.iter() + .map(|k| json!({ "at": s(k.at), "value": k.value, "ease": k.ease.name() })) + .collect::>() + ), + ); + } + Value::Object(out) +} + fn kind(kind: MediaKind) -> &'static str { match kind { MediaKind::Video => "video", @@ -158,6 +187,7 @@ impl Editor { return self.ctl_plan(request, reply, cx); } "bible" => return self.ctl_bible(request, reply, cx), + "transition" | "speed" | "freeze" | "keyframe" => self.ctl_effects(cmd, request, cx), "story" | "cue" | "view" => self.ctl_story(cmd, request, cx), "record" => return self.ctl_record(request, reply, cx), "symbol" | "placeholder" | "hold" => return self.ctl_symbol(cmd, request, reply, cx), @@ -381,9 +411,14 @@ impl Editor { .unwrap_or(0) }); let mut look = splice_core::look::Look::default(); - if let Err(e) = apply_look(&mut look, request) { + if let Err(e) = apply_look(&mut look, request, 0) { return Some(fail(e)); } + if !look.grade.is_none() { + return Some(fail( + "colour (--grade, --exposure…) is for video and pictures; a title or colour clip takes its own --color", + )); + } let id = self.edit(cx, |p| { let id = p.insert_generated(generator, track, at, duration); p.set_look(id, |l| *l = look); @@ -429,9 +464,20 @@ impl Editor { self.edited_with(json!({ "clips": clips })) } "look" => { - let ids = match self.clips(request) { - Ok(ids) => ids, - Err(e) => return Some(fail(e)), + // `--all`: every video and picture, for one colour across the movie. + let ids = if flag("all") == Some(true) { + let project = &self.doc.project; + project + .clips() + .filter(|(_, c)| c.generator.is_none()) + .filter(|(_, c)| project.media(c.media).is_some_and(|m| m.is_visual())) + .map(|(_, c)| c.id) + .collect() + } else { + match self.clips(request) { + Ok(ids) => ids, + Err(e) => return Some(fail(e)), + } }; let mut looks = Vec::new(); for &id in &ids { @@ -439,12 +485,18 @@ impl Editor { continue; }; let mut look = clip.look.clone(); - if let Err(e) = apply_look(&mut look, request) { + let at = (self.player.position - clip.start).clamp(0, clip.duration); + if let Err(e) = apply_look(&mut look, request, at) { return Some(fail(e)); } if look.key.is_some() && clip.generator.is_some() { return Some(fail("a green-screen key is for video and pictures, not text or colour")); } + if look.grade != clip.look.grade && clip.generator.is_some() { + return Some(fail( + "colour (--grade, --exposure…) is for video and pictures; a title or colour clip takes its own --color", + )); + } looks.push((id, look)); } self.edit(cx, |p| { @@ -805,6 +857,24 @@ impl Editor { if clip.look != splice_core::look::Look::default() { value["look"] = look_json(&clip.look); } + if clip.frozen { + value["frozen"] = json!(true); + } + if clip.speed != 1.0 { + value["speed"] = json!(clip.speed); + } + if clip.reverse { + value["reverse"] = json!(true); + } + if let Some(cut) = project.cut_into(clip.id) { + value["transition"] = json!({ + "kind": cut.kind.name(), + "duration": s(cut.duration()), + "from": cut.from.map(|c| c.0), + "start": s(cut.start), + "end": s(cut.end), + }); + } value } @@ -948,15 +1018,19 @@ impl Editor { } /// Look fields from a request, onto `look`. -fn apply_look(look: &mut splice_core::look::Look, request: &Value) -> Result<(), String> { +/// `at` is clip time, where a keyframed value is keyed. +fn apply_look(look: &mut splice_core::look::Look, request: &Value, at: Time) -> Result<(), String> { use splice_core::look::{Drift, Key, Mask, MotionKind, Shape}; let text = |key: &str| request.get(key).and_then(Value::as_str); let number = |key: &str| request.get(key).and_then(Value::as_f64).map(|v| v as f32); let flag = |key: &str| request.get(key).and_then(Value::as_bool); - look.x = number("x").unwrap_or(look.x); - look.y = number("y").unwrap_or(look.y); - look.scale = number("scale").map_or(look.scale, |v| v.clamp(0.01, 20.0)); - look.opacity = number("opacity").map_or(look.opacity, |v| v.clamp(0.0, 1.0)); + // A keyframed value takes a keyframe at `at`; the rest change throughout. + use splice_core::look::Prop; + for prop in Prop::ALL { + if let Some(v) = number(prop.name()) { + look.set_value(prop, at, v); + } + } for (key, motion) in [("enter", &mut look.enter), ("exit", &mut look.exit)] { if let Some(kind) = text(key) { motion.kind = MotionKind::parse(kind).ok_or_else(|| { @@ -1015,6 +1089,19 @@ fn apply_look(look: &mut splice_core::look::Look, request: &Value) -> Result<(), mask.feather = number("feather").map_or(mask.feather, |v| v.clamp(0.001, 1.0)); mask.invert = flag("invert").unwrap_or(mask.invert); } + if let Some(name) = text("grade") { + look.grade.preset = splice_core::look::Preset::parse(name).ok_or_else(|| { + let names: Vec<_> = splice_core::look::Preset::ALL.iter().map(|p| p.name()).collect(); + format!("`grade` is one of {}", names.join(", ")) + })?; + } + let grade = &mut look.grade; + grade.exposure = number("exposure").unwrap_or(grade.exposure); + grade.contrast = number("contrast").unwrap_or(grade.contrast); + grade.saturation = number("saturation").unwrap_or(grade.saturation); + grade.temperature = number("warmth").unwrap_or(grade.temperature); + grade.tint = number("tint").unwrap_or(grade.tint); + *grade = grade.clamped(); if text("crop") == Some("none") { look.crop = None; } diff --git a/crates/splice-app/src/editor/effects.rs b/crates/splice-app/src/editor/effects.rs new file mode 100644 index 0000000..9d131cf --- /dev/null +++ b/crates/splice-app/src/editor/effects.rs @@ -0,0 +1,392 @@ +//! Commands for transitions, speed and keyframes: what the menu and keys do +//! beyond the inspector's own controls. + +use super::*; +use crate::actions::{AddKeyframe, AddTransition, FreezeFrame, ReverseClip}; +use splice_core::look::{Ease, Prop}; +use splice_core::model::Clip; +use splice_core::transition::{Kind, Transition}; + +/// How long a frozen frame holds when first made. +const HOLD: Time = 2 * SECOND; + +impl Editor { + /// The clip a playhead command works on: the selected one under the + /// playhead, or else the topmost picture there. + fn clip_at_playhead(&self, want: impl Fn(&Clip) -> bool) -> Option { + let project = &self.doc.project; + let t = self.player.position; + self.selection + .iter() + .filter_map(|id| project.clip(*id)) + .find(|c| c.contains(t) && want(c)) + .or_else(|| { + project + .visual_stack(t) + .into_iter() + .rev() + .map(|(c, _)| c) + .find(|c| want(c)) + }) + .map(|c| c.id) + } + + /// Holds the frame at the playhead for two seconds. + pub(super) fn freeze_frame_at_playhead(&mut self, cx: &mut Context) { + let t = self.player.position; + let video = |c: &Clip| { + c.generator.is_none() + && !c.frozen + && self + .doc + .project + .media(c.media) + .is_some_and(|m| m.kind == MediaKind::Video) + }; + let Some(id) = self.clip_at_playhead(video) else { + return self.say_error("Put the playhead over a video clip to hold its frame"); + }; + let held = self.edit(cx, |p| p.freeze_frame(id, t, HOLD)); + match held { + Some(held) => { + self.selection = vec![held]; + self.say("Holding this frame for 2s; drag its end to hold it longer"); + } + None => self.say_error("That frame could not be held"), + } + } + + pub(super) fn freeze_frame_action(&mut self, _: &FreezeFrame, _: &mut Window, cx: &mut Context) { + self.freeze_frame_at_playhead(cx); + } + + /// A dissolve into each selected clip, or else at the cut nearest the playhead. + pub(super) fn add_transition_action(&mut self, _: &AddTransition, _: &mut Window, cx: &mut Context) { + let project = &self.doc.project; + let visual = |id: ClipId| { + project + .clip(id) + .and_then(|c| project.source(c)) + .is_some_and(|s| s.is_visual()) + }; + let mut targets: Vec = self + .selection + .iter() + .copied() + .filter(|id| visual(*id) && project.clip(*id).is_some_and(|c| c.transition.is_none())) + .collect(); + if targets.is_empty() { + // The nearest cut between two clips, on a visible track. + let t = self.player.position; + let nearest = project + .tracks + .iter() + .filter(|lane| !lane.hidden) + .flat_map(|lane| lane.clips.iter()) + .filter(|c| project.clip_before(c.id).is_some() && visual(c.id)) + .min_by_key(|c| (c.start - t).abs()); + match nearest { + Some(c) if c.transition.is_none() => targets.push(c.id), + Some(c) => { + self.selection = vec![c.id]; + self.folds.insert((c.id, "Transition"), true); + self.layout.inspector_open = true; + self.say("That cut has a transition already"); + return cx.notify(); + } + None => return self.say_error("No cut between two clips to put a transition on"), + } + } + self.edit(cx, |p| { + p.set_transition(&targets, Some(Transition::new(Kind::Dissolve))) + }); + self.selection = targets.clone(); + for id in &targets { + self.folds.insert((*id, "Transition"), true); + } + self.layout.inspector_open = true; + self.say(match targets.len() { + 1 => "Dissolve added; choose another in the inspector".to_string(), + n => format!("Dissolves into {n} clips"), + }); + } + + /// Plays the selected clips backwards, or forwards again. + pub(super) fn reverse_action(&mut self, _: &ReverseClip, _: &mut Window, cx: &mut Context) { + let project = &self.doc.project; + let ids: Vec = self + .selection + .iter() + .copied() + .filter(|id| { + project + .clip(*id) + .filter(|c| c.generator.is_none() && !c.frozen) + .and_then(|c| project.media(c.media)) + .is_some_and(|m| m.kind == MediaKind::Video || m.kind == MediaKind::Audio) + }) + .collect(); + if ids.is_empty() { + return self.say_error("Select a video or sound clip to play it backwards"); + } + let back = !ids.iter().all(|id| project.clip(*id).is_some_and(|c| c.reverse)); + self.edit(cx, |p| p.set_reverse(&ids, back)); + self.say(if back { "Playing backwards" } else { "Playing forwards" }); + } + + /// Keyframes where and how big the selected picture is, at the playhead. + pub(super) fn add_keyframe_action(&mut self, _: &AddKeyframe, _: &mut Window, cx: &mut Context) { + let visual = |c: &Clip| self.doc.project.source(c).is_some_and(|s| s.is_visual()); + let Some(id) = self.clip_at_playhead(visual) else { + return self.say_error("Put the playhead over a picture to keyframe it"); + }; + let Some(clip) = self.doc.project.clip(id) else { return }; + let at = self.clip_time(clip); + self.edit(cx, |p| { + p.set_look(id, |look| { + for prop in [Prop::X, Prop::Y, Prop::Scale] { + let v = look.value(prop, at); + look.keys.set(prop, at, v, None); + } + }); + }); + self.selection = vec![id]; + self.layout.inspector_open = true; + self.say("Keyframed its place and size here; move the playhead and drag it to animate"); + } +} + +impl Editor { + /// `splice ctl transition | speed | freeze | keyframe`. + pub(super) fn ctl_effects( + &mut self, + cmd: &str, + request: &serde_json::Value, + cx: &mut Context, + ) -> serde_json::Value { + use crate::ctl::fail; + use serde_json::{Value, json}; + let text = |key: &str| request.get(key).and_then(Value::as_str); + let number = |key: &str| request.get(key).and_then(Value::as_f64); + let flag = |key: &str| request.get(key).and_then(Value::as_bool) == Some(true); + let named = match self.clips_of(request) { + Ok(ids) => ids, + Err(e) => return fail(e), + }; + let reply = |this: &Self, ids: &[ClipId]| { + let clips: Vec = ids.iter().map(|id| this.clip_json(*id)).collect(); + this.edited_with(json!({ "clips": clips })) + }; + match cmd { + "transition" => { + let transition = if flag("none") { + None + } else { + let Some(name) = text("kind") else { + return fail( + "say which with --kind (dissolve, dip-black, wipe-left, push-left, zoom…) or --none", + ); + }; + let Some(kind) = Kind::parse(name) else { + let names: Vec<_> = Kind::ALL.iter().map(|k| k.name()).collect(); + return fail(format!("`kind` is one of {}", names.join(", "))); + }; + let duration = number("for").map_or(Transition::DEFAULT_LENGTH, splice_core::time::from_secs); + Some(Transition { kind, duration }) + }; + let project = &self.doc.project; + let ids: Vec = if flag("all") { + let track = self.track(request); + project + .tracks + .iter() + .enumerate() + .filter(|(i, _)| track.is_none_or(|t| t == *i)) + .flat_map(|(_, lane)| lane.clips.iter()) + .filter(|c| project.clip_before(c.id).is_some()) + .map(|c| c.id) + .collect() + } else { + named + }; + if ids.is_empty() { + return fail(if flag("all") { + "no cuts between two clips there" + } else { + "name the clip the transition leads into with --clip (or use --all)" + }); + } + let pictures: Vec = ids + .iter() + .copied() + .filter(|id| { + project + .clip(*id) + .and_then(|c| project.source(c)) + .is_some_and(|s| s.is_visual()) + }) + .collect(); + if pictures.is_empty() { + return fail("transitions are for pictures: video, images, titles, colours, scenes"); + } + self.edit(cx, |p| p.set_transition(&pictures, transition)); + reply(self, &pictures) + } + "speed" => { + if named.is_empty() { + return fail("name clips with --clip"); + } + let project = &self.doc.project; + for id in &named { + let clip = project.clip(*id).expect("named clips exist"); + let ok = clip.generator.is_none() + && project.media(clip.media).is_some_and(|m| { + m.is_timed() && !matches!(m.recipe, Some(splice_core::model::Recipe::Symbol(_))) + }); + if !ok { + return fail(format!( + "clip {} has no speed: only video and sound play faster or slower (a symbol stretches: `symbol length`)", + id.0 + )); + } + if clip.frozen { + return fail(format!("clip {} is a held frame; drag or trim it to hold longer", id.0)); + } + } + let rate = number("rate"); + if let Some(r) = rate + && !(splice_core::model::SLOWEST..=splice_core::model::FASTEST).contains(&r) + { + return fail(format!( + "`rate` is from {} to {}", + splice_core::model::SLOWEST, + splice_core::model::FASTEST + )); + } + let reverse = request.get("reverse").and_then(Value::as_bool); + if rate.is_none() && reverse.is_none() { + return fail("say how: --rate R and/or --reverse | --forwards"); + } + self.edit(cx, |p| { + if let Some(r) = rate { + p.set_speed(&named, r); + } + if let Some(back) = reverse { + p.set_reverse(&named, back); + } + }); + reply(self, &named) + } + "freeze" => { + let [id] = named[..] else { + return fail("name one video clip with --clip"); + }; + let at = match self.time(request, "at") { + Ok(t) => t.unwrap_or(self.player.position), + Err(e) => return fail(e), + }; + let hold = number("for").map_or(HOLD, splice_core::time::from_secs); + match self.edit(cx, |p| p.freeze_frame(id, at, hold)) { + Some(held) => reply(self, &[held]), + None => fail("hold a frame of a video clip, at a time inside it"), + } + } + "keyframe" => { + let [id] = named[..] else { + return fail("name one clip with --clip"); + }; + let props: Vec = match text("prop") { + Some(name) => match Prop::parse(name) { + Some(p) => vec![p], + None => return fail("`prop` is x, y, scale or opacity"), + }, + None => Prop::ALL.to_vec(), + }; + let Some(clip) = self.doc.project.clip(id).cloned() else { + return fail("there is no such clip"); + }; + if flag("clear") { + let at = self.clip_time(&clip); + self.edit(cx, |p| { + p.set_look(id, |look| { + for prop in &props { + let v = look.value(*prop, at); + look.keys.of_mut(*prop).clear(); + look.set_value(*prop, at, v); + } + }) + }); + return reply(self, &[id]); + } + let at = match self.time(request, "at") { + Ok(Some(t)) => t - clip.start, + Ok(None) => return fail("say when with --at T (timeline time)"), + Err(e) => return fail(e), + }; + if at < 0 || at > clip.duration { + return fail(format!( + "--at is outside clip {} ({:.2}s to {:.2}s)", + id.0, + splice_core::time::secs(clip.start), + splice_core::time::secs(clip.end()) + )); + } + if flag("drop") { + let mut gone = 0; + self.edit(cx, |p| { + p.set_look(id, |look| { + for prop in &props { + let v = look.value(*prop, at); + if look.keys.remove(*prop, at) { + gone += 1; + if look.keys.of(*prop).is_empty() { + look.set_value(*prop, at, v); + } + } + } + }) + }); + if gone == 0 { + return fail("no keyframe there"); + } + return reply(self, &[id]); + } + let ease = match text("ease") { + Some(name) => match Ease::parse(name) { + Some(e) => Some(e), + None => return fail("`ease` is linear, in, out, in-out or hold"), + }, + None => None, + }; + let values: Vec<(Prop, f32)> = Prop::ALL + .iter() + .filter_map(|p| number(p.name()).map(|v| (*p, v as f32))) + .collect(); + if values.is_empty() && ease.is_none() { + return fail("give values to keyframe: --x, --y, --scale, --opacity (and --ease)"); + } + self.edit(cx, |p| { + p.set_look(id, |look| { + for (prop, v) in &values { + look.keys.set(*prop, at, *v, ease); + } + // An ease alone changes the keyframes already there. + if values.is_empty() + && let Some(e) = ease + { + for prop in &props { + for k in look.keys.of_mut(*prop) { + if (k.at - at).abs() <= splice_core::look::KEY_NEAR { + k.ease = e; + } + } + } + } + }) + }); + reply(self, &[id]) + } + _ => fail(format!("unknown command {cmd}")), + } + } +} diff --git a/crates/splice-app/src/editor/inspector.rs b/crates/splice-app/src/editor/inspector.rs index 30e59ac..b93cdb4 100644 --- a/crates/splice-app/src/editor/inspector.rs +++ b/crates/splice-app/src/editor/inspector.rs @@ -6,8 +6,9 @@ use super::render::{caps, icon, tip}; use super::stage::Grab; use super::*; use gpui::{Hsla, Stateful}; -use splice_core::look::{Drift, Key, Mask, MotionKind, Shape}; +use splice_core::look::{Drift, Ease, KEY_NEAR, Key, Mask, MotionKind, Preset, Prop, Shape}; use splice_core::model::{Align, Clip, Source, Title}; +use splice_core::transition::{Kind as TransitionKind, Transition}; pub const INSPECTOR_WIDTH: f32 = 292.0; @@ -33,9 +34,27 @@ pub enum Field { CropBottom, EnterDuration, ExitDuration, + Exposure, + Contrast, + Saturation, + Warmth, + Tint, + Speed, + TransitionLength, } impl Field { + /// The keyframed property it shows, if it is one. + pub fn prop(self) -> Option { + match self { + Field::X => Some(Prop::X), + Field::Y => Some(Prop::Y), + Field::Scale => Some(Prop::Scale), + Field::Opacity => Some(Prop::Opacity), + _ => None, + } + } + fn range(self) -> (f32, f32) { match self { Field::X | Field::Y => (-0.75, 0.75), @@ -50,6 +69,9 @@ impl Field { Field::MaskFeather => (0.001, 1.0), Field::CropLeft | Field::CropRight | Field::CropTop | Field::CropBottom => (0.0, 0.95), Field::EnterDuration | Field::ExitDuration => (0.1, 3.0), + Field::Exposure | Field::Contrast | Field::Saturation | Field::Warmth | Field::Tint => (-1.0, 1.0), + Field::Speed => (0.1, 4.0), + Field::TransitionLength => (0.1, 3.0), } } @@ -66,6 +88,9 @@ impl Field { Field::MaskFeather => Mask::default().feather, Field::CropLeft | Field::CropRight | Field::CropTop | Field::CropBottom => 0.0, Field::EnterDuration | Field::ExitDuration => 0.5, + Field::Exposure | Field::Contrast | Field::Saturation | Field::Warmth | Field::Tint => 0.0, + Field::Speed => 1.0, + Field::TransitionLength => secs(Transition::DEFAULT_LENGTH) as f32, } } @@ -88,7 +113,13 @@ impl Field { Field::CropRight => "Right", Field::CropTop => "Top", Field::CropBottom => "Bottom", - Field::EnterDuration | Field::ExitDuration => "Length", + Field::EnterDuration | Field::ExitDuration | Field::TransitionLength => "Length", + Field::Exposure => "Exposure", + Field::Contrast => "Contrast", + Field::Saturation => "Saturation", + Field::Warmth => "Warmth", + Field::Tint => "Tint", + Field::Speed => "Speed", } } @@ -104,7 +135,10 @@ impl Field { } } Field::TextSize => format!("{:.0} px", v * canvas_height as f32), - Field::EnterDuration | Field::ExitDuration => format!("{v:.1}s"), + Field::EnterDuration | Field::ExitDuration | Field::TransitionLength => format!("{v:.1}s"), + Field::Exposure => format!("{v:+.1}"), + Field::Contrast | Field::Saturation | Field::Warmth | Field::Tint => format!("{:+.0}", v * 100.0), + Field::Speed => format!("{}×", crate::timeline::speed_label(v as f64)), Field::X | Field::Y => format!("{:+.0}%", v * 100.0), // Mask places count from the middle of the picture, like X and Y. Field::MaskX | Field::MaskY => format!("{:+.0}%", (v - 0.5) * 100.0), @@ -112,7 +146,8 @@ impl Field { } } - fn get(self, clip: &Clip) -> f32 { + /// Its value for `clip`, `at` (clip time) for what keyframes animate. + fn get(self, clip: &Clip, at: Time) -> f32 { let look = &clip.look; let key = look.key.unwrap_or_default(); let mask = look.mask.unwrap_or_default(); @@ -120,11 +155,11 @@ impl Field { Some(Generator::Text(t)) => t.size, _ => Title::default().size, }; + if let Some(prop) = self.prop() { + return look.value(prop, at); + } match self { - Field::X => look.x, - Field::Y => look.y, - Field::Scale => look.scale, - Field::Opacity => look.opacity, + Field::X | Field::Y | Field::Scale | Field::Opacity => unreachable!("keyed above"), Field::Volume => clip.volume, Field::TextSize => title, Field::KeySimilarity => key.similarity, @@ -140,16 +175,32 @@ impl Field { Field::CropBottom => look.crop.map_or(0.0, |c| c.bottom), Field::EnterDuration => secs(look.enter.duration) as f32, Field::ExitDuration => secs(look.exit.duration) as f32, + Field::Exposure => look.grade.exposure, + Field::Contrast => look.grade.contrast, + Field::Saturation => look.grade.saturation, + Field::Warmth => look.grade.temperature, + Field::Tint => look.grade.tint, + Field::Speed => clip.speed as f32, + Field::TransitionLength => clip.transition.map_or(0.6, |t| secs(t.duration) as f32), } } - fn set(self, clip: &mut Clip, v: f32) { + /// Sets it on `clip`; what keyframes animate gets a keyframe `at` (clip + /// time). Speed and transition lengths go through the project instead. + fn set(self, clip: &mut Clip, v: f32, at: Time) { let look = &mut clip.look; + if let Some(prop) = self.prop() { + look.set_value(prop, at, v); + return; + } match self { - Field::X => look.x = v, - Field::Y => look.y = v, - Field::Scale => look.scale = v, - Field::Opacity => look.opacity = v, + Field::X | Field::Y | Field::Scale | Field::Opacity => {} + Field::Speed | Field::TransitionLength => {} + Field::Exposure => look.grade.exposure = v, + Field::Contrast => look.grade.contrast = v, + Field::Saturation => look.grade.saturation = v, + Field::Warmth => look.grade.temperature = v, + Field::Tint => look.grade.tint = v, Field::Volume => clip.volume = v, Field::TextSize => { if let Some(Generator::Text(t)) = &mut clip.generator { @@ -271,8 +322,16 @@ impl Editor { // Sliders. fn slider(&self, id: ClipId, clip: &Clip, field: Field, cx: &mut Context) -> impl IntoElement + use<> { - let (lo, hi) = field.range(); - let value = field.get(clip); + let (lo, hi) = self.field_range(field, clip); + let value = match field { + // As it plays: trims since can have shortened it. + Field::TransitionLength => self + .doc + .project + .cut_into(id) + .map_or_else(|| field.get(clip, 0), |c| secs(c.duration()) as f32), + _ => field.get(clip, self.clip_time(clip)), + }; let frac = ((value - lo) / (hi - lo)).clamp(0.0, 1.0); let bounds = self.slider_bounds.clone(); let held = matches!(&self.grab, Some(Grab::Slider { field: f, .. }) if *f == field); @@ -348,7 +407,11 @@ impl Editor { cx.listener(move |this, event: &MouseDownEvent, _, cx| { cx.stop_propagation(); if event.click_count >= 2 { - this.change(id, cx, |clip| field.set(clip, field.default())); + let before = this.doc.project.clone(); + this.apply_field(id, field, field.default()); + this.doc.commit(before); + this.after_edit(); + cx.notify(); return; } this.grab = Some(Grab::Slider { @@ -378,19 +441,53 @@ impl Editor { return; }; let Some(id) = self.inspected() else { return }; - let (lo, hi) = field.range(); + let Some(clip) = self.doc.project.clip(id) else { return }; + let (lo, hi) = self.field_range(field, clip); let frac = (f32::from(at.x - bounds.left()) / f32::from(bounds.size.width).max(1.0)).clamp(0.0, 1.0); let mut v = lo + frac * (hi - lo); // Rest at the natural value when near it. if (v - field.default()).abs() < (hi - lo) * 0.015 { v = field.default(); } - if let Some(clip) = self.doc.project.clip_mut(id) { - field.set(clip, v); - } + self.apply_field(id, field, v); cx.notify(); } + /// A slider's range for this clip: a transition is no longer than its clips allow. + fn field_range(&self, field: Field, clip: &Clip) -> (f32, f32) { + let (lo, hi) = field.range(); + match field { + Field::TransitionLength => { + let most = secs(self.doc.project.longest_transition(clip.id)) as f32; + (lo, hi.min(most).max(lo + 0.05)) + } + _ => (lo, hi), + } + } + + /// Sets a slider's value on clip `id`, not yet an undo step. + pub(super) fn apply_field(&mut self, id: ClipId, field: Field, v: f32) { + match field { + Field::Speed => self.doc.project.set_speed(&[id], v as f64), + Field::TransitionLength => { + if let Some(t) = self.doc.project.clip(id).and_then(|c| c.transition) { + let length = splice_core::time::from_secs(v as f64); + self.doc + .project + .set_transition(&[id], Some(Transition { duration: length, ..t })); + } + } + _ => { + let Some(at) = self.doc.project.clip(id).map(|c| self.clip_time(c)) else { + return; + }; + if let Some(clip) = self.doc.project.clip_mut(id) { + field.set(clip, v, at); + } + } + } + } + // Small controls. pub(super) fn chip( @@ -696,6 +793,15 @@ impl Editor { seconds(clip.start) ); + // What its media lets it do: change colour (any picture of media), + // change speed (video and sound, not a symbol, which stretches instead). + let (graded, timed) = match project.source(&clip) { + Some(Source::Media(m)) => ( + m.is_visual(), + m.is_timed() && !matches!(m.recipe, Some(splice_core::model::Recipe::Symbol(_))), + ), + _ => (false, false), + }; let mut sections: Vec = Vec::new(); let symbol = project .media(clip.media) @@ -788,7 +894,16 @@ impl Editor { } if visual { sections.push(self.place_section(id, &clip, cx).into_any_element()); + sections.push(self.transition_section(id, &clip, cx).into_any_element()); sections.push(self.motion_section(id, &clip, cx).into_any_element()); + if graded { + sections.push(self.colour_section(id, &clip, cx).into_any_element()); + } + } + if timed { + sections.push(self.speed_section(id, &clip, cx).into_any_element()); + } + if visual { if media_clip { sections.push(self.crop_section(id, &clip, cx).into_any_element()); sections.push(self.key_section(id, &clip, cx).into_any_element()); @@ -1082,13 +1197,429 @@ impl Editor { .text_size(px(11.0)) .line_height(px(16.0)) .text_color(Theme::overlay()) - .child("Drag it on the stage, or its corners to resize."), + .child("Drag it on the stage, or its corners to resize. ◆ keys a value at the playhead."), ), ) - .child(self.slider(id, clip, Field::Scale, cx)) - .child(self.slider(id, clip, Field::Opacity, cx)) - .child(self.slider(id, clip, Field::X, cx)) - .child(self.slider(id, clip, Field::Y, cx)) + .child(self.keyed_slider(id, clip, Field::Scale, cx)) + .child(self.keyed_slider(id, clip, Field::Opacity, cx)) + .child(self.keyed_slider(id, clip, Field::X, cx)) + .child(self.keyed_slider(id, clip, Field::Y, cx)) + .when(!clip.look.keys.is_empty(), |d| d.child(self.keyframe_bar(id, clip, cx))) + .when( + !clip.look.keys.is_empty() && (self.player.position < clip.start || self.player.position > clip.end()), + |d| { + d.child( + div() + .text_size(px(10.5)) + .line_height(px(15.0)) + .text_color(Theme::overlay()) + .child( + "The playhead is outside this clip: changes to animated values key its nearest end.", + ), + ) + }, + ) + } + + // Keyframes. + + /// A slider with a keyframe button: filled when this value has a + /// keyframe at the playhead, hollow and bright when it is animated + /// elsewhere, faint when it is not animated. + fn keyed_slider( + &mut self, + id: ClipId, + clip: &Clip, + field: Field, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let prop = field.prop().expect("a keyed field"); + let at = self.clip_time(clip); + let keys = clip.look.keys.of(prop); + let here = keys.iter().any(|k| (k.at - at).abs() <= KEY_NEAR); + let animated = !keys.is_empty(); + // Keyframes go where the playhead is, so it has to be in the clip. + let inside = self.player.position >= clip.start && self.player.position <= clip.end(); + let color = if !inside { + Theme::surface2() + } else if here || animated { + Theme::accent() + } else { + Theme::overlay() + }; + let button = div() + .id(SharedString::from(format!("key-{}", prop.name()))) + .size(px(18.0)) + .flex_none() + .rounded(px(4.0)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|s| s.bg(Theme::lane())) + .tooltip(tip( + if !inside { + "Move the playhead into this clip to keyframe it" + } else if here { + "Take away this keyframe" + } else if animated { + "Add a keyframe here" + } else { + "Animate: keyframe this value here" + }, + None, + )) + .child(icon(if here { "key-on" } else { "key" }, color).size(px(11.0))) + .when(inside, |d| { + d.on_click(cx.listener(move |this, _, _, cx| this.toggle_key(id, prop, cx))) + }); + div() + .flex() + .items_center() + .gap(px(4.0)) + .child(button) + .child(div().flex_1().min_w_0().child(self.slider(id, clip, field, cx))) + } + + /// Keyframes the value of `prop` at the playhead, or takes away the one there. + pub(super) fn toggle_key(&mut self, id: ClipId, prop: Prop, cx: &mut Context) { + let Some(clip) = self.doc.project.clip(id) else { return }; + let at = self.clip_time(clip); + self.change(id, cx, |c| { + let value = c.look.value(prop, at); + if !c.look.keys.remove(prop, at) { + c.look.keys.set(prop, at, value, None); + } else if c.look.keys.of(prop).is_empty() { + // The last one gone, the value stays where it was. + c.look.set_value(prop, at, value); + } + }); + } + + /// Under the keyed sliders: step between keyframes, ease the one at the + /// playhead, or clear them all. + fn keyframe_bar(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> { + let at = self.clip_time(clip); + let moments = clip.look.keys.moments(); + let start = clip.start; + let previous = moments.iter().rev().find(|m| **m < at - KEY_NEAR).copied(); + let next = moments.iter().find(|m| **m > at + KEY_NEAR).copied(); + let here = clip.look.keys.has_at(at); + let step = |name: &'static str, glyph: &str, to: Option