From 66a13aeaa439ae2dfbadfd582668cf2bf4bacc68 Mon Sep 17 00:00:00 2001 From: Calvin Grunewald Date: Thu, 22 Oct 2026 12:32:32 -0700 Subject: [PATCH 1/2] Transitions, colour, speed and keyframes Tier one of effects, each shared by the viewer and export: - Transitions at cuts: dissolve, dip to black or white, wipes and pushes four ways, and zoom, centred on the cut with frames held past either clip's ends. Sound crossfades across them (dips fade out and in). - Colour: ready-made looks plus exposure, contrast, saturation, warmth and tint, run as decoder filters so preview and export match. - Speed: 0.1x to 16x, backwards, and freeze frames. Speed keeps the stretch of source and ripples the track; sound keeps its pitch. Reversed clips export in pieces made beforehand, so memory stays flat. - Keyframes for position, size and opacity with five eases; the stage and sliders key at the playhead. compose.rs works out what is drawn when; export and the stage both read it. Also fixes moving pictures smearing their edges across the canvas in export (perspective fills outside the picture with edge pixels). Co-Authored-By: Claude Opus 5.5 --- README.md | 23 +- crates/splice-app/src/actions.rs | 24 + crates/splice-app/src/audio.rs | 31 +- crates/splice-app/src/cli.rs | 9 +- crates/splice-app/src/ctl.rs | 34 +- crates/splice-app/src/editor.rs | 198 +++++- crates/splice-app/src/editor/agent.rs | 107 ++- crates/splice-app/src/editor/effects.rs | 392 +++++++++++ crates/splice-app/src/editor/inspector.rs | 581 ++++++++++++++++- crates/splice-app/src/editor/render.rs | 4 + crates/splice-app/src/editor/stage.rs | 101 ++- crates/splice-app/src/ffmpeg.rs | 269 +++++++- crates/splice-app/src/icons.rs | 13 + crates/splice-app/src/player.rs | 143 ++-- crates/splice-app/src/symbols.rs | 9 +- crates/splice-app/src/timeline.rs | 112 +++- crates/splice-core/src/compose.rs | 582 +++++++++++++++++ crates/splice-core/src/export.rs | 758 ++++++++++++++++++---- crates/splice-core/src/lib.rs | 2 + crates/splice-core/src/look.rs | 618 +++++++++++++++++- crates/splice-core/src/model.rs | 318 +++++++-- crates/splice-core/src/story.rs | 12 +- crates/splice-core/src/symbol.rs | 13 +- crates/splice-core/src/transition.rs | 335 ++++++++++ crates/splice-core/tests/render.rs | 463 +++++++++++++ skill/splice/SKILL.md | 37 ++ 26 files changed, 4776 insertions(+), 412 deletions(-) create mode 100644 crates/splice-app/src/editor/effects.rs create mode 100644 crates/splice-core/src/compose.rs create mode 100644 crates/splice-core/src/transition.rs create mode 100644 crates/splice-core/tests/render.rs 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