diff --git a/README.md b/README.md index aac7209..d2372d5 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,17 @@ make install-app # Splice.app in /Applications - **Text** (⌘T, or the Text shelf): titles in any installed font, with size, bold, italic, alignment, colour, a box behind and a soft shadow. Type in the inspector, or double-click a title on the stage. -- **Colour** shelf: full-frame colours for backgrounds and title cards, and a - half-black tint. +- **Words come in** one at a time: by word, letter or line, fading, + rising, popping or typed; set it in the Text section. +- **Captions** (Edit → Add Captions from Speech, or the clip's Sound + section): Splice hears what is said, on your Mac (whisper.cpp, + `brew install whisper-cpp`; a 148 MB speech model downloads once), and lays + captions over it, each word lighting up as it is said. Each caption is a + title you can edit. `.srt` and `.vtt` files load too. +- **Colour** shelf: full-frame colours for backgrounds and title cards, a + half-black tint, and light leaks that wash warm or cool light over a shot. +- **Letterbox** (the canvas inspector, with nothing selected): bars that + frame the whole movie to 2.39, 2:1, 1.85 or 4:3. - **Inspector** (⌘2), for the selected clip: - **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 @@ -125,6 +134,12 @@ make install-app # Splice.app in /Applications - **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. + - **Effects:** blur, vignette, film grain, a handheld shake and a glitch, + each a slider. + - **Background:** fill the canvas behind a picture with a blurred, darkened + copy of it: a phone video on a wide canvas. + - **Hide part:** blur, pixelate or black out a box of the picture (a + face, a screen, a number plate). - **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. @@ -316,6 +331,9 @@ with `make install-skill`) teaches Claude Code to use it. | `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 captions` | captions from speech (on this Mac) or a subtitle file | +| `splice ctl zoom` | zoom a screen recording in where it was clicked | +| `splice ctl leak` | light leaks over a shot | | `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 d02b295..ed0d326 100644 --- a/crates/splice-app/src/actions.rs +++ b/crates/splice-app/src/actions.rs @@ -59,6 +59,7 @@ actions!( FreezeFrame, ReverseClip, AddKeyframe, + AddCaptions, ShowSymbols, ShowMedia, ShowTextStyles, @@ -202,6 +203,7 @@ pub fn menus() -> Vec { MenuItem::action("Freeze Frame", FreezeFrame), MenuItem::action("Play Backwards", ReverseClip), MenuItem::action("Add Keyframe", AddKeyframe), + MenuItem::action("Add Captions from Speech", AddCaptions), MenuItem::separator(), MenuItem::action("Make Symbol…", MakeSymbol), MenuItem::action("Break Symbol Apart", BreakApart), @@ -313,6 +315,11 @@ pub fn commands() -> Vec<(&'static str, &'static str, Box)> { ), ("Play the selection backwards", "", Box::new(ReverseClip)), ("Keyframe place and size at the playhead", "⌥K", Box::new(AddKeyframe)), + ( + "Add captions from what is said (selected clip)", + "", + Box::new(AddCaptions), + ), ("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/cli.rs b/crates/splice-app/src/cli.rs index 6e62abf..9752b67 100644 --- a/crates/splice-app/src/cli.rs +++ b/crates/splice-app/src/cli.rs @@ -107,6 +107,7 @@ fn new(path: &Path, media: &[String]) -> anyhow::Result<()> { source: std::fs::canonicalize(&file).unwrap_or(file.clone()), recipe: None, content: None, + clicks: Vec::new(), }); project.append_clip(id); } diff --git a/crates/splice-app/src/ctl.rs b/crates/splice-app/src/ctl.rs index 10482aa..fde8701 100644 --- a/crates/splice-app/src/ctl.rs +++ b/crates/splice-app/src/ctl.rs @@ -33,7 +33,7 @@ Editing (each is one undo step; clips and media are named by id, media also by n duplicate --clip C copy clips to just after themselves volume --clip C --value V 1 is as recorded, 0 silent, up to 4 track --track N [--mute|--unmute] [--hide|--show] - canvas [--aspect 16:9|9:16|1:1|4:5] [--size WxH] [--fps N] + canvas [--aspect 16:9|9:16|1:1|4:5] [--size WxH] [--fps N] [--letterbox 2.39|1.85|2|none] undo | redo Titles, colours and looks @@ -47,6 +47,8 @@ Titles, colours and looks style: --font NAME --size PX --color C --box C|none --align left|center|right --bold/--no-bold --italic/--no-italic --shadow/--no-shadow + --animate fade|rise|pop|type|karaoke|none [--by word|letter|line] [--over S] + (words come in one at a time, spread over S seconds, or as captions' words are said) look: --x F --y F (centre offset, fractions of the canvas) --scale F (1 = natural) --opacity F --enter KIND [--enter-for S] --exit KIND [--exit-for S] KIND: none, fade, slide-up, slide-down, slide-left, slide-right, zoom, pop @@ -59,6 +61,10 @@ Titles, colours and looks --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) + --blur F --vignette F --grain F --shake F --glitch F (effects, 0 to 1; 0 is off) + --backdrop on|off [--backdrop-blur F --backdrop-dim F] (a blurred copy filling the canvas behind) + --redact blur|pixelate|black|none [--redact-x F --redact-y F --redact-width F --redact-height F] + (hide a box of the picture: a face, a screen; fractions of the clip's frame, centre and size) C is #rrggbb, #rrggbbaa or a name: white, black, paper, ink, vermilion, gold, green, blue, clear. Transitions, speed and keyframes @@ -79,6 +85,14 @@ Transitions, speed and keyframes (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 + zoom --clip C [--to 2] | --none a screen recording made in Splice: zoom in towards each burst + of clicks noted while recording (keyframes on place and size) + leak --kind warm|sun|cool [--at T] a light leak washing over what is there, 4s (a scene: edit as one) + captions --clip C [--words N] [--plain] [--wait] + hear what is said in clip C (on this Mac, whisper.cpp) and lay captions + over it on a new track: lines of up to N words (6), each word lighting up + as it is said (--plain: whole lines). Each caption is a title to edit + captions --file subtitles.srt captions from an .srt or .vtt file Music (a score: instruments and notes, played by General MIDI sounds) score new [--file score.json] [--name N] [--at T] [--track N] [--wait] @@ -447,13 +461,16 @@ 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" | "--grade" | "--ease" | "--prop" => { + | "--query" | "--crop" | "--grade" | "--ease" | "--prop" | "--backdrop" | "--redact" | "--letterbox" + | "--animate" | "--by" => { 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" | "--exposure" | "--contrast" - | "--saturation" | "--warmth" | "--tint" | "--rate" => { + | "--saturation" | "--warmth" | "--tint" | "--rate" | "--blur" | "--vignette" | "--grain" | "--shake" + | "--glitch" | "--backdrop-blur" | "--backdrop-dim" | "--redact-x" | "--redact-y" | "--redact-width" + | "--redact-height" => { let raw = next(arg)?; let v = match raw.parse::() { Ok(v) => json!(v), @@ -490,6 +507,10 @@ fn build(args: &[String]) -> Result { let w = next(arg)?; request.insert("window".into(), w.parse::().map_or(json!(w), |id| json!(id))); } + "--over" => { + let v: f64 = next(arg)?.parse().map_err(|_| "--over expects seconds".to_string())?; + request.insert("over".into(), json!(v)); + } "--for" => { let v: f64 = next(arg)?.parse().map_err(|_| "--for expects seconds".to_string())?; request.insert("for".into(), json!(v)); @@ -501,6 +522,10 @@ fn build(args: &[String]) -> Result { let v: f64 = next(arg)?.parse().map_err(|_| "--every expects seconds".to_string())?; request.insert("every".into(), json!(v)); } + "--words" if command == "captions" => { + let n: u64 = next(arg)?.parse().map_err(|_| "--words expects a number".to_string())?; + request.insert("words".into(), json!(n)); + } "--value" => { let v: f64 = next(arg)?.parse().map_err(|_| "--value expects a number".to_string())?; request.insert("value".into(), json!(v)); @@ -540,7 +565,8 @@ 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" | "--drop" => { + "--ripple" | "--wait" | "--keep" | "--none" | "--all" | "--reset" | "--clear" | "--seam" | "--drop" + | "--plain" => { request.insert(arg[2..].into(), json!(true)); } "--reverse" | "--forwards" => { @@ -565,7 +591,8 @@ 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" | "transition" | "speed" | "freeze" | "keyframe" => command.as_str(), + | "color" | "style" | "look" | "fonts" | "transition" | "speed" | "freeze" | "keyframe" | "leak" + | "captions" | "zoom" => 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 09ccf60..af5f43e 100644 --- a/crates/splice-app/src/editor.rs +++ b/crates/splice-app/src/editor.rs @@ -200,6 +200,8 @@ pub struct Editor { /// The sound of clips played at another speed or backwards, by /// `retimed_key`; None while it is decoded. retimed_sound: Arc>>>>, + /// Clips being heard for captions. + captioning: std::collections::HashSet, selection: Vec, bin_selection: Option, view: View, @@ -368,6 +370,7 @@ impl Editor { player: Player::new(), audio: AudioOut::open(), retimed_sound: Default::default(), + captioning: Default::default(), selection: Vec::new(), bin_selection: None, view: View::default(), @@ -654,6 +657,8 @@ impl Editor { source: path.clone(), recipe: None, content: None, + // A screen recording's clicks, noted beside it. + clicks: crate::recorder::read_clicks(&path), }); let media = project.media(id).cloned().expect("just added"); if let Some(landing) = self.take_after_import.remove(&path) { diff --git a/crates/splice-app/src/editor/agent.rs b/crates/splice-app/src/editor/agent.rs index a0116ee..2fdb711 100644 --- a/crates/splice-app/src/editor/agent.rs +++ b/crates/splice-app/src/editor/agent.rs @@ -105,6 +105,9 @@ fn look_json(look: &splice_core::look::Look) -> Value { })), "mask": look.mask, "crop": look.crop, + "effects": (!look.fx.is_none()).then_some(look.fx), + "backdrop": look.backdrop, + "redact": look.redact, "grade": (!look.grade.is_none()).then(|| json!({ "look": look.grade.preset.name(), "exposure": look.grade.exposure, @@ -187,7 +190,8 @@ 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), + "captions" => return self.ctl_captions(request, reply, cx), + "transition" | "speed" | "freeze" | "keyframe" | "leak" | "zoom" => 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), @@ -492,6 +496,17 @@ impl Editor { 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")); } + let pictures_only = look.backdrop != clip.look.backdrop + || look.redact != clip.look.redact + || look.fx.blur != clip.look.fx.blur + || look.fx.vignette != clip.look.fx.vignette + || look.fx.grain != clip.look.fx.grain; + if pictures_only && clip.generator.is_some() { + return Some(fail( + "blur, vignette, grain, --backdrop and --redact are for video and pictures; a title or colour \ + takes --shake, --glitch and the rest", + )); + } 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", @@ -540,7 +555,30 @@ impl Editor { } settings.fps = fps as u32; } - self.edit(cx, |p| p.settings = settings); + // Black bars: a shape (2.39, 1.85, 2:1, 16:9) or none. + let letterbox = match text("letterbox") { + None => self.doc.project.letterbox, + Some("none" | "off") => None, + Some(shape) => { + let parsed = match shape.split_once(':') { + Some((w, h)) => w + .trim() + .parse::() + .ok() + .zip(h.trim().parse::().ok()) + .map(|(w, h)| w / h), + None => shape.trim().parse::().ok(), + }; + match parsed.filter(|a| (0.3..=4.0).contains(a)) { + Some(a) => Some(a), + None => return Some(fail("`letterbox` is a shape like 2.39, 1.85 or 2:1, or none")), + } + } + }; + self.edit(cx, |p| { + p.settings = settings; + p.letterbox = letterbox; + }); self.edited() } "undo" | "redo" => { @@ -747,6 +785,30 @@ impl Editor { title.bold = flag("bold").unwrap_or(title.bold); title.italic = flag("italic").unwrap_or(title.italic); title.shadow = flag("shadow").unwrap_or(title.shadow); + // Coming in a piece at a time. + use splice_core::textmotion::{By, Style, TextMotion}; + match request.get("animate").and_then(Value::as_str) { + Some("none" | "off") => title.animate = None, + Some(name) => { + let style = Style::parse(name).ok_or_else(|| { + let names: Vec<_> = Style::ALL.iter().map(|s| s.name()).collect(); + format!("`animate` is none or one of {}", names.join(", ")) + })?; + title.animate = Some(TextMotion { + style, + ..title.animate.unwrap_or_default() + }); + } + None => {} + } + if let Some(m) = &mut title.animate { + if let Some(name) = request.get("by").and_then(Value::as_str) { + m.by = By::parse(name).ok_or("`by` is word, letter or line")?; + } + if let Some(over) = request.get("over").and_then(Value::as_f64) { + m.over = splice_core::time::from_secs(over.clamp(0.05, 30.0)); + } + } Ok(()) } @@ -850,6 +912,12 @@ impl Editor { "align": title.align, "box": title.background.map(hex), "shadow": title.shadow, + "animate": title.animate.map(|m| json!({ + "style": m.style.name(), + "by": m.by.name(), + "over": s(m.over), + })), + "timed": !title.timing.is_empty(), }); } Some(Generator::Color { color }) => value["color"] = json!(hex(*color)), @@ -860,6 +928,12 @@ impl Editor { if clip.frozen { value["frozen"] = json!(true); } + if let Some(m) = project + .media(clip.media) + .filter(|m| !m.clicks.is_empty() && clip.generator.is_none()) + { + value["clicks"] = json!(m.clicks.len()); + } if clip.speed != 1.0 { value["speed"] = json!(clip.speed); } @@ -894,7 +968,12 @@ impl Editor { "saved": !self.doc.edited, "revision": self.doc.revision, }, - "canvas": { "width": settings.width, "height": settings.height, "fps": settings.fps }, + "canvas": { + "width": settings.width, + "height": settings.height, + "fps": settings.fps, + "letterbox": self.doc.project.letterbox, + }, "duration": s(project.duration()), "playhead": s(self.player.position), "playing": self.player.is_playing(), @@ -1102,6 +1181,41 @@ fn apply_look(look: &mut splice_core::look::Look, request: &Value, at: Time) -> grade.temperature = number("warmth").unwrap_or(grade.temperature); grade.tint = number("tint").unwrap_or(grade.tint); *grade = grade.clamped(); + let fx = &mut look.fx; + fx.blur = number("blur").unwrap_or(fx.blur); + fx.vignette = number("vignette").unwrap_or(fx.vignette); + fx.grain = number("grain").unwrap_or(fx.grain); + fx.shake = number("shake").unwrap_or(fx.shake); + fx.glitch = number("glitch").unwrap_or(fx.glitch); + *fx = fx.clamped(); + match text("backdrop") { + Some("on" | "yes" | "blur") => look.backdrop = Some(look.backdrop.unwrap_or_default()), + Some("off" | "no" | "none") => look.backdrop = None, + Some(other) => return Err(format!("`backdrop` is on or off, not {other}")), + None => {} + } + if let Some(b) = &mut look.backdrop { + b.blur = number("backdrop_blur").map_or(b.blur, |v| v.clamp(0.0, 1.0)); + b.dim = number("backdrop_dim").map_or(b.dim, |v| v.clamp(0.0, 1.0)); + } + match text("redact") { + Some("none" | "off") => look.redact = None, + Some(name) => { + let style = splice_core::look::RedactStyle::parse(name) + .ok_or(format!("`redact` is blur, pixelate, black or none, not {name}"))?; + look.redact = Some(splice_core::look::Redact { + style, + ..look.redact.unwrap_or_default() + }); + } + None => {} + } + if let Some(r) = &mut look.redact { + r.x = number("redact_x").map_or(r.x, |v| v.clamp(0.0, 1.0)); + r.y = number("redact_y").map_or(r.y, |v| v.clamp(0.0, 1.0)); + r.width = number("redact_width").map_or(r.width, |v| v.clamp(0.02, 1.0)); + r.height = number("redact_height").map_or(r.height, |v| v.clamp(0.02, 1.0)); + } 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 index 9d131cf..78a2715 100644 --- a/crates/splice-app/src/editor/effects.rs +++ b/crates/splice-app/src/editor/effects.rs @@ -2,7 +2,7 @@ //! beyond the inspector's own controls. use super::*; -use crate::actions::{AddKeyframe, AddTransition, FreezeFrame, ReverseClip}; +use crate::actions::{AddCaptions, AddKeyframe, AddTransition, FreezeFrame, ReverseClip}; use splice_core::look::{Ease, Prop}; use splice_core::model::Clip; use splice_core::transition::{Kind, Transition}; @@ -386,7 +386,205 @@ impl Editor { }); reply(self, &[id]) } + "zoom" => { + let [id] = named[..] else { + return fail("name one screen recording clip with --clip"); + }; + if flag("none") { + self.edit(cx, |p| p.clear_zoom(id)); + return reply(self, &[id]); + } + let zoom = number("to").unwrap_or(2.0) as f32; + match self.edit(cx, |p| p.zoom_to_clicks(id, zoom)) { + 0 => fail( + "no clicks noted in the stretch of recording this clip plays (record with Splice to note them)", + ), + _ => reply(self, &[id]), + } + } + "leak" => { + let names: Vec<&str> = splice_core::leaks::LEAKS.iter().map(|l| l.name).collect(); + let Some(leak) = text("kind").and_then(splice_core::leaks::find) else { + return fail(format!("say which with --kind: {}", names.join(", "))); + }; + let at = match self.time(request, "at") { + Ok(t) => t, + Err(e) => return fail(e), + }; + match self.add_leak(leak, at, cx) { + Some(clip) => reply(self, &[clip]), + None => fail("the light leak could not be placed"), + } + } _ => fail(format!("unknown command {cmd}")), } } } + +impl Editor { + /// Hears what is said in clip `id` and lays captions over it, on a new + /// track: in the background, downloading the speech model first if it + /// is not here. `reply` (a `splice ctl … --wait`) hears how it went. + pub(super) fn caption_clip( + &mut self, + id: ClipId, + layout: splice_core::captions::Layout, + reply: Option>, + cx: &mut Context, + ) -> Result<(), String> { + let project = &self.doc.project; + let clip = project.clip(id).ok_or("there is no such clip")?.clone(); + let media = project + .media(clip.media) + .filter(|m| m.has_audio && clip.generator.is_none()) + .ok_or("that clip has no sound to caption")?; + if clip.reverse || clip.frozen { + return Err("a clip playing backwards or held still has no speech to caption".into()); + } + if crate::speech::whisper().is_none() { + return Err(crate::speech::INSTALL.into()); + } + let path = self + .library + .path(&media.hash) + .ok_or("its media is not ready yet")? + .to_path_buf(); + let (from, span) = (clip.source_in, clip.span()); + if !self.captioning.insert(id) { + return Err("that clip is being captioned already".into()); + } + self.say(if crate::speech::has_model() { + "Listening for words…".to_string() + } else { + format!( + "Downloading the speech model ({} MB, once), then listening…", + crate::speech::MODEL_SIZE_MB + ) + }); + cx.notify(); + cx.spawn(async move |this, cx| { + let heard = cx + .background_spawn(async move { crate::speech::transcribe(&path, from, span) }) + .await; + this.update(cx, |editor, cx| { + editor.captioning.remove(&id); + // Where the clip is now: it may have moved while it was heard. + let now = editor.doc.project.clip(id).cloned(); + let heard: anyhow::Result<(Vec, Time, f64)> = match (heard, now) { + (Err(e), _) => Err(e), + (Ok(_), None) => Err(anyhow::anyhow!("the clip was removed while it was heard")), + (Ok(_), Some(c)) if c.source_in != from || c.speed != clip.speed || c.reverse => Err( + anyhow::anyhow!("the clip was trimmed or retimed while it was heard; caption it again"), + ), + (Ok(h), Some(c)) => Ok((h, c.start, c.speed)), + }; + let answer = match heard { + Ok((heard, _, _)) if heard.is_empty() => { + editor.say_error("No speech heard in that clip"); + serde_json::json!({ "ok": false, "error": "no speech heard" }) + } + Ok((heard, start, speed)) => { + // From source time to timeline time, at the clip's speed. + let at = |t: Time| start + (t as f64 / speed).round() as Time; + let words: Vec = heard + .into_iter() + .map(|w| splice_core::captions::Word { + text: w.text, + start: at(w.start), + end: at(w.end), + }) + .collect(); + let ids = editor.edit(cx, |p| p.add_captions(&words, &layout)); + editor.selection = ids.clone(); + editor.say(format!( + "{} captions from {} words; each is a title you can edit", + ids.len(), + words.len() + )); + let clips: Vec<_> = ids.iter().map(|id| editor.clip_json(*id)).collect(); + editor.edited_with(serde_json::json!({ "clips": clips, "words": words.len() })) + } + Err(e) => { + editor.say_error(format!("Captions: {e}")); + serde_json::json!({ "ok": false, "error": e.to_string() }) + } + }; + if let Some(reply) = reply { + let _ = reply.send(answer.to_string()); + } + cx.notify(); + }) + .ok(); + }) + .detach(); + Ok(()) + } + + /// Captions the selected clip (or the first with sound under the playhead). + pub(super) fn add_captions_action(&mut self, _: &AddCaptions, _: &mut Window, cx: &mut Context) { + let project = &self.doc.project; + let sounds = |c: &Clip| c.generator.is_none() && project.media(c.media).is_some_and(|m| m.has_audio); + let t = self.player.position; + let target = self + .selection + .iter() + .filter_map(|id| project.clip(*id)) + .find(|c| sounds(c)) + .or_else(|| project.clips().map(|(_, c)| c).find(|c| c.contains(t) && sounds(c))) + .map(|c| c.id); + let Some(id) = target else { + return self.say_error("Select a clip with speech in it to caption"); + }; + if let Err(e) = self.caption_clip(id, Default::default(), None, cx) { + self.say_error(e); + } + } +} + +impl Editor { + /// `splice ctl captions`: from speech (`--clip`, answered when heard with + /// `--wait`) or from a subtitle file (`--file`). + pub(super) fn ctl_captions( + &mut self, + request: &serde_json::Value, + reply: std::sync::mpsc::Sender, + cx: &mut Context, + ) -> Option { + use crate::ctl::fail; + use serde_json::{Value, json}; + if let Some(text) = request.get("content").and_then(Value::as_str) { + let cues = splice_core::captions::parse_subtitles(text); + if cues.is_empty() { + return Some(fail("no captions found: give an .srt or .vtt file")); + } + let ids = self.edit(cx, |p| p.add_subtitles(&cues)); + self.selection = ids.clone(); + let clips: Vec = ids.iter().map(|id| self.clip_json(*id)).collect(); + return Some(self.edited_with(json!({ "clips": clips }))); + } + let ids = match self.clips_of(request) { + Ok(ids) => ids, + Err(e) => return Some(fail(e)), + }; + let [id] = ids[..] else { + return Some(fail( + "name one clip with speech in it (--clip), or give --file captions.srt", + )); + }; + let mut layout = splice_core::captions::Layout::default(); + if let Some(n) = request.get("words").and_then(Value::as_f64) { + layout.words = (n as usize).clamp(1, 20); + } + if request.get("plain").and_then(Value::as_bool) == Some(true) { + layout.karaoke = false; + } + let wait = request.get("wait").and_then(Value::as_bool) == Some(true); + match self.caption_clip(id, layout, wait.then_some(reply), cx) { + Err(e) => Some(fail(e)), + Ok(()) if wait => None, + Ok(()) => { + Some(json!({ "ok": true, "listening": true, "note": "captions arrive when heard; --wait waits" })) + } + } + } +} diff --git a/crates/splice-app/src/editor/inspector.rs b/crates/splice-app/src/editor/inspector.rs index b93cdb4..78c55c3 100644 --- a/crates/splice-app/src/editor/inspector.rs +++ b/crates/splice-app/src/editor/inspector.rs @@ -6,12 +6,23 @@ use super::render::{caps, icon, tip}; use super::stage::Grab; use super::*; use gpui::{Hsla, Stateful}; -use splice_core::look::{Drift, Ease, KEY_NEAR, Key, Mask, MotionKind, Preset, Prop, Shape}; +use splice_core::look::{ + Backdrop, Drift, Ease, KEY_NEAR, Key, Mask, MotionKind, Preset, Prop, Redact, RedactStyle, Shape, +}; use splice_core::model::{Align, Clip, Source, Title}; use splice_core::transition::{Kind as TransitionKind, Transition}; pub const INSPECTOR_WIDTH: f32 = 292.0; +/// Letterbox shapes offered: name, and width over height (None: no bars). +pub const LETTERBOXES: &[(&str, Option)] = &[ + ("None", None), + ("2.39 Cinema", Some(2.39)), + ("2:1", Some(2.0)), + ("1.85", Some(1.85)), + ("4:3", Some(4.0 / 3.0)), +]; + /// A number the inspector edits with a slider. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Field { @@ -41,6 +52,18 @@ pub enum Field { Tint, Speed, TransitionLength, + Blur, + Vignette, + Grain, + Shake, + Glitch, + BackdropBlur, + BackdropDim, + RedactX, + RedactY, + RedactWidth, + RedactHeight, + TextOver, } impl Field { @@ -72,6 +95,17 @@ impl Field { 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), + Field::Blur + | Field::Vignette + | Field::Grain + | Field::Shake + | Field::Glitch + | Field::BackdropBlur + | Field::BackdropDim + | Field::RedactX + | Field::RedactY => (0.0, 1.0), + Field::RedactWidth | Field::RedactHeight => (0.02, 1.0), + Field::TextOver => (0.1, 5.0), } } @@ -91,6 +125,14 @@ impl Field { Field::Exposure | Field::Contrast | Field::Saturation | Field::Warmth | Field::Tint => 0.0, Field::Speed => 1.0, Field::TransitionLength => secs(Transition::DEFAULT_LENGTH) as f32, + Field::Blur | Field::Vignette | Field::Grain | Field::Shake | Field::Glitch => 0.0, + Field::BackdropBlur => Backdrop::default().blur, + Field::BackdropDim => Backdrop::default().dim, + Field::RedactX => Redact::default().x, + Field::RedactY => Redact::default().y, + Field::RedactWidth => Redact::default().width, + Field::RedactHeight => Redact::default().height, + Field::TextOver => 1.0, } } @@ -120,6 +162,17 @@ impl Field { Field::Warmth => "Warmth", Field::Tint => "Tint", Field::Speed => "Speed", + Field::Blur | Field::BackdropBlur => "Blur", + Field::Vignette => "Vignette", + Field::Grain => "Grain", + Field::Shake => "Shake", + Field::Glitch => "Glitch", + Field::BackdropDim => "Darken", + Field::RedactX => "Across", + Field::RedactY => "Down", + Field::RedactWidth => "Width", + Field::RedactHeight => "Height", + Field::TextOver => "Over", } } @@ -135,13 +188,15 @@ impl Field { } } Field::TextSize => format!("{:.0} px", v * canvas_height as f32), - Field::EnterDuration | Field::ExitDuration | Field::TransitionLength => format!("{v:.1}s"), + Field::EnterDuration | Field::ExitDuration | Field::TransitionLength | Field::TextOver => { + 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), + Field::MaskX | Field::MaskY | Field::RedactX | Field::RedactY => format!("{:+.0}%", (v - 0.5) * 100.0), _ => format!("{:.0}%", v * 100.0), } } @@ -182,6 +237,21 @@ impl Field { 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), + Field::Blur => look.fx.blur, + Field::Vignette => look.fx.vignette, + Field::Grain => look.fx.grain, + Field::Shake => look.fx.shake, + Field::Glitch => look.fx.glitch, + Field::BackdropBlur => look.backdrop.unwrap_or_default().blur, + Field::BackdropDim => look.backdrop.unwrap_or_default().dim, + Field::RedactX => look.redact.unwrap_or_default().x, + Field::RedactY => look.redact.unwrap_or_default().y, + Field::RedactWidth => look.redact.unwrap_or_default().width, + Field::RedactHeight => look.redact.unwrap_or_default().height, + Field::TextOver => match &clip.generator { + Some(Generator::Text(t)) => t.animate.map_or(1.0, |m| secs(m.over) as f32), + _ => 1.0, + }, } } @@ -196,6 +266,37 @@ impl Field { match self { Field::X | Field::Y | Field::Scale | Field::Opacity => {} Field::Speed | Field::TransitionLength => {} + Field::TextOver => { + if let Some(Generator::Text(t)) = &mut clip.generator + && let Some(m) = &mut t.animate + { + m.over = splice_core::time::from_secs(v as f64); + } + } + Field::Blur => look.fx.blur = v, + Field::Vignette => look.fx.vignette = v, + Field::Grain => look.fx.grain = v, + Field::Shake => look.fx.shake = v, + Field::Glitch => look.fx.glitch = v, + Field::BackdropBlur | Field::BackdropDim => { + if let Some(b) = &mut look.backdrop { + if self == Field::BackdropBlur { + b.blur = v; + } else { + b.dim = v; + } + } + } + Field::RedactX | Field::RedactY | Field::RedactWidth | Field::RedactHeight => { + if let Some(r) = &mut look.redact { + match self { + Field::RedactX => r.x = v, + Field::RedactY => r.y = v, + Field::RedactWidth => r.width = v, + _ => r.height = v, + } + } + } Field::Exposure => look.grade.exposure = v, Field::Contrast => look.grade.contrast = v, Field::Saturation => look.grade.saturation = v, @@ -751,6 +852,7 @@ impl Editor { self.section("Shape") .child(div().flex().flex_wrap().gap(px(6.0)).children(chips)), ) + .child(self.letterbox_section(cx)) .when_some(self.hold_section(cx), |d, hold| d.child(hold)) .child( div() @@ -763,6 +865,35 @@ impl Editor { ) } + /// Bars that frame the movie to another shape. + fn letterbox_section(&mut self, cx: &mut Context) -> impl IntoElement + use<> { + let current = self.doc.project.letterbox; + let chips: Vec = LETTERBOXES + .iter() + .map(|(label, aspect)| { + let aspect = *aspect; + let on = match (current, aspect) { + (None, None) => true, + (Some(a), Some(b)) => (a - b).abs() < 0.005, + _ => false, + }; + self.chip(format!("letterbox-{label}"), label, on, cx, move |this, cx| { + this.edit(cx, |p| p.letterbox = aspect); + }) + .into_any_element() + }) + .collect(); + self.section("Letterbox") + .child(div().flex().flex_wrap().gap(px(6.0)).children(chips)) + .child( + div() + .text_size(px(10.5)) + .line_height(px(15.0)) + .text_color(Theme::overlay()) + .child("Black bars over the whole movie, framing it to a wider shape."), + ) + } + fn inspect_clip(&mut self, clip: Clip, window: &mut Window, cx: &mut Context) -> impl IntoElement + use<> { let id = clip.id; let project = &self.doc.project; @@ -898,11 +1029,26 @@ impl Editor { sections.push(self.motion_section(id, &clip, cx).into_any_element()); if graded { sections.push(self.colour_section(id, &clip, cx).into_any_element()); + sections.push(self.effects_section(id, &clip, cx).into_any_element()); + sections.push(self.backdrop_section(id, &clip, cx).into_any_element()); + sections.push(self.redact_section(id, &clip, cx).into_any_element()); + } else { + sections.push(self.effects_section(id, &clip, cx).into_any_element()); } } if timed { sections.push(self.speed_section(id, &clip, cx).into_any_element()); } + // A screen recording with its clicks noted. + let clicks = self + .doc + .project + .media(clip.media) + .filter(|_| clip.generator.is_none()) + .map_or(0, |m| m.clicks.len()); + if clicks > 0 { + sections.push(self.zoom_section(id, clicks, cx).into_any_element()); + } if visual { if media_clip { sections.push(self.crop_section(id, &clip, cx).into_any_element()); @@ -911,9 +1057,22 @@ impl Editor { sections.push(self.mask_section(id, &clip, cx).into_any_element()); } if audio { + let speech = clip.generator.is_none() && !clip.reverse && !clip.frozen; sections.push( self.section("Sound") .child(self.slider(id, &clip, Field::Volume, cx)) + .when(speech, |d| { + d.child( + div().flex().child( + self.chip("captions", "Add captions from speech", false, cx, move |this, cx| { + if let Err(e) = this.caption_clip(id, Default::default(), None, cx) { + this.say_error(e); + } + }) + .tooltip(tip("Hears what is said, on this Mac, and lays captions over it", None)), + ), + ) + }) .into_any_element(), ); } @@ -1135,6 +1294,83 @@ impl Editor { .child(self.toggle("shadow", "Shadow", title.shadow, cx, move |this, cx| { this.change_title(id, cx, |t| t.shadow = !t.shadow); })) + .child(self.words_in(id, clip, &title, cx)) + } + + /// How a title's words come in: one at a time, and how. + fn words_in(&mut self, id: ClipId, clip: &Clip, title: &Title, cx: &mut Context) -> impl IntoElement + use<> { + use splice_core::textmotion::{By, Style, TextMotion}; + let motion = title.animate; + let timed = !title.timing.is_empty(); + let mut styles: Vec = vec![ + self.chip("words-none", "All at once", motion.is_none(), cx, move |this, cx| { + this.change_title(id, cx, |t| t.animate = None); + }) + .into_any_element(), + ]; + styles.extend(Style::ALL.into_iter().map(|style| { + let on = motion.is_some_and(|m| m.style == style); + self.chip( + format!("words-{}", style.name()), + style.label(), + on, + cx, + move |this, cx| { + this.change_title(id, cx, |t| { + t.animate = Some(TextMotion { + style, + ..t.animate.unwrap_or_default() + }); + }); + }, + ) + .into_any_element() + })); + let units: Vec = By::ALL + .into_iter() + .map(|by| { + let on = motion.is_some_and(|m| m.by == by); + let label = match by { + By::Word => "Words", + By::Letter => "Letters", + By::Line => "Lines", + }; + self.chip(format!("words-by-{}", by.name()), label, on, cx, move |this, cx| { + this.change_title(id, cx, |t| { + if let Some(m) = &mut t.animate { + m.by = by; + } + }); + }) + .flex_1() + .into_any_element() + }) + .collect(); + div() + .pt(px(6.0)) + .flex() + .flex_col() + .gap(px(6.0)) + .child( + div() + .text_size(px(11.0)) + .text_color(Theme::overlay()) + .child("Words come in"), + ) + .child(div().flex().flex_wrap().gap(px(5.0)).children(styles)) + .when(motion.is_some(), |d| { + d.child(div().flex().gap(px(5.0)).children(units)) + .when(!timed, |d| d.child(self.slider(id, clip, Field::TextOver, cx))) + .when(timed, |d| { + d.child( + div() + .text_size(px(10.5)) + .line_height(px(15.0)) + .text_color(Theme::overlay()) + .child("Timed to the words as they are said."), + ) + }) + }) } fn place_section(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> { @@ -1535,6 +1771,158 @@ impl Editor { self.say(format!("Every picture has this colour now ({count} clips)")); } + // Effects. + + fn effects_section(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> { + let fx = clip.look.fx; + // Titles and colours shake and glitch; what blurs and grains is media. + let media = clip.generator.is_none(); + let (section, open) = self.fold(id, "Effects", !fx.is_none(), fx.summary(), cx); + section.when(open, |d| { + d.when(media, |d| { + d.child(self.slider(id, clip, Field::Blur, cx)) + .child(self.slider(id, clip, Field::Vignette, cx)) + .child(self.slider(id, clip, Field::Grain, cx)) + }) + .child(self.slider(id, clip, Field::Shake, cx)) + .child(self.slider(id, clip, Field::Glitch, cx)) + .when(!fx.is_none(), |d| { + d.child( + div() + .flex() + .child(self.chip("fx-reset", "Reset effects", false, cx, move |this, cx| { + this.change(id, cx, |c| c.look.fx = Default::default()); + })), + ) + }) + }) + } + + /// A blurred copy of the picture filling the canvas behind it. + fn backdrop_section(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> { + let on = clip.look.backdrop.is_some(); + let (section, open) = self.fold(id, "Background", on, if on { "Blurred fill" } else { "Off" }.into(), cx); + section.when(open, |d| { + d.child(self.toggle( + "backdrop-on", + "Fill the canvas behind it, blurred", + on, + cx, + move |this, cx| { + this.change(id, cx, |c| { + c.look.backdrop = if c.look.backdrop.is_some() { + None + } else { + Some(Backdrop::default()) + }; + }); + }, + )) + .when(on, |d| { + d.child(self.slider(id, clip, Field::BackdropBlur, cx)) + .child(self.slider(id, clip, Field::BackdropDim, cx)) + }) + .child( + div() + .text_size(px(10.5)) + .line_height(px(15.0)) + .text_color(Theme::overlay()) + .child("For a picture that doesn't fill the frame: a phone video on a wide canvas."), + ) + }) + } + + /// Hiding part of the picture: a face, a screen, a number plate. + fn redact_section(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> { + let redact = clip.look.redact; + let styles: Vec = std::iter::once(None) + .chain(RedactStyle::ALL.into_iter().map(Some)) + .map(|style| { + let label = match style { + None => "Off", + Some(RedactStyle::Blur) => "Blur", + Some(RedactStyle::Pixelate) => "Pixelate", + Some(RedactStyle::Black) => "Black box", + }; + let on = redact.map(|r| r.style) == style; + self.chip(format!("redact-{label}"), label, on, cx, move |this, cx| { + this.change(id, cx, |c| { + c.look.redact = style.map(|style| Redact { + style, + ..c.look.redact.unwrap_or_default() + }); + }); + }) + .flex_1() + .px(px(4.0)) + .into_any_element() + }) + .collect(); + let summary = match redact.map(|r| r.style) { + None => "Off", + Some(RedactStyle::Blur) => "Blurred box", + Some(RedactStyle::Pixelate) => "Pixelated box", + Some(RedactStyle::Black) => "Black box", + }; + let (section, open) = self.fold(id, "Hide part", redact.is_some(), summary.into(), cx); + section.when(open, |d| { + d.child(div().flex().gap(px(4.0)).children(styles)) + .when(redact.is_some(), |d| { + d.child(self.slider(id, clip, Field::RedactWidth, cx)) + .child(self.slider(id, clip, Field::RedactHeight, cx)) + .child(self.slider(id, clip, Field::RedactX, cx)) + .child(self.slider(id, clip, Field::RedactY, cx)) + }) + }) + } + + /// Zooming in where a screen recording was clicked. + fn zoom_section(&mut self, id: ClipId, clicks: usize, cx: &mut Context) -> impl IntoElement + use<> { + let zooms: Vec = [1.5f32, 2.0, 2.5] + .into_iter() + .map(|zoom| { + self.chip( + format!("zoom-{zoom}"), + &format!("{zoom}×"), + false, + cx, + move |this, cx| { + let made = this.edit(cx, |p| p.zoom_to_clicks(id, zoom)); + if made == 0 { + this.say_error("No clicks in the part of the recording this clip plays"); + } else { + this.say(format!( + "Zooms in {made} time{}; they are keyframes, so drag or ease them as you like", + if made == 1 { "" } else { "s" } + )); + } + }, + ) + .flex_1() + .into_any_element() + }) + .collect(); + self.section("Zoom to clicks") + .child( + div() + .text_size(px(11.0)) + .line_height(px(16.0)) + .text_color(Theme::overlay()) + .child(format!( + "{clicks} click{} noted while recording. Zoom in towards each burst of them:", + if clicks == 1 { "" } else { "s" } + )), + ) + .child(div().flex().gap(px(5.0)).children(zooms)) + .child( + div() + .flex() + .child(self.chip("zoom-off", "Remove zoom", false, cx, move |this, cx| { + this.edit(cx, |p| p.clear_zoom(id)); + })), + ) + } + // Speed. fn speed_section(&mut self, id: ClipId, clip: &Clip, cx: &mut Context) -> impl IntoElement + use<> { diff --git a/crates/splice-app/src/editor/made.rs b/crates/splice-app/src/editor/made.rs index d46e031..89ac24f 100644 --- a/crates/splice-app/src/editor/made.rs +++ b/crates/splice-app/src/editor/made.rs @@ -83,6 +83,7 @@ impl Editor { source: PathBuf::new(), recipe: Some(recipe), content: None, + clicks: Vec::new(), } } diff --git a/crates/splice-app/src/editor/render.rs b/crates/splice-app/src/editor/render.rs index 274ae10..129c253 100644 --- a/crates/splice-app/src/editor/render.rs +++ b/crates/splice-app/src/editor/render.rs @@ -332,6 +332,7 @@ impl Render for Editor { .on_action(cx.listener(Self::freeze_frame_action)) .on_action(cx.listener(Self::reverse_action)) .on_action(cx.listener(Self::add_keyframe_action)) + .on_action(cx.listener(Self::add_captions_action)) .on_action(|_: &Minimize, window, _| window.minimize_window()) .on_action(|_: &ZoomWindow, window, _| window.zoom_window()) .on_action(|_: &ToggleFullScreen, window, _| window.toggle_fullscreen()) diff --git a/crates/splice-app/src/editor/shelf.rs b/crates/splice-app/src/editor/shelf.rs index 2b092ca..414d799 100644 --- a/crates/splice-app/src/editor/shelf.rs +++ b/crates/splice-app/src/editor/shelf.rs @@ -126,6 +126,9 @@ fn presets() -> Vec { ] } +/// How long a light leak runs when laid down. +const LEAK_LENGTH: Time = 4 * SECOND; + const COLORS: &[(&str, u32)] = &[ ("Black", 0x000000ff), ("White", 0xffffffff), @@ -342,7 +345,13 @@ impl Editor { ) }) .collect(); - self.shelf_grid("Backgrounds and title cards. Tint darkens what is under it.", cards) + // Light leaks: washes of light over the footage. + let mut cards = cards; + cards.extend(splice_core::leaks::LEAKS.iter().map(|leak| self.leak_card(leak, cx))); + self.shelf_grid( + "Backgrounds and title cards. Tint darkens what is under it; a leak washes light over it.", + cards, + ) } Shelf::Symbols => { let cards: Vec = self @@ -442,6 +451,78 @@ impl Editor { .into_any_element() } + /// A light leak's card: its colours as a glow; a click lays it over + /// what is at the playhead. + fn leak_card(&self, leak: &'static splice_core::leaks::Leak, cx: &mut Context) -> AnyElement { + let group: SharedString = format!("shelf-{}", leak.name).into(); + let card = self.card_width; + let [a, b] = leak.colors; + let glow = |c: u32, alpha: f32| { + let mut h: gpui::Hsla = gpui::rgb(c).into(); + h.a = alpha; + h + }; + div() + .id(SharedString::from(format!("card-{}", leak.name))) + .group(group.clone()) + .w(px(card)) + .flex() + .flex_col() + .gap(px(5.0)) + .cursor_pointer() + .child( + div() + .w(px(card)) + .h(px(card * render::CARD_ASPECT)) + .rounded(px(7.0)) + .overflow_hidden() + .bg(gpui::linear_gradient( + 115.0, + gpui::linear_color_stop(glow(a, 0.9), 0.0), + gpui::linear_color_stop(glow(b, 0.15), 1.0), + )) + .border_1() + .border_color(Theme::edge()) + .group_hover(group.clone(), |s| s.border_color(Theme::surface2())), + ) + .child( + div() + .w(px(card)) + .px(px(1.0)) + .text_size(px(10.5)) + .text_color(Theme::muted()) + .truncate() + .child(leak.name), + ) + .tooltip(tip("Click to lay it over the playhead", None)) + .on_click(cx.listener(move |this, _, _, cx| { + this.add_leak(leak, None, cx); + })) + .into_any_element() + } + + /// Lays a light leak over what is at `at` (default: the playhead), for + /// four seconds. Returns its clip. + pub(super) fn add_leak( + &mut self, + leak: &splice_core::leaks::Leak, + at: Option