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,
+ cx: &mut Context,
+ ) -> Option {
+ let at = at.unwrap_or(self.player.position);
+ let track = self.layer_above(at, LEAK_LENGTH);
+ let (_, clip) = self.add_scene(
+ leak.name.to_string(),
+ Some(leak.scene.to_string()),
+ LEAK_LENGTH,
+ true,
+ Some((at, track)),
+ cx,
+ );
+ self.say(format!("{} added; its scene can be edited like any other", leak.name));
+ clip
+ }
+
/// The layer just above everything under the playhead for `duration`.
pub(super) fn layer_above(&self, start: Time, duration: Time) -> usize {
self.doc
diff --git a/crates/splice-app/src/editor/stage.rs b/crates/splice-app/src/editor/stage.rs
index 1c28db7..aba7ea6 100644
--- a/crates/splice-app/src/editor/stage.rs
+++ b/crates/splice-app/src/editor/stage.rs
@@ -56,6 +56,10 @@ pub struct Layer {
pub visible: [f32; 4],
/// The part a wipe shows now, the same way.
pub shows: [f32; 4],
+ /// Whether clicking it picks its clip (one piece of a title does not:
+ /// the whole title does), and whether it is drawn at all.
+ pub pick: bool,
+ pub paint: bool,
}
/// What the stage paints, bottom first: pictures, and the colour of a dip.
@@ -138,6 +142,38 @@ impl Editor {
for shown in project.picture(t) {
let (drawn, pose, part) = match shown {
Shown::Clip { drawn, pose, part } => (*drawn, pose, part),
+ // The blurred fill behind a clip: covering the canvas, never picked.
+ Shown::Backdrop {
+ clip,
+ source: Source::Media(media),
+ opacity,
+ } => {
+ let Some(image) =
+ self.player
+ .frame(splice_core::compose::backdrop_id(clip.id), media, true, &self.library)
+ else {
+ continue;
+ };
+ let (mw, mh) = (media.width.max(1) as f64, media.height.max(1) as f64);
+ let cover = (canvas.0 as f64 / mw).max(canvas.1 as f64 / mh);
+ layers.push(Paint::Picture(Layer {
+ clip: splice_core::compose::backdrop_id(clip.id),
+ image: image.clone(),
+ natural: (mw * cover, mh * cover),
+ pose: Pose {
+ x: 0.0,
+ y: 0.0,
+ scale: 1.0,
+ opacity,
+ },
+ visible: [0.0, 0.0, 0.0, 0.0],
+ shows: [0.0, 0.0, 1.0, 1.0],
+ pick: false,
+ paint: true,
+ }));
+ continue;
+ }
+ Shown::Backdrop { .. } => continue,
Shown::Fill { color, opacity, over } => {
layers.push(Paint::Fill {
color,
@@ -148,10 +184,69 @@ impl Editor {
}
};
let (clip, source) = (drawn.clip, drawn.source);
+ // A title coming in a piece at a time: its box, then each piece,
+ // and the whole of it (unseen) to pick it by.
+ if let Source::Generated(Generator::Text(title)) = source
+ && let Some(motion) = &title.animate
+ && let Some(parts) = self.drawings.parts(title, clip.look.mask.as_ref(), canvas)
+ {
+ let natural = (parts.size.0 as f64, parts.size.1 as f64);
+ let whole = |image: Arc, pick: bool, paint: bool| Layer {
+ clip: clip.id,
+ image,
+ natural,
+ pose,
+ visible: visible_part(clip.look.mask.as_ref()),
+ shows: part,
+ pick,
+ paint,
+ };
+ layers.push(Paint::Picture(whole(parts.text.clone(), true, false)));
+ if let Some(back) = &parts.back {
+ layers.push(Paint::Picture(whole(back.clone(), false, true)));
+ }
+ let local = splice_core::time::secs(t - clip.start);
+ let (cw, ch) = (canvas.0 as f64, canvas.1 as f64);
+ for piece in splice_core::textmotion::pieces(
+ motion,
+ &parts.units,
+ &title.timing,
+ natural,
+ (cw, ch),
+ title.size as f64,
+ &drawn.pose,
+ ) {
+ let p = piece.pose.at(local);
+ let [x, y, w, h] = piece.rect.map(|v| v as f64);
+ // The whole picture placed so this piece lands where it goes.
+ let (dx, dy) = (x + w / 2.0 - natural.0 / 2.0, y + h / 2.0 - natural.1 / 2.0);
+ layers.push(Paint::Picture(Layer {
+ clip: clip.id,
+ image: parts.text.clone(),
+ natural,
+ pose: Pose {
+ x: p.x - dx * p.scale / cw,
+ y: p.y - dy * p.scale / ch,
+ ..p
+ },
+ visible: [0.0, 0.0, 1.0, 1.0],
+ // Its part of the title, inside what a wipe shows of the whole.
+ shows: [
+ ((x / natural.0) as f32).max(part[0]),
+ ((y / natural.1) as f32).max(part[1]),
+ (((x + w) / natural.0) as f32).min(part[2]),
+ (((y + h) / natural.1) as f32).min(part[3]),
+ ],
+ pick: false,
+ paint: true,
+ }));
+ }
+ continue;
+ }
let (image, rendered) = match source {
Source::Media(media) => {
- let filters = clip.look.pixel_filters();
- let Some(image) = self.player.frame(clip.id, media, filters.as_deref(), &self.library) else {
+ let filtered = clip.look.has_pixel_filters();
+ let Some(image) = self.player.frame(clip.id, media, filtered, &self.library) else {
continue;
};
(image.clone(), None)
@@ -194,6 +289,8 @@ impl Editor {
v
},
shows: part,
+ pick: true,
+ paint: true,
}));
}
layers
@@ -313,12 +410,16 @@ impl Editor {
let rect = layer_rect(stage, canvas_size, layer.natural, &layer.pose);
// Picked by what shows of it: inside its mask and any wipe.
let shows = sub_rect(rect, layer.shows);
- let picked = sub_rect(rect, layer.visible).intersect(&shows);
- placed.borrow_mut().push((layer.clip, picked, rect.center()));
- let clip_to = stage.intersect(&shows);
- window.with_content_mask(Some(gpui::ContentMask { bounds: clip_to }), |window| {
- let _ = window.paint_image(rect, Corners::default(), layer.image, 0, false);
- });
+ if layer.pick {
+ let picked = sub_rect(rect, layer.visible).intersect(&shows);
+ placed.borrow_mut().push((layer.clip, picked, rect.center()));
+ }
+ if layer.paint {
+ let clip_to = stage.intersect(&shows);
+ window.with_content_mask(Some(gpui::ContentMask { bounds: clip_to }), |window| {
+ let _ = window.paint_image(rect, Corners::default(), layer.image, 0, false);
+ });
+ }
},
)
.size_full(),
@@ -399,6 +500,31 @@ impl Editor {
.absolute()
.inset_0();
+ // Letterbox bars, over every picture as export draws them.
+ let bars = self.doc.project.letterbox_bars().map(|(tb, lr)| {
+ let cell = self.stage_bounds.clone();
+ canvas(
+ |_, _, _| {},
+ move |_, _, window, _| {
+ let stage = cell.get();
+ let k = f32::from(stage.size.width) / canvas_size.0.max(1) as f32;
+ let (tb, lr) = (px(tb as f32 * k), px(lr as f32 * k));
+ for bar in [
+ Bounds::new(stage.origin, size(stage.size.width, tb)),
+ Bounds::new(point(stage.left(), stage.bottom() - tb), size(stage.size.width, tb)),
+ Bounds::new(stage.origin, size(lr, stage.size.height)),
+ Bounds::new(point(stage.right() - lr, stage.top()), size(lr, stage.size.height)),
+ ] {
+ if bar.size.width > px(0.0) && bar.size.height > px(0.0) {
+ window.paint_quad(gpui::fill(bar, gpui::black()));
+ }
+ }
+ },
+ )
+ .absolute()
+ .inset_0()
+ });
+
div()
.id("stage")
.flex_1()
@@ -406,6 +532,7 @@ impl Editor {
.relative()
.child(ground)
.children(pictures)
+ .children(bars)
.child(overlay)
.on_mouse_down(MouseButton::Left, cx.listener(Self::stage_down))
}
diff --git a/crates/splice-app/src/main.rs b/crates/splice-app/src/main.rs
index 19f7b64..a641ba5 100644
--- a/crates/splice-app/src/main.rs
+++ b/crates/splice-app/src/main.rs
@@ -14,6 +14,7 @@ mod pointer;
mod recorder;
mod scenes;
mod session;
+mod speech;
mod symbols;
mod theme;
mod timeline;
diff --git a/crates/splice-app/src/player.rs b/crates/splice-app/src/player.rs
index 6d05908..1920d23 100644
--- a/crates/splice-app/src/player.rs
+++ b/crates/splice-app/src/player.rs
@@ -68,6 +68,26 @@ struct Wanted {
filters: Option,
/// The source time it shows.
source: Time,
+ /// The size its frames are decoded at.
+ size: (u32, u32),
+}
+
+/// How big frames decoded at `size` are against the media's own.
+fn scale_of(size: (u32, u32), media: &Media) -> f32 {
+ size.0 as f32 / media.width.max(1) as f32
+}
+
+/// A backdrop's filters: what the clip hides stays hidden, then the blur.
+fn backdrop_filters(clip: &Clip, size: (u32, u32)) -> String {
+ let (mut filters, after) = clip.look.hiding_filters(size, "b");
+ filters.push(clip.look.backdrop.unwrap_or_default().ffmpeg(after));
+ filters.push("format=rgba".into());
+ filters.join(",")
+}
+
+/// A backdrop's decoded size: small, since it is blurred and scaled up anyway.
+pub fn backdrop_size(full: (u32, u32)) -> (u32, u32) {
+ ((full.0 / 4).max(16) & !1, (full.1 / 4).max(16) & !1)
}
/// What the editor should do after a tick.
@@ -140,16 +160,17 @@ impl Player {
}
}
- /// The image to draw for a media clip at the playhead. Pictures with
- /// no key or mask come straight from the library; the rest are decoded.
+ /// The image to draw for a media clip at the playhead. Pictures whose
+ /// pixels nothing changes (`filtered`) come straight from the library;
+ /// the rest are decoded.
pub fn frame<'a>(
&'a self,
clip: ClipId,
media: &Media,
- filters: Option<&str>,
+ filtered: bool,
library: &'a Library,
) -> Option<&'a Arc> {
- if media.kind == MediaKind::Image && filters.is_none() {
+ if media.kind == MediaKind::Image && !filtered {
return library.get(&media.hash).and_then(|a| a.still.as_ref());
}
self.shown.get(&clip).map(|s| &s.image)
@@ -230,6 +251,13 @@ impl Player {
// colour; each with the source time it shows now (held inside the
// media where a transition draws it past its ends).
let frame = project.frame();
+ let shown_at = |clip: &Clip, m: &Media| {
+ if m.kind == MediaKind::Video {
+ floor_to_frame(clip.source_at(position).clamp(0, (m.duration - frame).max(0)), fps)
+ } else {
+ 0
+ }
+ };
let stack: Vec = project
.picture(position)
.into_iter()
@@ -237,22 +265,39 @@ impl Player {
compose::Shown::Clip { drawn, .. } => match drawn.source {
Source::Media(m) => {
let clip = drawn.clip;
- let filters = clip.look.pixel_filters();
+ let size = self.decode_size((m.width, m.height));
+ let filters = clip.look.pixel_filters(size, scale_of(size, m), "p");
let video = m.kind == MediaKind::Video;
(video || filters.is_some()).then(|| Wanted {
clip: clip.clone(),
media: m.clone(),
filters,
- source: if video {
- floor_to_frame(clip.source_at(position).clamp(0, (m.duration - frame).max(0)), fps)
- } else {
- 0
- },
+ source: shown_at(clip, m),
+ size,
})
}
Source::Generated(_) => None,
},
- compose::Shown::Fill { .. } => None,
+ // A backdrop: the same media, small (it is blurred anyway),
+ // decoded as a clip of its own.
+ compose::Shown::Backdrop {
+ clip,
+ source: Source::Media(m),
+ ..
+ } => {
+ let size = backdrop_size(self.decode_size((m.width, m.height)));
+ Some(Wanted {
+ clip: Clip {
+ id: compose::backdrop_id(clip.id),
+ ..clip.clone()
+ },
+ media: m.clone(),
+ filters: Some(backdrop_filters(clip, size)),
+ source: shown_at(clip, m),
+ size,
+ })
+ }
+ compose::Shown::Backdrop { .. } | compose::Shown::Fill { .. } => None,
})
.collect();
let visible: HashSet = stack.iter().map(|w| w.clip.id).collect();
@@ -263,8 +308,10 @@ impl Player {
media,
filters,
source,
+ size,
} in &stack
{
+ let size = *size;
let source = *source;
// A held frame is a still, playing or not.
if playing && media.kind == MediaKind::Video && !clip.frozen {
@@ -272,7 +319,7 @@ impl Player {
let Some(path) = library.path(&media.hash) else {
continue;
};
- self.ensure_stream(clip, media, filters, path, source, fps);
+ self.ensure_stream(clip, media, filters, path, source, fps, size);
// Half a frame of playback, in source time.
let half = (half_frame as f64 * clip.speed) as Time;
let due = if clip.reverse { source - half } else { source + half };
@@ -280,7 +327,6 @@ impl Player {
self.show(clip.id, media.id, filters.clone(), t, size, image);
}
} else {
- let size = self.decode_size((media.width, media.height));
let current = self
.shown
.get(&clip.id)
@@ -291,14 +337,7 @@ impl Player {
let Some(path) = library.path(&media.hash) else {
continue;
};
- self.request_still(
- clip.id,
- media.id,
- filters.clone(),
- path.to_path_buf(),
- source,
- (media.width, media.height),
- );
+ self.request_still(clip.id, media.id, filters.clone(), path.to_path_buf(), source, size);
}
}
}
@@ -321,9 +360,21 @@ impl Player {
continue;
};
keep_streams.insert(clip.id);
- let filters = clip.look.pixel_filters();
+ let size = self.decode_size((media.width, media.height));
+ let filters = clip.look.pixel_filters(size, scale_of(size, media), "p");
let source = clip.source_at(drawn.from).clamp(0, (media.duration - frame).max(0));
- self.ensure_stream(clip, media, &filters, path, source, fps);
+ self.ensure_stream(clip, media, &filters, path, source, fps, size);
+ // Its backdrop too, so a cut into it is not black at the sides.
+ if clip.look.backdrop.is_some() {
+ let small = backdrop_size(size);
+ let backdrop = Clip {
+ id: compose::backdrop_id(clip.id),
+ ..clip.clone()
+ };
+ keep_streams.insert(backdrop.id);
+ let filters = Some(backdrop_filters(clip, small));
+ self.ensure_stream(&backdrop, media, &filters, path, source, fps, small);
+ }
}
}
}
@@ -375,7 +426,9 @@ impl Player {
ffmpeg::fit(media.0, media.1, self.preview.0.max(64), self.preview.1.max(64))
}
- /// Keeps a stream for `clip` that will reach `source` soon, opening one if not.
+ /// Keeps a stream for `clip` that will reach `source` soon, opening one
+ /// if not, decoding frames of `size`.
+ #[allow(clippy::too_many_arguments)]
fn ensure_stream(
&mut self,
clip: &Clip,
@@ -384,6 +437,7 @@ impl Player {
path: &std::path::Path,
source: Time,
fps: u32,
+ size: (u32, u32),
) {
let (speed, reverse) = (clip.speed, clip.reverse);
if let Some(stream) = self.streams.get(&clip.id) {
@@ -400,7 +454,7 @@ impl Player {
return;
}
}
- let (w, h) = self.decode_size((media.width, media.height));
+ let (w, h) = size;
match FrameStream::open(path, source, fps, w, h, filters.as_deref(), speed, reverse) {
Ok(decoder) => {
self.streams.insert(
@@ -453,7 +507,7 @@ impl Player {
size: (u32, u32),
) {
self.stills.insert(clip);
- let (w, h) = self.decode_size(size);
+ let (w, h) = size;
let tx = self.still_tx.clone();
std::thread::spawn(move || {
let image = ffmpeg::still(&path, source, w, h, filters.as_deref()).ok();
diff --git a/crates/splice-app/src/recorder.rs b/crates/splice-app/src/recorder.rs
index 22632de..9bd1ef2 100644
--- a/crates/splice-app/src/recorder.rs
+++ b/crates/splice-app/src/recorder.rs
@@ -15,6 +15,8 @@ use objc::{class, msg_send, sel, sel_impl};
use serde::Serialize;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
/// What to record.
@@ -154,6 +156,185 @@ pub struct Recording {
pub path: PathBuf,
pub started: Instant,
pub source: Source,
+ /// Watching the mouse for clicks, until told to stop.
+ clicks: Option<(Arc, std::thread::JoinHandle>)>,
+}
+
+/// A click while recording: seconds in, and where, as fractions of what is
+/// recorded from its top left.
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, serde::Deserialize)]
+pub struct Click {
+ pub t: f64,
+ pub x: f64,
+ pub y: f64,
+}
+
+/// The clicks noted beside a take, for its media.
+pub fn read_clicks(take: &std::path::Path) -> Vec {
+ let Ok(text) = std::fs::read_to_string(clicks_file(take)) else {
+ return Vec::new();
+ };
+ let clicks: Vec = serde_json::from_str(&text).unwrap_or_default();
+ clicks
+ .into_iter()
+ .map(|c| splice_core::model::Click {
+ at: splice_core::time::from_secs(c.t),
+ x: c.x as f32,
+ y: c.y as f32,
+ })
+ .collect()
+}
+
+/// Where clicks of a take are kept: beside it.
+pub fn clicks_file(take: &std::path::Path) -> PathBuf {
+ take.with_extension("clicks.json")
+}
+
+#[repr(C)]
+#[derive(Clone, Copy)]
+struct Point {
+ x: f64,
+ y: f64,
+}
+
+#[link(name = "CoreGraphics", kind = "framework")]
+unsafe extern "C" {
+ fn CGEventSourceButtonState(state: u32, button: u32) -> bool;
+ fn CGEventCreate(source: *const std::ffi::c_void) -> *mut std::ffi::c_void;
+ fn CGEventGetLocation(event: *mut std::ffi::c_void) -> Point;
+}
+
+#[link(name = "CoreFoundation", kind = "framework")]
+unsafe extern "C" {
+ fn CFRelease(object: *const std::ffi::c_void);
+}
+
+/// Where the mouse is, in screen points from the main display's top left.
+fn mouse() -> Option<(f64, f64)> {
+ unsafe {
+ let event = CGEventCreate(std::ptr::null());
+ if event.is_null() {
+ return None;
+ }
+ let p = CGEventGetLocation(event);
+ CFRelease(event);
+ Some((p.x, p.y))
+ }
+}
+
+/// Whether the left button is down (no permission needed to ask).
+fn pressed() -> bool {
+ const COMBINED: u32 = 0;
+ const LEFT: u32 = 0;
+ unsafe { CGEventSourceButtonState(COMBINED, LEFT) }
+}
+
+/// The part of the screen `source` records, in screen points from the main
+/// display's top left: x, y, width, height.
+fn area(source: &Source) -> Option<(f64, f64, f64, f64)> {
+ match source {
+ Source::Region { x, y, width, height } => Some((*x as f64, *y as f64, *width as f64, *height as f64)),
+ Source::Display { number } => {
+ #[repr(C)]
+ #[derive(Clone, Copy)]
+ struct Rect {
+ x: f64,
+ y: f64,
+ w: f64,
+ h: f64,
+ }
+ unsafe {
+ let screens: *mut Object = msg_send![class!(NSScreen), screens];
+ let count: usize = msg_send![screens, count];
+ let index = (*number as usize).saturating_sub(1);
+ if count == 0 || index >= count {
+ return None;
+ }
+ let main: *mut Object = msg_send![screens, objectAtIndex: 0usize];
+ let main: Rect = msg_send![main, frame];
+ let screen: *mut Object = msg_send![screens, objectAtIndex: index];
+ let f: Rect = msg_send![screen, frame];
+ // Cocoa counts up from the bottom; the mouse, down from the top.
+ Some((f.x, main.h - (f.y + f.h), f.w, f.h))
+ }
+ }
+ Source::Window { id } => window_bounds(*id),
+ }
+}
+
+/// A window's place on screen, in points from the main display's top left.
+fn window_bounds(id: u32) -> Option<(f64, f64, f64, f64)> {
+ const ON_SCREEN: u32 = 1;
+ let raw = unsafe { CGWindowListCopyWindowInfo(ON_SCREEN, 0) };
+ if raw.is_null() {
+ return None;
+ }
+ let list: CFArray> = unsafe { CFArray::wrap_under_create_rule(raw) };
+ for info in list.iter() {
+ let number = info
+ .find(CFString::new("kCGWindowNumber"))
+ .and_then(|v| v.downcast::())
+ .and_then(|n| n.to_i64());
+ if number != Some(id as i64) {
+ continue;
+ }
+ let bounds = info
+ .find(CFString::new("kCGWindowBounds"))
+ .and_then(|v| v.downcast::())?;
+ let bounds: CFDictionary =
+ unsafe { CFDictionary::wrap_under_get_rule(bounds.as_concrete_TypeRef()) };
+ let side = |k: &str| {
+ bounds
+ .find(CFString::new(k))
+ .and_then(|v| v.downcast::())
+ .and_then(|n| n.to_f64())
+ .unwrap_or(0.0)
+ };
+ return Some((side("X"), side("Y"), side("Width"), side("Height")));
+ }
+ None
+}
+
+/// screencapture starts recording about this long after it is started.
+const LATE: f64 = 0.2;
+
+/// Clicks in the recording's own time, without the click that stopped it
+/// (the Stop button, or Splice in the Dock) in its last half second.
+fn settle(clicks: Vec, ended: f64) -> Vec {
+ clicks
+ .into_iter()
+ .filter(|c| c.t < ended - 0.5)
+ .map(|c| Click {
+ t: (c.t - LATE).max(0.0),
+ ..c
+ })
+ .collect()
+}
+
+/// Watches for clicks inside `area` until `stop`, from `started`.
+fn watch(area: (f64, f64, f64, f64), started: Instant, stop: Arc) -> Vec {
+ let (ax, ay, aw, ah) = area;
+ let mut clicks = Vec::new();
+ let mut was = pressed();
+ while !stop.load(Ordering::Relaxed) {
+ let now = pressed();
+ if now
+ && !was
+ && let Some((x, y)) = mouse()
+ {
+ let (u, v) = ((x - ax) / aw.max(1.0), (y - ay) / ah.max(1.0));
+ if (0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v) {
+ clicks.push(Click {
+ t: started.elapsed().as_secs_f64(),
+ x: u,
+ y: v,
+ });
+ }
+ }
+ was = now;
+ std::thread::sleep(std::time::Duration::from_millis(8));
+ }
+ clicks
}
impl Recording {
@@ -166,13 +347,27 @@ impl Recording {
matches!(self.child.try_wait(), Ok(Some(_)))
}
- /// Stops it and waits for the file to be finished. Blocking (a second or two).
+ /// Stops it and waits for the file to be finished, with its clicks
+ /// beside it. Blocking (a second or two).
pub fn stop(mut self) -> Result {
if !self.finished() {
unsafe {
libc_kill(self.child.id() as i32, 2);
}
}
+ if let Some((stop, watcher)) = self.clicks.take() {
+ let ended = self.started.elapsed().as_secs_f64();
+ stop.store(true, Ordering::Relaxed);
+ if let Ok(clicks) = watcher.join()
+ && let clicks = settle(clicks, ended)
+ && !clicks.is_empty()
+ {
+ let _ = std::fs::write(
+ clicks_file(&self.path),
+ serde_json::to_string(&clicks).unwrap_or_default(),
+ );
+ }
+ }
let output = self.child.wait_with_output()?;
let ok = std::fs::metadata(&self.path).is_ok_and(|m| m.len() > 0);
if !ok {
@@ -246,11 +441,23 @@ pub fn start(source: Source, options: &Options) -> Result {
.stderr(Stdio::piped())
.spawn()
.context("starting screencapture")?;
+ let started = Instant::now();
+ // Clicks are noted for zooming in on later.
+ let clicks = area(&source).and_then(|area| {
+ let stop = Arc::new(AtomicBool::new(false));
+ let flag = stop.clone();
+ std::thread::Builder::new()
+ .name("splice-clicks".into())
+ .spawn(move || watch(area, started, flag))
+ .ok()
+ .map(|watcher| (stop, watcher))
+ });
Ok(Recording {
child,
path,
- started: Instant::now(),
+ started,
source,
+ clicks,
})
}
@@ -258,6 +465,21 @@ pub fn start(source: Source, options: &Options) -> Result {
mod tests {
use super::*;
+ #[test]
+ fn the_click_that_stops_a_recording_is_not_kept() {
+ let clicks = vec![
+ Click { t: 1.0, x: 0.5, y: 0.5 },
+ Click {
+ t: 9.8,
+ x: 0.9,
+ y: 0.99,
+ },
+ ];
+ let kept = settle(clicks, 10.0);
+ assert_eq!(kept.len(), 1);
+ assert!((kept[0].t - 0.8).abs() < 1e-9, "in the recording's own time");
+ }
+
#[test]
fn lists_displays_and_windows() {
// A machine without a screen (a CI runner) may list none; listing must
diff --git a/crates/splice-app/src/scenes.rs b/crates/splice-app/src/scenes.rs
index 58f2c2d..918b19d 100644
--- a/crates/splice-app/src/scenes.rs
+++ b/crates/splice-app/src/scenes.rs
@@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio;
use tiny_skia::{
Color, FillRule, GradientStop, LineCap, LineJoin, LinearGradient, Paint as Brush, PathBuilder, Pixmap, PixmapPaint,
- Point, Rect, Shader, SpreadMode, Stroke, StrokeDash, Transform,
+ Point, RadialGradient, Rect, Shader, SpreadMode, Stroke, StrokeDash, Transform,
};
/// Renders by this painter name the same bytes.
@@ -365,13 +365,26 @@ fn gradient(g: &motion::Gradient, w: f32, h: f32, opacity: f64) -> Brush<'static
)
})
.collect();
- let shader = LinearGradient::new(
- Point::from_xy(-dx, -dy),
- Point::from_xy(dx, dy),
- stops,
- SpreadMode::Pad,
- Transform::identity(),
- )
+ let shader = if g.radial {
+ // A circle the shape's width across, squashed to its height.
+ let r = (w / 2.0).max(0.5);
+ RadialGradient::new(
+ Point::from_xy(0.0, 0.0),
+ Point::from_xy(0.0, 0.0),
+ r,
+ stops,
+ SpreadMode::Pad,
+ Transform::from_scale(1.0, (h / w.max(1.0)).max(0.01)),
+ )
+ } else {
+ LinearGradient::new(
+ Point::from_xy(-dx, -dy),
+ Point::from_xy(dx, dy),
+ stops,
+ SpreadMode::Pad,
+ Transform::identity(),
+ )
+ }
.unwrap_or(Shader::SolidColor(Color::WHITE));
Brush {
shader,
diff --git a/crates/splice-app/src/speech.rs b/crates/splice-app/src/speech.rs
new file mode 100644
index 0000000..373e975
--- /dev/null
+++ b/crates/splice-app/src/speech.rs
@@ -0,0 +1,190 @@
+//! Hearing what is said in a clip: whisper.cpp's `whisper-cli` on this Mac,
+//! with its speech model (downloaded once, checked), giving each word and
+//! when it is said. Nothing leaves the machine.
+
+use crate::ffmpeg;
+use crate::session::dirs;
+use anyhow::{Context, Result, bail};
+use sha2::{Digest, Sha256};
+use splice_core::time::{Time, secs};
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+use std::sync::Mutex;
+
+const MODEL_URL: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin";
+const MODEL_SHA256: &str = "60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe";
+pub const MODEL_SIZE_MB: u32 = 148;
+
+pub fn model_path() -> PathBuf {
+ dirs().data.join("models").join("ggml-base.bin")
+}
+
+pub fn has_model() -> bool {
+ model_path().exists()
+}
+
+/// The recognizer, if installed.
+pub fn whisper() -> Option {
+ ffmpeg::tool("whisper-cli")
+}
+
+/// What to say when the recognizer is missing.
+pub const INSTALL: &str = "Captions need whisper.cpp, which hears speech on this Mac: brew install whisper-cpp";
+
+/// Downloads the speech model if it is not here yet, checking it is the one
+/// expected before keeping it. Blocking.
+pub fn fetch_model() -> Result {
+ static FETCH: Mutex<()> = Mutex::new(());
+ let _one_at_a_time = FETCH.lock();
+ let path = model_path();
+ if path.exists() {
+ return Ok(path);
+ }
+ let dir = path.parent().expect("a folder");
+ std::fs::create_dir_all(dir)?;
+ let partial = dir.join("download.part");
+ let status = Command::new("curl")
+ .args(["--fail", "--silent", "--show-error", "--location", "--output"])
+ .arg(&partial)
+ .arg(MODEL_URL)
+ .status()
+ .context("running curl")?;
+ if !status.success() {
+ bail!("could not download the speech model (curl {status})");
+ }
+ let mut hasher = Sha256::new();
+ let mut file = std::fs::File::open(&partial)?;
+ std::io::copy(&mut file, &mut hasher)?;
+ let hex: String = hasher.finalize().iter().map(|b| format!("{b:02x}")).collect();
+ if hex != MODEL_SHA256 {
+ let _ = std::fs::remove_file(&partial);
+ bail!("the downloaded speech model did not match its checksum");
+ }
+ std::fs::rename(&partial, &path)?;
+ Ok(path)
+}
+
+/// A word heard, and when (from the start of what was heard).
+#[derive(Clone, Debug, PartialEq)]
+pub struct Heard {
+ pub text: String,
+ pub start: Time,
+ pub end: Time,
+}
+
+/// The words said in `length` of `path` from `from`. Blocking: about a
+/// second for every ten of speech.
+pub fn transcribe(path: &Path, from: Time, length: Time) -> Result> {
+ let whisper = whisper().context(INSTALL)?;
+ let model = fetch_model()?;
+ // Its own folder: two clips can be heard at once.
+ static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+ let run = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let work = std::env::temp_dir().join(format!("splice-speech-{}-{run}", std::process::id()));
+ std::fs::create_dir_all(&work)?;
+ let wav = work.join("speech.wav");
+ // 16 kHz mono, as whisper hears.
+ let status = ffmpeg::export_command()?
+ .args(["-hide_banner", "-nostdin", "-y", "-loglevel", "error"])
+ .args([
+ "-ss",
+ &format!("{:.6}", secs(from)),
+ "-t",
+ &format!("{:.6}", secs(length)),
+ "-i",
+ ])
+ .arg(path)
+ .args(["-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le"])
+ .arg(&wav)
+ .status()?;
+ if !status.success() {
+ let _ = std::fs::remove_dir_all(&work);
+ bail!("could not read the sound to hear it");
+ }
+ let output = Command::new(whisper)
+ .arg("-m")
+ .arg(&model)
+ .arg("-f")
+ .arg(&wav)
+ .args(["-l", "auto", "-ml", "1", "-sow", "-oj", "-np", "-of"])
+ .arg(work.join("words"))
+ .stdout(Stdio::null())
+ .stderr(Stdio::piped())
+ .output()
+ .context("running whisper-cli")?;
+ let json = std::fs::read_to_string(work.join("words.json"));
+ let _ = std::fs::remove_dir_all(&work);
+ if !output.status.success() {
+ let text = String::from_utf8_lossy(&output.stderr);
+ bail!("whisper could not hear it: {}", text.lines().last().unwrap_or(""));
+ }
+ parse(&json?)
+}
+
+/// whisper-cli's JSON, one segment a word (`-ml 1 -sow`), as words.
+fn parse(json: &str) -> Result> {
+ let value: serde_json::Value = serde_json::from_str(json)?;
+ let segments = value["transcription"].as_array().cloned().unwrap_or_default();
+ Ok(segments
+ .iter()
+ .filter_map(|s| {
+ let text = s["text"].as_str()?.trim().trim_matches('"').to_string();
+ // Whisper marks what it cannot make out in brackets: [MUSIC], (laughs).
+ let noise = text.starts_with('[') || text.starts_with('(');
+ let (a, z) = (s["offsets"]["from"].as_i64()?, s["offsets"]["to"].as_i64()?);
+ (!text.is_empty() && !noise).then_some(Heard {
+ text,
+ start: a * 1000,
+ end: z.max(a) * 1000,
+ })
+ })
+ .collect())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn reads_whisper_words() {
+ let json = r#"{"transcription":[
+ {"offsets":{"from":0,"to":250},"text":""},
+ {"offsets":{"from":250,"to":350},"text":" Almost"},
+ {"offsets":{"from":2830,"to":3650},"text":" \"AI\""},
+ {"offsets":{"from":4000,"to":4500},"text":" [MUSIC]"}]}"#;
+ let words = parse(json).unwrap();
+ assert_eq!(words.len(), 2);
+ assert_eq!(
+ words[0],
+ Heard {
+ text: "Almost".into(),
+ start: 250_000,
+ end: 350_000
+ }
+ );
+ assert_eq!(words[1].text, "AI");
+ }
+
+ #[test]
+ fn hears_speech_when_whisper_is_here() {
+ if whisper().is_none() || !has_model() || !ffmpeg::available() {
+ return;
+ }
+ let dir = std::env::temp_dir().join(format!("splice-hear-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let speech = dir.join("speech.aiff");
+ let said = Command::new("say")
+ .arg("-o")
+ .arg(&speech)
+ .arg("Hello there, this is a test.")
+ .status();
+ if !said.is_ok_and(|s| s.success()) {
+ return;
+ }
+ let words = transcribe(&speech, 0, 5 * splice_core::time::SECOND).unwrap();
+ let text: Vec = words.iter().map(|w| w.text.to_lowercase()).collect();
+ assert!(text.iter().any(|w| w.starts_with("hello")), "{text:?}");
+ assert!(words.windows(2).all(|p| p[0].start <= p[1].start));
+ let _ = std::fs::remove_dir_all(dir);
+ }
+}
diff --git a/crates/splice-app/src/titles.rs b/crates/splice-app/src/titles.rs
index f82c057..0eafd1d 100644
--- a/crates/splice-app/src/titles.rs
+++ b/crates/splice-app/src/titles.rs
@@ -6,6 +6,7 @@ use cosmic_text::{Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, St
use splice_core::export::{Rendered, Resolve};
use splice_core::look::Mask;
use splice_core::model::{Align, Clip, Generator, Media, Title};
+use splice_core::textmotion::{Unit, Units};
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
@@ -83,6 +84,20 @@ impl Picture {
self.rgba[i + 3] = (out_a * 255.0).round() as u8;
}
+ /// Lays `top` (the same size) over this picture.
+ fn over(&mut self, top: &Picture) {
+ for y in 0..self.height.min(top.height) {
+ for x in 0..self.width.min(top.width) {
+ let i = ((y * top.width + x) * 4) as usize;
+ let px = [top.rgba[i], top.rgba[i + 1], top.rgba[i + 2], 255];
+ let coverage = top.rgba[i + 3] as f32 / 255.0;
+ if coverage > 0.0 {
+ self.blend(x as i32, y as i32, px, coverage);
+ }
+ }
+ }
+ }
+
fn apply_mask(&mut self, mask: &Mask) {
let (w, h) = (self.width as f32, self.height as f32);
for y in 0..self.height {
@@ -136,7 +151,28 @@ pub fn draw(generator: &Generator, mask: Option<&Mask>, canvas: (u32, u32)) -> P
picture
}
+/// A title drawn in parts, for one that comes in a piece at a time: its box
+/// alone, its letters (and their shadow) alone, and where each word, letter
+/// and line is among them. Both pictures are the size of the whole title.
+pub struct Parts {
+ pub back: Option,
+ pub text: Picture,
+ pub units: Units,
+}
+
fn draw_title(title: &Title, canvas: (u32, u32)) -> Picture {
+ let parts = draw_title_parts(title, canvas);
+ match parts.back {
+ Some(mut back) => {
+ back.over(&parts.text);
+ back
+ }
+ None => parts.text,
+ }
+}
+
+/// Draws a title as its parts (see `Parts`).
+pub fn draw_title_parts(title: &Title, canvas: (u32, u32)) -> Parts {
let size = (title.size * canvas.1 as f32).clamp(4.0, 2000.0);
let line = size * 1.18;
let text = if title.text.trim().is_empty() {
@@ -183,10 +219,67 @@ fn draw_title(title: &Title, canvas: (u32, u32)) -> Picture {
let shadow = if title.shadow { size * 0.06 } else { 0.0 };
let width = (widest + pad * 2.0 + shadow * 2.0).ceil() as u32;
let full_height = (height + pad * 2.0 + shadow * 2.0).ceil() as u32;
+ let mut back = Picture::new(width.max(2), full_height.max(2));
let mut picture = Picture::new(width.max(2), full_height.max(2));
let origin = (pad + shadow, pad + shadow);
+ // Where each word, letter and line is, with room for its shadow.
+ let units = {
+ let margin = if title.shadow { size * 0.14 } else { size * 0.05 };
+ let (pw, ph) = (picture.width as f32, picture.height as f32);
+ let rect = |x0: f32, x1: f32, top: f32, height: f32| -> [u32; 4] {
+ let left = (origin.0 + x0 - margin).clamp(0.0, pw - 1.0);
+ let right = (origin.0 + x1 + margin).clamp(left + 1.0, pw);
+ let top_px = (origin.1 + top - margin).clamp(0.0, ph - 1.0);
+ let bottom = (origin.1 + top + height + margin).clamp(top_px + 1.0, ph);
+ [
+ left.floor() as u32,
+ top_px.floor() as u32,
+ (right - left.floor()).ceil() as u32,
+ (bottom - top_px.floor()).ceil() as u32,
+ ]
+ };
+ let mut units = Units::default();
+ let mut word = 0usize;
+ for run in buffer.layout_runs() {
+ let mut line: Option<(f32, f32, usize)> = None;
+ let mut current: Option<(f32, f32)> = None;
+ let close = |current: &mut Option<(f32, f32)>, units: &mut Units, word: &mut usize| {
+ if let Some((a, z)) = current.take() {
+ units.words.push(Unit {
+ rect: rect(a, z, run.line_top, run.line_height),
+ word: *word,
+ });
+ *word += 1;
+ }
+ };
+ for glyph in run.glyphs.iter() {
+ let blank = run.text.get(glyph.start..glyph.end).is_none_or(|t| t.trim().is_empty());
+ if blank {
+ close(&mut current, &mut units, &mut word);
+ continue;
+ }
+ let (a, z) = (glyph.x, glyph.x + glyph.w);
+ current = Some(current.map_or((a, z), |(ca, cz)| (ca.min(a), cz.max(z))));
+ units.letters.push(Unit {
+ rect: rect(a, z, run.line_top, run.line_height),
+ word,
+ });
+ line = Some(line.map_or((a, z, word), |(la, lz, lw)| (la.min(a), lz.max(z), lw)));
+ }
+ close(&mut current, &mut units, &mut word);
+ if let Some((a, z, first)) = line {
+ units.lines.push(Unit {
+ rect: rect(a, z, run.line_top, run.line_height),
+ word: first,
+ });
+ }
+ }
+ units
+ };
+
if let Some(background) = title.background {
+ let picture = &mut back;
let (x0, y0) = (shadow, shadow);
let (x1, y1) = (width as f32 - shadow, full_height as f32 - shadow);
let radius = size * 0.22;
@@ -253,7 +346,11 @@ fn draw_title(title: &Title, canvas: (u32, u32)) -> Picture {
}
},
);
- picture
+ Parts {
+ back: title.background.is_some().then_some(back),
+ text: picture,
+ units,
+ }
}
/// Text alone, tightly drawn: for scenes. Lines wrap at `wrap` pixels.
@@ -369,10 +466,58 @@ pub fn png(
Ok((path, picture.width, picture.height))
}
+/// A title's parts as PNGs in `dir` (made once and kept), and its pieces.
+pub fn png_parts(
+ dir: &Path,
+ title: &Title,
+ mask: Option<&Mask>,
+ canvas: (u32, u32),
+) -> anyhow::Result {
+ let key = key(&Generator::Text(title.clone()), mask, canvas);
+ let (back, text) = (
+ dir.join(format!("{key:016x}-back.png")),
+ dir.join(format!("{key:016x}-text.png")),
+ );
+ std::fs::create_dir_all(dir)?;
+ let mut parts = draw_title_parts(title, canvas);
+ if let Some(mask) = mask {
+ parts.text.apply_mask(mask);
+ if let Some(b) = &mut parts.back {
+ b.apply_mask(mask);
+ }
+ }
+ if !text.exists() {
+ parts.text.save_png(&text)?;
+ }
+ let back = match &parts.back {
+ Some(picture) => {
+ if !back.exists() {
+ picture.save_png(&back)?;
+ }
+ Some(back)
+ }
+ None => None,
+ };
+ Ok(splice_core::export::TitleParts {
+ back,
+ text,
+ units: parts.units,
+ })
+}
+
+/// A title drawn in parts for the viewer.
+pub struct Shown {
+ pub back: Option>,
+ pub text: std::sync::Arc,
+ pub units: Units,
+ pub size: (u32, u32),
+}
+
/// Drawn titles and colours for the viewer, kept while they are on screen.
#[derive(Default)]
pub struct Drawings {
map: std::collections::HashMap,
+ parts: std::collections::HashMap, u64)>,
frame: u64,
}
@@ -408,21 +553,58 @@ impl Drawings {
Some((drawn.image.clone(), drawn.size))
}
+ /// A title drawn in parts (one that comes in a piece at a time).
+ pub fn parts(&mut self, title: &Title, mask: Option<&Mask>, canvas: (u32, u32)) -> Option> {
+ let key = key(&Generator::Text(title.clone()), mask, canvas);
+ if !self.parts.contains_key(&key) {
+ let mut parts = draw_title_parts(title, canvas);
+ if let Some(mask) = mask {
+ parts.text.apply_mask(mask);
+ if let Some(b) = &mut parts.back {
+ b.apply_mask(mask);
+ }
+ }
+ let image = |p: &Picture| crate::ffmpeg::render_image(p.width, p.height, p.bgra());
+ let shown = Shown {
+ back: parts.back.as_ref().and_then(image),
+ text: image(&parts.text)?,
+ units: parts.units,
+ size: (parts.text.width, parts.text.height),
+ };
+ self.parts.insert(key, (std::sync::Arc::new(shown), self.frame));
+ }
+ let entry = self.parts.get_mut(&key)?;
+ entry.1 = self.frame;
+ Some(entry.0.clone())
+ }
+
/// Ends a frame: pictures unused for a while are handed back for release.
pub fn sweep(&mut self) -> Vec> {
self.frame += 1;
let frame = self.frame;
+ let stale_parts: Vec = self
+ .parts
+ .iter()
+ .filter(|(_, (_, used))| frame - used > 90)
+ .map(|(k, _)| *k)
+ .collect();
+ let mut released = Vec::new();
+ for k in stale_parts {
+ if let Some((shown, _)) = self.parts.remove(&k)
+ && let Ok(shown) = std::sync::Arc::try_unwrap(shown)
+ {
+ released.push(shown.text);
+ released.extend(shown.back);
+ }
+ }
let stale: Vec = self
.map
.iter()
.filter(|(_, d)| frame - d.used > 90)
.map(|(k, _)| *k)
.collect();
- stale
- .into_iter()
- .filter_map(|k| self.map.remove(&k))
- .map(|d| d.image)
- .collect()
+ released.extend(stale.into_iter().filter_map(|k| self.map.remove(&k)).map(|d| d.image));
+ released
}
}
@@ -440,8 +622,25 @@ impl Option> Resolve for Resolver {
}
fn generated(&self, clip: &Clip, generator: &Generator) -> Option {
- match png(&self.dir, generator, clip.look.mask.as_ref(), self.canvas) {
- Ok((path, width, height)) => Some(Rendered { path, width, height }),
+ let mask = clip.look.mask.as_ref();
+ // A title coming in a piece at a time: its box and letters apart too.
+ let parts = match generator {
+ Generator::Text(title) if title.animate.is_some() => match png_parts(&self.dir, title, mask, self.canvas) {
+ Ok(parts) => Some(parts),
+ Err(e) => {
+ eprintln!("splice: could not draw {}: {e}", generator.name());
+ return None;
+ }
+ },
+ _ => None,
+ };
+ match png(&self.dir, generator, mask, self.canvas) {
+ Ok((path, width, height)) => Some(Rendered {
+ path,
+ width,
+ height,
+ parts,
+ }),
Err(e) => {
eprintln!("splice: could not draw {}: {e}", generator.name());
None
diff --git a/crates/splice-core/src/captions.rs b/crates/splice-core/src/captions.rs
new file mode 100644
index 0000000..0b21a58
--- /dev/null
+++ b/crates/splice-core/src/captions.rs
@@ -0,0 +1,459 @@
+//! Captions: words heard in a clip, with when each is said, laid out as
+//! titles along the timeline. A line holds a few words, breaks at the end
+//! of a sentence or a pause, and lights each word up as it is said.
+
+use crate::look::Look;
+use crate::model::{ClipId, Generator, Project, Title};
+use crate::textmotion::{By, Style, TextMotion};
+use crate::time::{SECOND, Time};
+
+/// A word and when it is said, in timeline time.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Word {
+ pub text: String,
+ pub start: Time,
+ pub end: Time,
+}
+
+/// How captions read.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Layout {
+ /// The most words on one line.
+ pub words: usize,
+ /// The most characters on one line.
+ pub chars: usize,
+ /// Light words up as they are said (else each line appears whole).
+ pub karaoke: bool,
+}
+
+impl Default for Layout {
+ fn default() -> Layout {
+ Layout {
+ words: 6,
+ chars: 36,
+ karaoke: true,
+ }
+ }
+}
+
+/// A pause this long or longer starts a new line.
+const PAUSE: Time = SECOND * 7 / 10;
+/// A line stays up at least this long.
+const SHORTEST: Time = SECOND * 7 / 10;
+
+/// Words grouped into lines: each sentence (or stretch between pauses)
+/// split into as few lines as fit `layout.words` words and `layout.chars`
+/// characters, its words shared evenly, so no line is left with one word.
+pub fn lines(words: &[Word], layout: &Layout) -> Vec> {
+ let words = tidy(words);
+ // Phrases first: a sentence ends, or the speaker pauses.
+ let mut phrases: Vec> = Vec::new();
+ let mut phrase: Vec = Vec::new();
+ for word in words.iter().filter(|w| !w.text.trim().is_empty()) {
+ let paused = phrase.last().is_some_and(|last| word.start - last.end >= PAUSE);
+ let ended = phrase
+ .last()
+ .is_some_and(|last| last.text.trim_end().ends_with(['.', '?', '!', ':', ';']));
+ if !phrase.is_empty() && (paused || ended) {
+ phrases.push(std::mem::take(&mut phrase));
+ }
+ phrase.push(Word {
+ text: word.text.trim().to_string(),
+ ..word.clone()
+ });
+ }
+ if !phrase.is_empty() {
+ phrases.push(phrase);
+ }
+ let mut out = Vec::new();
+ for phrase in phrases {
+ // Unspaced writing (Chinese, Japanese) runs about twice as wide a character.
+ let chars: usize = phrase
+ .iter()
+ .map(|w| w.text.chars().map(|c| if unspaced(c) { 2 } else { 1 }).sum::() + 1)
+ .sum();
+ let count = phrase
+ .len()
+ .div_ceil(layout.words.max(1))
+ .max(chars.div_ceil(layout.chars.max(8)))
+ .clamp(1, phrase.len());
+ // Words shared out evenly, the earlier lines taking any extra.
+ let (each, extra) = (phrase.len() / count, phrase.len() % count);
+ let mut rest = phrase.into_iter();
+ for i in 0..count {
+ let take = each + usize::from(i < extra);
+ out.push(rest.by_ref().take(take).collect());
+ }
+ }
+ out
+}
+
+/// Whether a character is written without spaces between words (Chinese,
+/// Japanese, Thai): a line of it is cut by characters instead.
+fn unspaced(c: char) -> bool {
+ matches!(c as u32, 0x3040..=0x30ff | 0x3400..=0x4dbf | 0x4e00..=0x9fff | 0xf900..=0xfaff | 0x0e00..=0x0e7f | 0xac00..=0xd7af)
+}
+
+/// Words in order, each starting after the last and ending after it
+/// starts; a long run of unspaced writing (heard as one "word") cut into
+/// pieces of a few characters, its time shared out.
+fn tidy(words: &[Word]) -> Vec {
+ let mut sorted: Vec = words.iter().filter(|w| !w.text.trim().is_empty()).cloned().collect();
+ sorted.sort_by_key(|w| w.start);
+ let mut out: Vec = Vec::new();
+ for word in sorted {
+ let text = word.text.trim().to_string();
+ let chars: Vec = text.chars().collect();
+ if chars.len() > 8 && chars.iter().filter(|c| unspaced(**c)).count() * 2 > chars.len() {
+ let n = chars.len().div_ceil(6);
+ let span = (word.end - word.start).max(0);
+ for (i, piece) in chars.chunks(chars.len().div_ceil(n)).enumerate() {
+ let k = piece.len() as Time;
+ let at = word.start + span * (i as Time * 6).min(chars.len() as Time) / chars.len().max(1) as Time;
+ out.push(Word {
+ text: piece.iter().collect(),
+ start: at,
+ end: at + span * k / chars.len().max(1) as Time,
+ });
+ }
+ } else {
+ out.push(Word { text, ..word });
+ }
+ }
+ // Starts that never run backwards or tie, ends after starts.
+ let mut last = Time::MIN;
+ for w in &mut out {
+ w.start = w.start.max(last + 1);
+ w.end = w.end.max(w.start + 1);
+ last = w.start;
+ }
+ out
+}
+
+/// Words joined as written: spaced, except within unspaced writing.
+fn join(line: &[Word]) -> String {
+ let mut text = String::new();
+ for w in line {
+ let glued = text.chars().last().is_some_and(unspaced) && w.text.chars().next().is_some_and(unspaced);
+ if !text.is_empty() && !glued {
+ text.push(' ');
+ }
+ text.push_str(&w.text);
+ }
+ text
+}
+
+/// The title a caption line becomes, and where it sits: big, light, on a
+/// dark box, low in the frame.
+pub fn style(canvas: (u32, u32)) -> (Title, Look) {
+ let portrait = canvas.1 > canvas.0;
+ (
+ Title {
+ text: String::new(),
+ font: "Avenir Next".into(),
+ size: if portrait { 0.034 } else { 0.052 },
+ bold: true,
+ italic: false,
+ color: 0xffffffff,
+ align: crate::model::Align::Center,
+ background: Some(0x000000b3),
+ shadow: false,
+ animate: None,
+ timing: Vec::new(),
+ },
+ Look {
+ y: if portrait { 0.28 } else { 0.36 },
+ ..Look::default()
+ },
+ )
+}
+
+impl Project {
+ /// Lays captions over the movie, on a new track on top: each line a
+ /// title from its first word to just before the next line. Returns them.
+ pub fn add_captions(&mut self, words: &[Word], layout: &Layout) -> Vec {
+ let (base, look) = style((self.settings.width, self.settings.height));
+ // No longer than fits across the canvas at the caption's size (a
+ // bold letter is about 0.6 of its size wide), with a margin.
+ let (w, h) = (self.settings.width as f32, self.settings.height as f32);
+ let fits = ((w * 0.86) / (base.size * h * 0.6)).floor().max(8.0) as usize;
+ let layout = Layout {
+ chars: layout.chars.min(fits),
+ words: layout.words.min((fits / 5).max(2)),
+ ..*layout
+ };
+ let mut lines = lines(words, &layout);
+ // A line starting within two frames of the one before joins it, so
+ // none is too short to show (or would have to go on another track).
+ let frame = self.frame();
+ let mut kept: Vec> = Vec::new();
+ for line in lines.drain(..) {
+ match kept.last_mut() {
+ Some(before) if line[0].start - before[0].start < 2 * frame => before.extend(line),
+ _ => kept.push(line),
+ }
+ }
+ let lines = kept;
+ let layout = &layout;
+ let track = self.tracks.len();
+ let mut ids = Vec::new();
+ for (i, line) in lines.iter().enumerate() {
+ let start = line[0].start.max(0);
+ let said = line.last().map_or(start, |w| w.end);
+ // Up until the next line (a short gap closes), never overlapping it.
+ let next = lines.get(i + 1).map_or(Time::MAX, |l| l[0].start);
+ let end = if next - said < SECOND / 2 {
+ next
+ } else {
+ said + SECOND / 5
+ };
+ let end = end.max(start + SHORTEST).min(next).max(start + frame);
+ let title = Title {
+ text: join(line),
+ animate: layout.karaoke.then_some(TextMotion {
+ style: Style::Karaoke,
+ by: By::Word,
+ over: SECOND,
+ }),
+ timing: if layout.karaoke {
+ line.iter().map(|w| (w.start - start).max(0)).collect()
+ } else {
+ Vec::new()
+ },
+ ..base.clone()
+ };
+ let id = self.insert_generated(Generator::Text(title), track, start, end - start);
+ self.set_look(id, |l| *l = look.clone());
+ ids.push(id);
+ }
+ ids
+ }
+}
+
+/// Reads SubRip (.srt) or WebVTT (.vtt) captions: (start, end, words).
+pub fn parse_subtitles(text: &str) -> Vec<(Time, Time, String)> {
+ let stamp = |s: &str| -> Option {
+ let s = s.trim().replace(',', ".");
+ let mut parts: Vec<&str> = s.split(':').collect();
+ if parts.len() == 2 {
+ parts.insert(0, "0");
+ }
+ let [h, m, sec] = parts[..] else { return None };
+ let seconds = h.parse::().ok()? * 3600.0 + m.parse::().ok()? * 60.0 + sec.parse::().ok()?;
+ Some(crate::time::from_secs(seconds))
+ };
+ let mut out = Vec::new();
+ let text = text.replace("\r\n", "\n");
+ for block in text.split("\n\n") {
+ let mut lines = block.lines().filter(|l| !l.trim().is_empty());
+ let Some(timing) = lines.by_ref().find(|l| l.contains("-->")) else {
+ continue;
+ };
+ let Some((a, z)) = timing.split_once("-->") else {
+ continue;
+ };
+ // A cue's settings (VTT) follow its end time.
+ let z = z.split_whitespace().next().unwrap_or("");
+ let (Some(a), Some(z)) = (stamp(a), stamp(z)) else {
+ continue;
+ };
+ let words: Vec = lines
+ .map(|l| {
+ // Without tags (, , ).
+ let mut plain = String::new();
+ let mut inside = false;
+ for c in l.chars() {
+ match c {
+ '<' => inside = true,
+ '>' => inside = false,
+ c if !inside => plain.push(c),
+ _ => {}
+ }
+ }
+ plain.trim().to_string()
+ })
+ .filter(|l| !l.is_empty())
+ .collect();
+ if z > a && !words.is_empty() {
+ out.push((a, z, words.join("\n")));
+ }
+ }
+ out
+}
+
+impl Project {
+ /// Lays captions read from a subtitle file over the movie, on a new track
+ /// on top, each line appearing whole. Returns them.
+ pub fn add_subtitles(&mut self, cues: &[(Time, Time, String)]) -> Vec {
+ let (base, look) = style((self.settings.width, self.settings.height));
+ let mut cues = cues.to_vec();
+ cues.sort_by_key(|c| c.0);
+ let track = self.tracks.len();
+ let mut ids = Vec::new();
+ let mut last_end = 0;
+ for (a, z, words) in &cues {
+ let start = (*a).max(last_end);
+ if *z <= start {
+ continue;
+ }
+ let title = Title {
+ text: words.clone(),
+ ..base.clone()
+ };
+ let id = self.insert_generated(Generator::Text(title), track, start, *z - start);
+ self.set_look(id, |l| *l = look.clone());
+ last_end = *z;
+ ids.push(id);
+ }
+ ids
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn said(words: &[(&str, f64, f64)]) -> Vec {
+ words
+ .iter()
+ .map(|(t, a, z)| Word {
+ text: t.to_string(),
+ start: crate::time::from_secs(*a),
+ end: crate::time::from_secs(*z),
+ })
+ .collect()
+ }
+
+ #[test]
+ fn lines_break_at_sentences_pauses_and_length() {
+ let words = said(&[
+ ("Hello", 0.0, 0.3),
+ ("there.", 0.3, 0.6),
+ ("This", 0.7, 0.9),
+ ("is", 0.9, 1.0),
+ ("a", 1.0, 1.1),
+ ("test", 1.1, 1.4),
+ ("after", 2.6, 2.9),
+ ("a", 2.9, 3.0),
+ ("pause", 3.0, 3.4),
+ ]);
+ let lines = lines(&words, &Layout::default());
+ let text: Vec = lines
+ .iter()
+ .map(|l| l.iter().map(|w| w.text.as_str()).collect::>().join(" "))
+ .collect();
+ assert_eq!(text, vec!["Hello there.", "This is a test", "after a pause"]);
+ // A long sentence splits evenly, never leaving one word behind.
+ let long: Vec = "Almost everybody has a different setup for using AI at home or work."
+ .split(' ')
+ .enumerate()
+ .map(|(i, w)| Word {
+ text: w.into(),
+ start: i as Time * 300_000,
+ end: i as Time * 300_000 + 250_000,
+ })
+ .collect();
+ let split: Vec = super::lines(&long, &Layout::default()).iter().map(Vec::len).collect();
+ assert_eq!(split, vec![5, 4, 4]);
+ }
+
+ #[test]
+ fn captions_are_titles_timed_to_their_words() {
+ let mut p = Project::default();
+ let words = said(&[
+ ("One", 1.0, 1.4),
+ ("two", 1.5, 1.9),
+ ("three.", 2.0, 2.5),
+ ("Four", 4.0, 4.4),
+ ]);
+ let ids = p.add_captions(&words, &Layout::default());
+ assert_eq!(ids.len(), 2);
+ let first = p.clip(ids[0]).unwrap();
+ assert_eq!((first.start, first.end()), (SECOND, 2_700_000));
+ let Some(Generator::Text(title)) = &first.generator else {
+ panic!()
+ };
+ assert_eq!(title.text, "One two three.");
+ assert_eq!(title.timing, vec![0, 500_000, SECOND]);
+ assert!(title.animate.is_some_and(|m| m.style == Style::Karaoke));
+ assert_eq!(p.slot(ids[1]).unwrap().track, p.slot(ids[0]).unwrap().track);
+ }
+
+ #[test]
+ fn captions_fit_the_canvas_and_never_stack() {
+ let mut p = Project::default();
+ (p.settings.width, p.settings.height) = (1080, 1920);
+ let long: Vec = "one two three four five six seven eight nine ten eleven twelve"
+ .split(' ')
+ .enumerate()
+ .map(|(i, w)| Word {
+ text: w.into(),
+ start: i as Time * 200_000,
+ end: i as Time * 200_000 + 150_000,
+ })
+ .collect();
+ let ids = p.add_captions(&long, &Layout::default());
+ for id in &ids {
+ let Some(Generator::Text(t)) = &p.clip(*id).unwrap().generator else {
+ panic!()
+ };
+ assert!(t.text.chars().count() <= 24, "fits a portrait canvas: {}", t.text);
+ }
+ // Words heard at one moment (whisper does this) do not stack captions.
+ let mut q = Project::default();
+ let tied = vec![
+ Word {
+ text: "Yes.".into(),
+ start: SECOND,
+ end: SECOND,
+ },
+ Word {
+ text: "And".into(),
+ start: SECOND,
+ end: SECOND,
+ },
+ Word {
+ text: "here".into(),
+ start: SECOND,
+ end: SECOND,
+ },
+ ];
+ let ids = q.add_captions(&tied, &Layout::default());
+ assert!(
+ ids.iter()
+ .all(|id| q.slot(*id).unwrap().track == q.slot(ids[0]).unwrap().track)
+ );
+ // Chinese heard as one long "word" is cut into lines.
+ let mut r = Project::default();
+ let heard = vec![Word {
+ text: "我們今天討論人工智能的發展和未來的挑戰".into(),
+ start: 0,
+ end: 4 * SECOND,
+ }];
+ let ids = r.add_captions(&heard, &Layout::default());
+ assert!(ids.len() >= 2, "{}", ids.len());
+ let Some(Generator::Text(t)) = &r.clip(ids[0]).unwrap().generator else {
+ panic!()
+ };
+ assert!(!t.text.contains(' '), "written without spaces: {}", t.text);
+ }
+
+ #[test]
+ fn reads_srt_and_vtt() {
+ let srt =
+ "1\n00:00:01,000 --> 00:00:02,500\nHello there \n\n2\n00:00:03,000 --> 00:00:04,000\nSecond\nline\n";
+ let cues = parse_subtitles(srt);
+ assert_eq!(
+ cues,
+ vec![
+ (SECOND, 2_500_000, "Hello there".to_string()),
+ (3 * SECOND, 4 * SECOND, "Second\nline".to_string()),
+ ]
+ );
+ let vtt = "WEBVTT\n\n00:01.000 --> 00:02.000 align:center\nShort form\n";
+ assert_eq!(
+ parse_subtitles(vtt),
+ vec![(SECOND, 2 * SECOND, "Short form".to_string())]
+ );
+ }
+}
diff --git a/crates/splice-core/src/compose.rs b/crates/splice-core/src/compose.rs
index bd3950c..8a4dd69 100644
--- a/crates/splice-core/src/compose.rs
+++ b/crates/splice-core/src/compose.rs
@@ -54,12 +54,24 @@ impl Reveal {
/// How much of the pixel at (`X`, `Y`) shows, for ffmpeg's `geq`, with
/// clip time written as `t`.
pub fn ffmpeg(&self, t: &str) -> String {
+ self.mask(t, "((X+0.5)/W)", "((Y+0.5)/H)")
+ }
+
+ /// The same for a piece of a picture: `rect` (left, top, width, height
+ /// in pixels) of a picture `size` big, so the wipe crosses the whole.
+ pub fn ffmpeg_within(&self, t: &str, rect: [u32; 4], size: (f64, f64)) -> String {
+ let across = format!("((X+{}+0.5)/{:.3})", rect[0], size.0.max(1.0));
+ let down = format!("((Y+{}+0.5)/{:.3})", rect[1], size.1.max(1.0));
+ self.mask(t, &across, &down)
+ }
+
+ fn mask(&self, t: &str, across: &str, down: &str) -> String {
let e = format!("({}*{:.6}+{:.6})", self.progress.ffmpeg(t), 1.0 + WIPE_SOFT, self.lead);
let (along, grows_back) = match self.from {
- Edge::Left => ("((X+0.5)/W)", false),
- Edge::Right => ("((X+0.5)/W)", true),
- Edge::Top => ("((Y+0.5)/H)", false),
- Edge::Bottom => ("((Y+0.5)/H)", true),
+ Edge::Left => (across, false),
+ Edge::Right => (across, true),
+ Edge::Top => (down, false),
+ Edge::Bottom => (down, true),
};
if grows_back {
format!("clip(({along}-1+{e})/{WIPE_SOFT:.6},0,1)")
@@ -168,6 +180,11 @@ impl Sound {
}
}
+/// The id the viewer gives a clip's backdrop, which it decodes on its own.
+pub fn backdrop_id(id: crate::model::ClipId) -> crate::model::ClipId {
+ crate::model::ClipId(id.0 | 1 << 62)
+}
+
/// What the viewer draws at a moment, bottom first.
#[derive(Clone, Debug)]
pub enum Shown<'a> {
@@ -177,6 +194,13 @@ pub enum Shown<'a> {
/// The part of its frame that shows.
part: [f32; 4],
},
+ /// The blurred fill behind a clip (its `look.backdrop`), covering the
+ /// canvas, showing as much as the clip does.
+ Backdrop {
+ clip: &'a Clip,
+ source: Source<'a>,
+ opacity: f64,
+ },
Fill {
color: u32,
opacity: f64,
@@ -426,6 +450,13 @@ impl Project {
let pose = drawn.pose_at(t);
let part = drawn.shows(t);
let tint = drawn.tint.filter(|(_, a, z)| t >= *a && t < *z);
+ if clip.look.backdrop.is_some() && matches!(drawn.source, Source::Media(_)) {
+ out.push(Shown::Backdrop {
+ clip,
+ source: drawn.source,
+ opacity: pose.opacity,
+ });
+ }
out.push(Shown::Clip {
drawn: Box::new(drawn),
pose,
@@ -477,6 +508,7 @@ mod tests {
source: PathBuf::new(),
recipe: None,
content: None,
+ clicks: Vec::new(),
})
}
@@ -493,7 +525,7 @@ mod tests {
.iter()
.map(|s| match s {
Shown::Clip { pose, .. } => pose.opacity,
- Shown::Fill { .. } => -1.0,
+ _ => -1.0,
})
.collect();
assert_eq!(opacities[0], 1.0, "a solid picture stays solid under the dissolve");
diff --git a/crates/splice-core/src/export.rs b/crates/splice-core/src/export.rs
index 73db651..fc03323 100644
--- a/crates/splice-core/src/export.rs
+++ b/crates/splice-core/src/export.rs
@@ -71,6 +71,36 @@ pub struct Rendered {
pub path: PathBuf,
pub width: u32,
pub height: u32,
+ /// For a title that comes in a piece at a time.
+ pub parts: Option,
+}
+
+/// A title drawn in parts (one that comes in a piece at a time): its box
+/// alone, its letters alone, and where each piece of the letters is.
+pub struct TitleParts {
+ pub back: Option,
+ pub text: PathBuf,
+ pub units: crate::textmotion::Units,
+}
+
+/// One picture placed on the canvas: its frames, size at scale 1, the shift
+/// a crop gives it, and its pose.
+struct Part {
+ frames: String,
+ natural: (f64, f64),
+ offset: (f64, f64),
+ pose: crate::look::PoseExpr,
+ tag: String,
+ /// A piece of a title's letters: where it is in the title's picture,
+ /// and that picture's size. It is placed as a small picture of its own.
+ piece: Option<([u32; 4], (f64, f64))>,
+}
+
+fn source_generator<'a>(source: Source<'a>) -> &'a Generator {
+ match source {
+ Source::Generated(g) => g,
+ Source::Media(_) => unreachable!("only drawn titles come in parts"),
+ }
}
/// Where export finds its pictures.
@@ -252,30 +282,43 @@ fn build(
let look = &clip.look;
// Where the picture's frames come from, ready at the frame rate
// and covering every moment it is drawn, starting at `drawn.from`.
+ // A title coming in a piece at a time is drawn in parts below.
+ let title_parts = match source {
+ Source::Generated(crate::model::Generator::Text(title)) if title.animate.is_some() => resolve
+ .generated(clip, source_generator(source))
+ .filter(|r| r.parts.is_some()),
+ _ => None,
+ };
let Frames {
label: frames,
rendered,
shared,
- } = clip_frames(
- &mut args,
- &mut prepare,
- &mut filters,
- &mut next_input,
- project,
- resolve,
- clip,
- drawn,
- source,
- still_at,
- &tag,
- )?;
+ } = match &title_parts {
+ Some(r) => Frames {
+ label: String::new(),
+ rendered: Some((r.width, r.height)),
+ shared: None,
+ },
+ None => clip_frames(
+ &mut args,
+ &mut prepare,
+ &mut filters,
+ &mut next_input,
+ project,
+ resolve,
+ clip,
+ drawn,
+ source,
+ still_at,
+ &tag,
+ )?,
+ };
let natural = natural_size(source, rendered, w, h);
// Only media is cropped; titles and colours are drawn to size.
let ((nw, nh), (cx, cy)) = match source {
Source::Media(_) => look.cropped(natural),
Source::Generated(_) => (natural, (0.0, 0.0)),
};
- let pose = &drawn.pose;
// Clip time, in seconds, of the picture's frames: they start
// at `drawn.from`. A still knows its one moment.
let local = |frame: &str| -> String {
@@ -286,202 +329,371 @@ fn build(
None => format!("({frame}/{fps})"),
}
};
- let mut chain = Vec::new();
- if matches!(source, Source::Media(_))
- && let Some(pixels) = look.pixel_filters()
+ // The blurred fill behind it: the same frames, covering the
+ // canvas, showing as much as the picture does.
+ let size = match source {
+ Source::Media(m) => (m.width.max(2), m.height.max(2)),
+ Source::Generated(_) => (w, h),
+ };
+ // With a backdrop, what is cut away or hidden goes first, so
+ // the copy behind shows none of it.
+ let (frames, size, hidden) = match (look.backdrop, source) {
+ (Some(_), Source::Media(_)) => {
+ let (hiding, after) = look.hiding_filters(size, &format!("h{tag}"));
+ if hiding.is_empty() {
+ (frames, size, false)
+ } else {
+ filters.push(format!("[{frames}]{}[{frames}h]", hiding.join(",")));
+ (format!("{frames}h"), after, true)
+ }
+ }
+ _ => (frames, size, false),
+ };
+ let frames = match (look.backdrop, source) {
+ (Some(backdrop), Source::Media(_)) => {
+ filters.push(format!("[{frames}]split[{frames}m][{frames}b]"));
+ let mut chain = vec![
+ backdrop.ffmpeg(size),
+ format!("scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"),
+ "format=rgba".to_string(),
+ ];
+ opacity(
+ &mut chain,
+ &mut prepare,
+ drawn,
+ &drawn.pose,
+ still_at,
+ fps,
+ &format!("{tag}b"),
+ );
+ let placed = fmt(if still_at.is_some() { 0 } else { drawn.from });
+ chain.push(format!(
+ "format={},setpts=PTS-STARTPTS+{placed}/TB",
+ if layer { "rgba" } else { "yuva420p" }
+ ));
+ filters.push(format!("[{frames}b]{}[b{tag}]", chain.join(",")));
+ let end = fmt(if still_at.is_some() { frame } else { drawn.to });
+ filters.push(format!(
+ "[{picture}][b{tag}]overlay=x=0:y=0:{}eof_action=pass:enable='between(t,{placed},{end})'[pb{tag}]",
+ if layer { "format=auto:" } else { "" }
+ ));
+ picture = format!("pb{tag}");
+ format!("{frames}m")
+ }
+ _ => frames,
+ };
+ // What is placed: the picture, or for a title that comes in a
+ // piece at a time, its box and each of its pieces.
+ let mut parts = vec![Part {
+ frames: frames.clone(),
+ natural: (nw, nh),
+ offset: (cx, cy),
+ pose: drawn.pose.clone(),
+ tag: tag.clone(),
+ piece: None,
+ }];
+ if let Source::Generated(crate::model::Generator::Text(title)) = source
+ && let Some(motion) = &title.animate
+ && let Some(split) = title_parts.and_then(|r| r.parts)
{
- chain.push(pixels);
+ parts.clear();
+ let length = fmt(if still_at.is_some() {
+ frame
+ } else {
+ drawn.to - drawn.from
+ });
+ let mut looped = |args: &mut Vec, path: &Path| {
+ args.extend(strings(&[
+ "-loop",
+ "1",
+ "-framerate",
+ &fps.to_string(),
+ "-t",
+ &length,
+ "-i",
+ ]));
+ args.push(path.to_string_lossy().to_string());
+ let i = next_input;
+ next_input += 1;
+ i
+ };
+ if let Some(back) = &split.back {
+ let i = looped(&mut args, back);
+ filters.push(format!("[{i}:v]fps={fps},setpts=PTS-STARTPTS[k{tag}]"));
+ parts.push(Part {
+ frames: format!("k{tag}"),
+ natural: (nw, nh),
+ offset: (0.0, 0.0),
+ pose: drawn.pose.clone(),
+ tag: format!("{tag}k"),
+ piece: None,
+ });
+ }
+ let pieces = crate::textmotion::pieces(
+ motion,
+ &split.units,
+ &title.timing,
+ (nw, nh),
+ (w as f64, h as f64),
+ title.size as f64,
+ &drawn.pose,
+ );
+ if !pieces.is_empty() {
+ let i = looped(&mut args, &split.text);
+ let outs: String = (0..pieces.len()).map(|k| format!("[u{tag}s{k}]")).collect();
+ filters.push(format!(
+ "[{i}:v]fps={fps},setpts=PTS-STARTPTS,split={}{outs}",
+ pieces.len()
+ ));
+ for (k, piece) in pieces.into_iter().enumerate() {
+ let [x, y, pw, ph] = piece.rect;
+ filters.push(format!("[u{tag}s{k}]crop={pw}:{ph}:{x}:{y}[u{tag}c{k}]"));
+ parts.push(Part {
+ frames: format!("u{tag}c{k}"),
+ natural: (pw as f64, ph as f64),
+ offset: (0.0, 0.0),
+ pose: piece.pose,
+ tag: format!("{tag}u{k}"),
+ piece: Some((piece.rect, (nw, nh))),
+ });
+ }
+ }
}
- chain.push("format=rgba".into());
- // Coming up out of a colour.
- if let Some((color, a, z)) = drawn.tint {
- match still_at {
- Some(t) => {
- let k = ((t - a) as f64 / (z - a).max(1) as f64).clamp(0.0, 1.0);
- let (r, g, b) = ((color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff);
+ for Part {
+ frames,
+ natural: (nw, nh),
+ offset: (cx, cy),
+ pose,
+ tag,
+ piece,
+ } in parts
+ {
+ let pose = &pose;
+ // A piece of a title: a small picture placed by position
+ // expressions, sized afresh only when its size moves.
+ if let Some((rect, title_size)) = piece {
+ let mut chain = vec!["format=rgba".to_string()];
+ let masks: Vec = [&drawn.reveal, &drawn.conceal]
+ .into_iter()
+ .flatten()
+ .map(|r| r.ffmpeg_within(&local("N"), rect, title_size))
+ .collect();
+ if !masks.is_empty() {
chain.push(format!(
+ "geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='alpha(X,Y)*{}'",
+ masks.join("*")
+ ));
+ }
+ opacity(&mut chain, &mut prepare, drawn, pose, still_at, fps, &tag);
+ let placed_at = if still_at.is_some() { 0 } else { drawn.from };
+ let start = fmt(placed_at);
+ let constant = match still_at {
+ Some(t) => Some(pose.at(secs(t - clip.start)).scale),
+ None => pose.scale.constant(),
+ };
+ match constant {
+ Some(k) => {
+ chain.push(format!("scale={}:{}", even((nw * k).round()), even((nh * k).round())))
+ }
+ None => {
+ // The largest it gets, drawn once; smaller is a
+ // perspective within its own small frame.
+ let count = ((drawn.to - drawn.from) as f64 * fps as f64 / SECOND as f64).ceil() as i64;
+ let head = secs(drawn.head());
+ let most = (0..count.max(1))
+ .map(|n| pose.scale.eval(n as f64 / fps as f64 - head))
+ .fold(0.0001f64, f64::max);
+ let (pw, ph) = (even((nw * most).round()), even((nh * most).round()));
+ let (fw, fh) = (pw + 2 * BORDER, ph + 2 * BORDER);
+ chain.push(format!("scale={pw}:{ph}"));
+ chain.push(format!("pad={fw}:{fh}:(ow-iw)/2:(oh-ih)/2:color=black@0"));
+ let t = local("in");
+ let k = format!("(max(0.0001,{})/{most:.6})", pose.scale.ffmpeg(&t));
+ let (hx, hy) = (format!("{}*{k}", fw as f64 / 2.0), format!("{}*{k}", fh as f64 / 2.0));
+ let (cx_at, cy_at) = (format!("{}", fw as f64 / 2.0), format!("{}", fh as f64 / 2.0));
+ chain.push("format=yuva444p".into());
+ chain.push(format!(
+ "perspective=sense=destination:interpolation=cubic:eval=frame:\
+ x0='{cx_at}-{hx}':y0='{cy_at}-{hy}':x1='{cx_at}+{hx}':y1='{cy_at}-{hy}':\
+ x2='{cx_at}-{hx}':y2='{cy_at}+{hy}':x3='{cx_at}+{hx}':y3='{cy_at}+{hy}'"
+ ));
+ }
+ }
+ chain.push(format!(
+ "format={},setpts=PTS-STARTPTS+{start}/TB",
+ if layer { "rgba" } else { "yuva420p" }
+ ));
+ filters.push(format!("[{frames}]{}[v{tag}]", chain.join(",")));
+ // Its centre, as the overlay's clock runs (timeline seconds).
+ let (x, y) = match still_at {
+ Some(t) => {
+ let p = pose.at(secs(t - clip.start));
+ (format!("W/2+{:.4}*W-w/2", p.x), format!("H/2+{:.4}*H-h/2", p.y))
+ }
+ None => {
+ let since = format!("(t-{})", fmt(clip.start));
+ (
+ format!("W/2+{}*W-w/2", pose.x.ffmpeg(&since)),
+ format!("H/2+{}*H-h/2", pose.y.ffmpeg(&since)),
+ )
+ }
+ };
+ let end = fmt(if still_at.is_some() { frame } else { drawn.to });
+ filters.push(format!(
+ "[{picture}][v{tag}]overlay=x='{x}':y='{y}':{}eof_action=pass:\
+ enable='between(t,{start},{end})'[p{tag}]",
+ if layer { "format=auto:" } else { "" }
+ ));
+ picture = format!("p{tag}");
+ continue;
+ }
+ let mut chain = Vec::new();
+ if matches!(source, Source::Media(_)) {
+ let pixels = if hidden {
+ let rest = look.clone_without_hiding();
+ rest.pixel_filters(size, 1.0, &format!("x{tag}"))
+ } else {
+ look.pixel_filters(size, 1.0, &format!("x{tag}"))
+ };
+ if let Some(pixels) = pixels {
+ chain.push(pixels);
+ }
+ }
+ chain.push("format=rgba".into());
+ // Coming up out of a colour.
+ if let Some((color, a, z)) = drawn.tint {
+ match still_at {
+ Some(t) => {
+ let k = ((t - a) as f64 / (z - a).max(1) as f64).clamp(0.0, 1.0);
+ let (r, g, b) = ((color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff);
+ chain.push(format!(
"geq=r='r(X,Y)*{k:.4}+{:.3}':g='g(X,Y)*{k:.4}+{:.3}':b='b(X,Y)*{k:.4}+{:.3}':a='alpha(X,Y)'",
r as f64 * (1.0 - k),
g as f64 * (1.0 - k),
b as f64 * (1.0 - k)
));
+ }
+ None => chain.push(format!(
+ "fade=t=in:st={}:d={}:color=0x{color:06x}",
+ fmt(a - drawn.from),
+ fmt(z - a)
+ )),
}
- None => chain.push(format!(
- "fade=t=in:st={}:d={}:color=0x{color:06x}",
- fmt(a - drawn.from),
- fmt(z - a)
- )),
}
- }
- // A wipe shows part of the picture; outside it, all of it.
- let masks: Vec = [&drawn.reveal, &drawn.conceal]
- .into_iter()
- .flatten()
- .map(|r| r.ffmpeg(&local("N")))
- .collect();
- if !masks.is_empty() {
- let cut_window = [project.cut_into(clip.id), project.cut_out_of(clip.id)]
+ // A wipe shows part of the picture; outside it, all of it.
+ let masks: Vec = [&drawn.reveal, &drawn.conceal]
.into_iter()
.flatten()
- .filter(|c| c.kind.dip().is_none())
- .map(|c| (c.start - drawn.from, c.end - drawn.from))
- .reduce(|a, z| (a.0.min(z.0), a.1.max(z.1)))
- .unwrap_or((0, drawn.to - drawn.from));
- let enable = match still_at {
- Some(_) => String::new(),
- None => format!(
- ":enable='between(t,{},{})'",
- fmt(cut_window.0 - project.frame()),
- fmt(cut_window.1 + project.frame())
- ),
- };
- chain.push(format!(
- "geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='alpha(X,Y)*{}'{enable}",
- masks.join("*")
- ));
- }
- // How much shows: a still knows exactly; a movie fades as
- // its motions do, or follows keyframes and transitions frame by frame.
- match (still_at, pose.opacity.constant()) {
- (Some(t), _) => {
- let o = drawn.pose_at(t).opacity;
- if o < 1.0 {
- chain.push(format!("colorchannelmixer=aa={:.4}", o.max(0.0)));
- }
- }
- (None, Some(o)) => {
- if o < 1.0 {
- chain.push(format!("colorchannelmixer=aa={:.4}", o.max(0.0)));
- }
+ .map(|r| r.ffmpeg(&local("N")))
+ .collect();
+ if !masks.is_empty() {
+ let cut_window = [project.cut_into(clip.id), project.cut_out_of(clip.id)]
+ .into_iter()
+ .flatten()
+ .filter(|c| c.kind.dip().is_none())
+ .map(|c| (c.start - drawn.from, c.end - drawn.from))
+ .reduce(|a, z| (a.0.min(z.0), a.1.max(z.1)))
+ .unwrap_or((0, drawn.to - drawn.from));
+ let enable = match still_at {
+ Some(_) => String::new(),
+ None => format!(
+ ":enable='between(t,{},{})'",
+ fmt(cut_window.0 - project.frame()),
+ fmt(cut_window.1 + project.frame())
+ ),
+ };
+ chain.push(format!(
+ "geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='alpha(X,Y)*{}'{enable}",
+ masks.join("*")
+ ));
}
- (None, None)
- if pose.opacity == look.pose_expr(clip.duration).opacity && look.keys.opacity.is_empty() =>
- {
- if look.opacity < 1.0 {
- chain.push(format!("colorchannelmixer=aa={:.4}", look.opacity.max(0.0)));
- }
- for (motion, inward) in [(look.enter, true), (look.exit, false)] {
- if motion.kind == MotionKind::None {
- continue;
- }
- let fd = secs(motion.duration.min(clip.duration));
- let head = secs(drawn.head());
- let st = if inward { head } else { head + secs(clip.duration) - fd };
- chain.push(format!(
- "fade=t={}:st={st:.6}:d={fd:.6}:alpha=1",
- if inward { "in" } else { "out" }
- ));
+ opacity(&mut chain, &mut prepare, drawn, pose, still_at, fps, &tag);
+ let moving = still_at.is_none() && pose.moves();
+ // A moving picture keeps one size and is placed anew each
+ // frame to a fraction of a pixel. Resizing it every frame
+ // rounded its size to whole pixels and moved its centre with
+ // the rounding, so a slow zoom or slide jittered.
+ let (pw, ph) = (even(nw.round()), even(nh.round()));
+ // A clear border all round: perspective fills what lies outside
+ // the picture with its edge pixels, so without one a picture
+ // that shrinks or slides smeared its edges across the canvas.
+ let (fw, fh) = (pw.max(w) + 2 * BORDER, ph.max(h) + 2 * BORDER);
+ let fixed = match still_at {
+ Some(t) => Some(pose.at(secs(t - clip.start))),
+ None if !moving => Some(pose.at(0.0)),
+ None => None,
+ };
+ match fixed {
+ Some(p) => chain.push(format!(
+ "scale={}:{}",
+ even((nw * p.scale).round()),
+ even((nh * p.scale).round())
+ )),
+ None => {
+ chain.push(format!("scale={pw}:{ph}"));
+ chain.push(format!("pad={fw}:{fh}:(ow-iw)/2:(oh-ih)/2:color=black@0"));
}
}
- (None, None) => {
- // Sent to the mixer as each frame arrives.
- let name = format!("colorchannelmixer@o{tag}");
- let count = ((drawn.to - drawn.from) as f64 * fps as f64 / SECOND as f64).ceil() as i64;
- let head = secs(drawn.head());
- let mut said: Option = None;
- let mut commands = Vec::new();
- for n in 0..count.max(1) {
- let at = n as f64 / fps as f64;
- let v = format!("{:.4}", pose.opacity.eval(at - head).clamp(0.0, 1.0));
- if said.as_ref() != Some(&v) {
- commands.push(format!("{at:.6} {name} aa {v}"));
- said = Some(v);
+ if moving {
+ // perspective has no clock of its own; its frame count is one.
+ let t = local("in");
+ let k = format!("max(0.0001,{})", pose.scale.ffmpeg(&t));
+ let along = |by: f64| {
+ if by.abs() < 1e-6 {
+ String::new()
+ } else {
+ format!("+{by:.3}*{k}")
}
- }
- let first = pose.opacity.eval(-head).clamp(0.0, 1.0);
- // In a file: a long clip has a command for every frame.
- let file = prepare.dir.join(format!("{tag}-opacity.txt"));
- prepare.files.push((file.clone(), commands.join(";\n") + ";\n"));
- prepare.used = true;
- chain.push(format!("sendcmd=f='{}'", file.to_string_lossy().replace('\'', "")));
- chain.push(format!("{name}=aa={first:.4}"));
- }
- }
- let moving = still_at.is_none() && pose.moves();
- // A moving picture keeps one size and is placed anew each
- // frame to a fraction of a pixel. Resizing it every frame
- // rounded its size to whole pixels and moved its centre with
- // the rounding, so a slow zoom or slide jittered.
- let (pw, ph) = (even(nw.round()), even(nh.round()));
- // A clear border all round: perspective fills what lies outside
- // the picture with its edge pixels, so without one a picture
- // that shrinks or slides smeared its edges across the canvas.
- let (fw, fh) = (pw.max(w) + 2 * BORDER, ph.max(h) + 2 * BORDER);
- let fixed = match still_at {
- Some(t) => Some(drawn.pose_at(t)),
- None if !moving => Some(drawn.pose_at(clip.start)),
- None => None,
- };
- match fixed {
- Some(p) => chain.push(format!(
- "scale={}:{}",
- even((nw * p.scale).round()),
- even((nh * p.scale).round())
- )),
- None => {
- chain.push(format!("scale={pw}:{ph}"));
- chain.push(format!("pad={fw}:{fh}:(ow-iw)/2:(oh-ih)/2:color=black@0"));
- }
- }
- if moving {
- // perspective has no clock of its own; its frame count is one.
- let t = local("in");
- let k = format!("max(0.0001,{})", pose.scale.ffmpeg(&t));
- let along = |by: f64| {
- if by.abs() < 1e-6 {
- String::new()
- } else {
- format!("+{by:.3}*{k}")
- }
- };
- // The picture's centre, in the frame it moves in.
- let cx_at = format!("({fw}/2+{}*{w}{})", pose.x.ffmpeg(&t), along(cx));
- let cy_at = format!("({fh}/2+{}*{h}{})", pose.y.ffmpeg(&t), along(cy));
- let (hx, hy) = (format!("{}*{k}", fw as f64 / 2.0), format!("{}*{k}", fh as f64 / 2.0));
- chain.push("format=yuva444p".into());
- chain.push(format!(
- "perspective=sense=destination:interpolation=cubic:eval=frame:\
+ };
+ // The picture's centre, in the frame it moves in.
+ let cx_at = format!("({fw}/2+{}*{w}{})", pose.x.ffmpeg(&t), along(cx));
+ let cy_at = format!("({fh}/2+{}*{h}{})", pose.y.ffmpeg(&t), along(cy));
+ let (hx, hy) = (format!("{}*{k}", fw as f64 / 2.0), format!("{}*{k}", fh as f64 / 2.0));
+ chain.push("format=yuva444p".into());
+ chain.push(format!(
+ "perspective=sense=destination:interpolation=cubic:eval=frame:\
x0='{cx_at}-{hx}':y0='{cy_at}-{hy}':x1='{cx_at}+{hx}':y1='{cy_at}-{hy}':\
x2='{cx_at}-{hx}':y2='{cy_at}+{hy}':x3='{cx_at}+{hx}':y3='{cy_at}+{hy}'"
+ ));
+ }
+ let placed_at = if still_at.is_some() { 0 } else { drawn.from };
+ let start = fmt(placed_at);
+ // Over a see-through canvas the overlay negotiates its format
+ // from its inputs: a picture that changes size frame to frame
+ // only stays right as RGBA (as yuva420p it froze at its first size).
+ chain.push(format!(
+ "format={},setpts=PTS-STARTPTS+{start}/TB",
+ if layer { "rgba" } else { "yuva420p" }
));
- }
- let placed_at = if still_at.is_some() { 0 } else { drawn.from };
- let start = fmt(placed_at);
- // Over a see-through canvas the overlay negotiates its format
- // from its inputs: a picture that changes size frame to frame
- // only stays right as RGBA (as yuva420p it froze at its first size).
- chain.push(format!(
- "format={},setpts=PTS-STARTPTS+{start}/TB",
- if layer { "rgba" } else { "yuva420p" }
- ));
- filters.push(format!("[{frames}]{}[v{tag}]", chain.join(",")));
+ filters.push(format!("[{frames}]{}[v{tag}]", chain.join(",")));
- let end = fmt(if still_at.is_some() { frame } else { drawn.to });
- // A cropped picture's remains stay where they were, scaled with it.
- let (x, y) = if moving {
- // Already placed in its frame.
- (format!("-{}", (fw - w) / 2), format!("-{}", (fh - h) / 2))
- } else {
- let p = fixed.expect("a still picture has a pose");
- let shift = |by: f64| {
- if by.abs() < 1e-6 {
- String::new()
- } else {
- format!("+{:.3}", by * p.scale)
- }
+ let end = fmt(if still_at.is_some() { frame } else { drawn.to });
+ // A cropped picture's remains stay where they were, scaled with it.
+ let (x, y) = if moving {
+ // Already placed in its frame.
+ (format!("-{}", (fw - w) / 2), format!("-{}", (fh - h) / 2))
+ } else {
+ let p = fixed.expect("a still picture has a pose");
+ let shift = |by: f64| {
+ if by.abs() < 1e-6 {
+ String::new()
+ } else {
+ format!("+{:.3}", by * p.scale)
+ }
+ };
+ (
+ format!("(W-w)/2+{:.6}*W{}", p.x, shift(cx)),
+ format!("(H-h)/2+{:.6}*H{}", p.y, shift(cy)),
+ )
};
- (
- format!("(W-w)/2+{:.6}*W{}", p.x, shift(cx)),
- format!("(H-h)/2+{:.6}*H{}", p.y, shift(cy)),
- )
- };
- filters.push(format!(
- "[{picture}][v{tag}]overlay=x='{x}':y='{y}':{}eof_action=pass:\
+ filters.push(format!(
+ "[{picture}][v{tag}]overlay=x='{x}':y='{y}':{}eof_action=pass:\
enable='between(t,{start},{end})'[p{tag}]",
- // Over a see-through canvas, keep what is see-through.
- if layer { "format=auto:" } else { "" }
- ));
- picture = format!("p{tag}");
+ // Over a see-through canvas, keep what is see-through.
+ if layer { "format=auto:" } else { "" }
+ ));
+ picture = format!("p{tag}");
+ }
if sounds_out && let (Some(shared), Some(media)) = (shared, audio_media) {
sounds.push(clip_sound(
@@ -560,8 +772,18 @@ fn build(
),
None => (String::new(), String::new()),
};
+ // Bars over everything, for a letterboxed movie (not a symbol's layer).
+ let bars = match project.letterbox_bars().filter(|_| !layer) {
+ Some((tb, 0)) => format!(
+ "drawbox=x=0:y=0:w=iw:h={tb}:color=black:t=fill,drawbox=x=0:y=ih-{tb}:w=iw:h={tb}:color=black:t=fill,"
+ ),
+ Some((0, lr)) => format!(
+ "drawbox=x=0:y=0:w={lr}:h=ih:color=black:t=fill,drawbox=x=iw-{lr}:y=0:w={lr}:h=ih:color=black:t=fill,"
+ ),
+ _ => String::new(),
+ };
filters.push(format!(
- "[{picture}]{cut}format={}[vout]",
+ "[{picture}]{bars}{cut}format={}[vout]",
if layer { "yuva444p10le" } else { "yuv420p" }
));
if out.is_some() {
@@ -576,7 +798,17 @@ fn build(
None => (duration, d),
};
- args.extend(strings(&["-filter_complex", &filters.join(";")]));
+ // A long graph (many captions) goes in a file: the command line has a limit.
+ let graph = filters.join(";");
+ if graph.len() > 64 * 1024 {
+ let file = prepare.dir.join("graph.txt");
+ prepare.files.push((file.clone(), graph));
+ prepare.used = true;
+ args.push("-/filter_complex".into());
+ args.push(file.to_string_lossy().to_string());
+ } else {
+ args.extend(strings(&["-filter_complex", &graph]));
+ }
let Some(out) = out else {
args.extend(strings(&["-map", "[vout]", "-frames:v", "1", "-update", "1"]));
args.push(output.to_string_lossy().to_string());
@@ -677,6 +909,75 @@ fn scratch_dir() -> PathBuf {
/// The clear margin around a moving picture, in pixels each side.
const BORDER: u32 = 2;
+/// The filters that set how much of a clip shows: a still knows exactly; a
+/// movie fades as its motions do, or follows keyframes and transitions frame
+/// by frame (commands in a file, sent to a mixer named after `tag`).
+fn opacity(
+ chain: &mut Vec,
+ prepare: &mut Prepare,
+ drawn: &crate::compose::Drawn,
+ pose: &crate::look::PoseExpr,
+ still_at: Option,
+ fps: u32,
+ tag: &str,
+) {
+ let clip = drawn.clip;
+ let look = &clip.look;
+ match (still_at, pose.opacity.constant()) {
+ (Some(t), _) => {
+ let o = pose.at(secs(t - clip.start)).opacity;
+ if o < 1.0 {
+ chain.push(format!("colorchannelmixer=aa={:.4}", o.max(0.0)));
+ }
+ }
+ (None, Some(o)) => {
+ if o < 1.0 {
+ chain.push(format!("colorchannelmixer=aa={:.4}", o.max(0.0)));
+ }
+ }
+ (None, None) if pose.opacity == look.pose_expr(clip.duration).opacity && look.keys.opacity.is_empty() => {
+ if look.opacity < 1.0 {
+ chain.push(format!("colorchannelmixer=aa={:.4}", look.opacity.max(0.0)));
+ }
+ for (motion, inward) in [(look.enter, true), (look.exit, false)] {
+ if motion.kind == MotionKind::None {
+ continue;
+ }
+ let fd = secs(motion.duration.min(clip.duration));
+ let head = secs(drawn.head());
+ let st = if inward { head } else { head + secs(clip.duration) - fd };
+ chain.push(format!(
+ "fade=t={}:st={st:.6}:d={fd:.6}:alpha=1",
+ if inward { "in" } else { "out" }
+ ));
+ }
+ }
+ (None, None) => {
+ // Sent to the mixer as each frame arrives.
+ let name = format!("colorchannelmixer@o{tag}");
+ let count = ((drawn.to - drawn.from) as f64 * fps as f64 / SECOND as f64).ceil() as i64;
+ let head = secs(drawn.head());
+ let mut said: Option = None;
+ let mut commands = Vec::new();
+ for n in 0..count.max(1) {
+ let at = n as f64 / fps as f64;
+ let v = format!("{:.4}", pose.opacity.eval(at - head).clamp(0.0, 1.0));
+ if said.as_ref() != Some(&v) {
+ commands.push(format!("{at:.6} {name} aa {v}"));
+ said = Some(v);
+ }
+ }
+ let first = pose.opacity.eval(-head).clamp(0.0, 1.0);
+ // In a file: a long clip has a command for every frame.
+ let file = prepare.dir.join(format!("{tag}-opacity.txt"));
+ prepare.files.push((file.clone(), commands.join(";\n") + ";\n"));
+ prepare.used = true;
+ chain.push(format!("sendcmd=f='{}'", file.to_string_lossy().replace('\'', "")));
+ chain.push(format!("{name}=aa={first:.4}"));
+ }
+ }
+}
+
/// How much of a reversed clip's source each of its pieces holds: reversing
/// keeps a whole piece in memory, so a long clip goes backwards in pieces.
const REVERSE_PIECE: Time = 2 * SECOND;
@@ -1027,6 +1328,7 @@ mod tests {
path: PathBuf::from(format!("/g/{}.png", clip.id.0)),
width: 600,
height: 120,
+ parts: None,
})
}
}
@@ -1045,6 +1347,7 @@ mod tests {
source: PathBuf::new(),
recipe: None,
content: None,
+ clicks: Vec::new(),
})
}
@@ -1069,6 +1372,7 @@ mod tests {
source: PathBuf::new(),
recipe: None,
content: None,
+ clicks: Vec::new(),
});
p.append_clip(v);
p.insert_clip(image, 0, SECOND);
diff --git a/crates/splice-core/src/leaks.rs b/crates/splice-core/src/leaks.rs
new file mode 100644
index 0000000..877043e
--- /dev/null
+++ b/crates/splice-core/src/leaks.rs
@@ -0,0 +1,81 @@
+//! Light leaks: soft washes of warm or cool light drifting across the
+//! frame, laid over footage. Each is a see-through scene (`motion::Spec`),
+//! so it renders like any other scene and can be edited like one.
+
+/// A light leak: its name, the colours its tile shows, and its scene.
+pub struct Leak {
+ pub name: &'static str,
+ pub colors: [u32; 2],
+ pub scene: &'static str,
+}
+
+pub const LEAKS: &[Leak] = &[
+ Leak {
+ name: "Warm leak",
+ colors: [0xff7a3d, 0xffc46b],
+ scene: r##"{
+ "layers": [
+ { "type": "ellipse", "name": "glow", "x": [[0, "0%"], [4, "35%", "inOutSine"]], "y": "30%", "width": "110%", "height": "170%",
+ "fill": { "radial": true, "stops": [[0, "#ff6a2bcc"], [0.35, "#ff6a2b77"], [0.7, "#ff6a2b22"], [1, "#ff6a2b00"]] },
+ "opacity": [[0, 0], [0.9, 1], [3.1, 0.8], [4, 0, "inSine"]] },
+ { "type": "ellipse", "name": "flare", "x": [[0, "20%"], [4, "60%", "inOutSine"]], "y": "85%", "width": "70%", "height": "90%",
+ "fill": { "radial": true, "stops": [[0, "#ffc46bcc"], [0.35, "#ffc46b77"], [0.7, "#ffc46b22"], [1, "#ffc46b00"]] },
+ "opacity": [[0, 0], [1.3, 0.85], [4, 0, "inSine"]] }
+ ]
+}"##,
+ },
+ Leak {
+ name: "Sun flare",
+ colors: [0xffe7a8, 0xfff8e8],
+ scene: r##"{
+ "layers": [
+ { "type": "ellipse", "name": "sun", "x": [[0, "88%"], [4, "72%", "inOutSine"]], "y": "12%", "width": "90%", "height": "120%",
+ "fill": { "radial": true, "stops": [[0, "#ffe7a8cc"], [0.35, "#ffe7a877"], [0.7, "#ffe7a822"], [1, "#ffe7a800"]] },
+ "opacity": [[0, 0], [0.9, 1], [3.1, 0.8], [4, 0, "inSine"]] },
+ { "type": "ellipse", "name": "streak", "x": [[0, "70%"], [4, "45%", "inOutSine"]], "y": [[0, "38%"], [4, "58%"]], "width": "140%", "height": "24%", "rotate": -24,
+ "fill": { "radial": true, "stops": [[0, "#fff8e8cc"], [0.35, "#fff8e877"], [0.7, "#fff8e822"], [1, "#fff8e800"]] },
+ "opacity": [[0, 0], [1.3, 0.85], [4, 0, "inSine"]] }
+ ]
+}"##,
+ },
+ Leak {
+ name: "Cool leak",
+ colors: [0x6c9ef8, 0xc792ea],
+ scene: r##"{
+ "layers": [
+ { "type": "ellipse", "name": "glow", "x": [[0, "100%"], [4, "65%", "inOutSine"]], "y": "65%", "width": "110%", "height": "170%",
+ "fill": { "radial": true, "stops": [[0, "#5b8cffcc"], [0.35, "#5b8cff77"], [0.7, "#5b8cff22"], [1, "#5b8cff00"]] },
+ "opacity": [[0, 0], [0.9, 1], [3.1, 0.8], [4, 0, "inSine"]] },
+ { "type": "ellipse", "name": "tint", "x": [[0, "80%"], [4, "40%", "inOutSine"]], "y": "15%", "width": "70%", "height": "90%",
+ "fill": { "radial": true, "stops": [[0, "#c792eacc"], [0.35, "#c792ea77"], [0.7, "#c792ea22"], [1, "#c792ea00"]] },
+ "opacity": [[0, 0], [1.3, 0.85], [4, 0, "inSine"]] }
+ ]
+}"##,
+ },
+];
+
+/// The leak called `name` (in any case, with or without "leak").
+pub fn find(name: &str) -> Option<&'static Leak> {
+ let want = name.trim().to_lowercase().replace(['-', '_'], " ");
+ let want = want.trim_end_matches(" leak");
+ (!want.is_empty())
+ .then(|| LEAKS.iter().find(|l| l.name.to_lowercase().starts_with(want)))
+ .flatten()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn every_leak_is_a_valid_scene() {
+ for leak in LEAKS {
+ let spec = crate::motion::Spec::parse(leak.scene);
+ assert!(spec.is_ok(), "{}: {:?}", leak.name, spec.err());
+ }
+ assert_eq!(find("warm").map(|l| l.name), Some("Warm leak"));
+ assert_eq!(find("sun-flare").map(|l| l.name), Some("Sun flare"));
+ assert_eq!(find("Cool").map(|l| l.name), Some("Cool leak"));
+ assert!(find("").is_none());
+ }
+}
diff --git a/crates/splice-core/src/lib.rs b/crates/splice-core/src/lib.rs
index c3fd6d2..491e1bc 100644
--- a/crates/splice-core/src/lib.rs
+++ b/crates/splice-core/src/lib.rs
@@ -4,9 +4,11 @@
pub mod bible;
pub mod brief;
+pub mod captions;
pub mod compose;
pub mod export;
pub mod history;
+pub mod leaks;
pub mod look;
pub mod model;
pub mod motion;
@@ -14,5 +16,7 @@ pub mod score;
pub mod store;
pub mod story;
pub mod symbol;
+pub mod textmotion;
pub mod time;
pub mod transition;
+pub mod zoom;
diff --git a/crates/splice-core/src/look.rs b/crates/splice-core/src/look.rs
index c9684a7..d8e44c8 100644
--- a/crates/splice-core/src/look.rs
+++ b/crates/splice-core/src/look.rs
@@ -38,6 +38,171 @@ pub struct Look {
/// between. A property with keyframes follows them instead of its value above.
#[serde(skip_serializing_if = "Keys::is_empty")]
pub keys: Keys,
+ /// Blur, vignette, grain, shake and glitch.
+ #[serde(skip_serializing_if = "Effects::is_none")]
+ pub fx: Effects,
+ /// A blurred, darkened copy of the picture filling the canvas behind it:
+ /// a portrait phone video on a wide canvas.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub backdrop: Option,
+ /// Part of the picture hidden: a face, a screen, a number plate.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub redact: Option,
+}
+
+/// Effects on a clip's picture, each 0 (off) to 1 (strongest).
+#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Effects {
+ pub blur: f32,
+ /// Darker towards the corners.
+ pub vignette: f32,
+ /// Film grain.
+ pub grain: f32,
+ /// A handheld wobble.
+ pub shake: f32,
+ /// Colour fringes, and now and then a jolt.
+ pub glitch: f32,
+}
+
+impl Effects {
+ pub fn is_none(&self) -> bool {
+ *self == Effects::default()
+ }
+
+ pub fn clamped(self) -> Effects {
+ let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
+ Effects {
+ blur: unit(self.blur),
+ vignette: unit(self.vignette),
+ grain: unit(self.grain),
+ shake: unit(self.shake),
+ glitch: unit(self.glitch),
+ }
+ }
+
+ /// What is on, in a few words.
+ pub fn summary(&self) -> String {
+ let fx = self.clamped();
+ let mut said = Vec::new();
+ for (v, name) in [
+ (fx.blur, "Blur"),
+ (fx.vignette, "Vignette"),
+ (fx.grain, "Grain"),
+ (fx.shake, "Shake"),
+ (fx.glitch, "Glitch"),
+ ] {
+ if v > 0.0 {
+ said.push(name);
+ }
+ }
+ if said.is_empty() {
+ "None".into()
+ } else {
+ said.join(" · ")
+ }
+ }
+}
+
+/// The blurred fill behind a picture.
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Backdrop {
+ /// 0 to 1.
+ pub blur: f32,
+ /// How much darker than the picture, 0 to 1.
+ pub dim: f32,
+}
+
+impl Default for Backdrop {
+ fn default() -> Backdrop {
+ Backdrop { blur: 0.6, dim: 0.35 }
+ }
+}
+
+impl Backdrop {
+ /// Its filters, for a frame of `size` (the backdrop is scaled to cover
+ /// the canvas after them).
+ pub fn ffmpeg(&self, size: (u32, u32)) -> String {
+ let radius = blur_radius(self.blur.clamp(0.0, 1.0) * 0.6 + 0.2, size);
+ let k = 1.0 - self.dim.clamp(0.0, 1.0) * 0.85;
+ format!(
+ "boxblur=lr={radius}:lp=3:cr={}:cp=3,colorchannelmixer=rr={k:.3}:gg={k:.3}:bb={k:.3}",
+ (radius / 2).max(1)
+ )
+ }
+}
+
+/// How hidden part of a picture is.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum RedactStyle {
+ #[default]
+ Blur,
+ Pixelate,
+ Black,
+}
+
+impl RedactStyle {
+ pub const ALL: [RedactStyle; 3] = [RedactStyle::Blur, RedactStyle::Pixelate, RedactStyle::Black];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ RedactStyle::Blur => "blur",
+ RedactStyle::Pixelate => "pixelate",
+ RedactStyle::Black => "black",
+ }
+ }
+
+ pub fn parse(name: &str) -> Option {
+ RedactStyle::ALL
+ .into_iter()
+ .find(|s| s.name() == name.trim().to_lowercase())
+ }
+}
+
+/// A box of the picture hidden, in fractions of its frame (centre and size).
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Redact {
+ pub style: RedactStyle,
+ pub x: f32,
+ pub y: f32,
+ pub width: f32,
+ pub height: f32,
+}
+
+impl Default for Redact {
+ fn default() -> Redact {
+ Redact {
+ style: RedactStyle::Blur,
+ x: 0.5,
+ y: 0.4,
+ width: 0.3,
+ height: 0.35,
+ }
+ }
+}
+
+impl Redact {
+ /// Its box in pixels of a frame of `size`: left, top, width, height,
+ /// even and inside the frame.
+ pub fn pixels(&self, size: (u32, u32)) -> (u32, u32, u32, u32) {
+ let (w, h) = (size.0.max(2) as f32, size.1.max(2) as f32);
+ let even = |v: f32| ((v.max(2.0) as u32) / 2) * 2;
+ let bw = even((self.width.clamp(0.02, 1.0) * w).min(w));
+ let bh = even((self.height.clamp(0.02, 1.0) * h).min(h));
+ let x = ((self.x * w - bw as f32 / 2.0).clamp(0.0, w - bw as f32)) as u32;
+ let y = ((self.y * h - bh as f32 / 2.0).clamp(0.0, h - bh as f32)) as u32;
+ (x, y, bw, bh)
+ }
+}
+
+/// A box blur radius for a blur of `amount` (0 to 1) on a frame of `size`:
+/// the same look at any size.
+fn blur_radius(amount: f32, size: (u32, u32)) -> u32 {
+ let short = size.0.min(size.1).max(2) as f32;
+ ((amount * 0.035 * short).round() as u32).min((short as u32 / 2).saturating_sub(1))
}
impl Default for Look {
@@ -55,6 +220,9 @@ impl Default for Look {
drift: Drift::None,
grade: Grade::default(),
keys: Keys::default(),
+ fx: Effects::default(),
+ backdrop: None,
+ redact: None,
}
}
}
@@ -759,6 +927,8 @@ pub enum Drift {
/// How far a slide travels, as a fraction of the canvas.
const SLIDE: f64 = 0.12;
+/// How far a full shake wanders, as a fraction of the canvas.
+const SHAKE: f64 = 0.012;
/// How much a drift zooms over the clip.
const DRIFT: f64 = 0.15;
@@ -782,6 +952,7 @@ pub enum Expr {
Div(Box, Box),
Clip(Box, f64, f64),
Pow(Box, f64),
+ Sin(Box),
}
use Expr::C;
@@ -837,6 +1008,7 @@ impl Expr {
Expr::Div(a, z) => a.eval(t) / z.eval(t),
Expr::Clip(a, lo, hi) => a.eval(t).clamp(*lo, *hi),
Expr::Pow(a, n) => a.eval(t).powf(*n),
+ Expr::Sin(a) => a.eval(t).sin(),
}
}
@@ -859,6 +1031,7 @@ impl Expr {
Expr::Div(a, z) => format!("({}/{})", a.ffmpeg(t), z.ffmpeg(t)),
Expr::Clip(a, lo, hi) => format!("clip({},{lo:.6},{hi:.6})", a.ffmpeg(t)),
Expr::Pow(a, n) => format!("pow({},{n:.6})", a.ffmpeg(t)),
+ Expr::Sin(a) => format!("sin({})", a.ffmpeg(t)),
}
}
}
@@ -1000,6 +1173,30 @@ impl Look {
Drift::ZoomIn => scale = mul(scale, add(C(1.0), mul(C(DRIFT), progress))),
Drift::ZoomOut => scale = mul(scale, sub(C(1.0 + DRIFT), mul(C(DRIFT), progress))),
}
+ // A wave at `hz`, `phase` along: sin(2π·hz·t + phase).
+ let wave = |hz: f64, phase: f64| Expr::Sin(b(add(mul(C(std::f64::consts::TAU * hz), Expr::T), C(phase))));
+ let fx = self.fx.clamped();
+ if fx.shake > 0.0 {
+ // Two slow waves a side, out of step: a hand, not a machine.
+ let a = SHAKE * fx.shake as f64;
+ x = add(
+ x,
+ mul(C(a), add(mul(C(0.6), wave(1.1, 0.0)), mul(C(0.4), wave(2.7, 1.3)))),
+ );
+ y = add(
+ y,
+ mul(C(a), add(mul(C(0.6), wave(1.4, 0.7)), mul(C(0.4), wave(3.1, 2.1)))),
+ );
+ // A little bigger, so its edges stay off the canvas.
+ scale = mul(scale, C(1.0 + 2.4 * a));
+ }
+ if fx.glitch > 0.0 {
+ // Now and then (when two slow waves peak together), a quick jolt.
+ let g = fx.glitch as f64;
+ let gate = clip01(mul(sub(add(wave(0.63, 0.0), wave(1.71, 0.9)), C(1.6)), C(8.0)));
+ x = add(x, mul(C(0.025 * g), mul(gate, wave(13.0, 0.0))));
+ scale = mul(scale, C(1.0 + 0.05 * g));
+ }
PoseExpr { x, y, scale, opacity }
}
@@ -1014,15 +1211,93 @@ impl Look {
|| self.exit.kind != MotionKind::None
|| self.drift != Drift::None
|| !self.keys.is_empty()
+ || self.fx.shake > 0.0
+ || self.fx.glitch > 0.0
+ }
+
+ /// The same look with no crop or hidden box: for frames those were
+ /// already applied to.
+ pub fn clone_without_hiding(&self) -> Look {
+ Look {
+ crop: None,
+ redact: None,
+ ..self.clone()
+ }
+ }
+
+ /// Whether anything changes the clip's own pixels (see `pixel_filters`).
+ pub fn has_pixel_filters(&self) -> bool {
+ self.pixel_filters((64, 64), 1.0, "x").is_some()
+ }
+
+ /// Filters that change the clip's own pixels (crop, a hidden box, blur,
+ /// key, colour, vignette, grain, fringes and mask), shared by the
+ /// viewer's decoder and export, for frames of `size` arriving. They leave
+ /// an RGBA frame. Some branch and join again inside; their labels start
+ /// with `prefix`, so several clips' filters can share one graph.
+ pub fn pixel_filters(&self, size: (u32, u32), scale: f32, prefix: &str) -> Option {
+ let (mut filters, size) = self.hiding_filters(size, prefix);
+ filters.extend(self.finishing_filters(size, scale, prefix));
+ (!filters.is_empty()).then(|| {
+ filters.push("format=rgba".into());
+ filters.join(",")
+ })
}
- /// Filters that change the clip's own pixels (crop, colour, key and
- /// mask), shared by the viewer's decoder and export. They leave an RGBA frame.
- pub fn pixel_filters(&self) -> Option {
+ /// The filters that cut a picture down and hide part of it (its crop
+ /// and hidden box), and the size it is then. A backdrop is made from
+ /// what these leave, so nothing hidden shows behind the picture.
+ pub fn hiding_filters(&self, size: (u32, u32), prefix: &str) -> (Vec, (u32, u32)) {
let mut filters = Vec::new();
+ let mut size = size;
// Cut first, so a key and a mask work on what remains.
if let Some(crop) = self.crop.filter(|c| !c.is_none()) {
filters.push(crop.ffmpeg());
+ let kept = crop.size((size.0 as f64, size.1 as f64));
+ size = ((kept.0 as u32 / 2 * 2).max(2), (kept.1 as u32 / 2 * 2).max(2));
+ }
+ if let Some(redact) = self.redact {
+ let (x, y, w, h) = redact.pixels(size);
+ // Too small to blur or pixelate (as at a tiny preview): blacked out.
+ let style = if w.min(h) < 16 {
+ RedactStyle::Black
+ } else {
+ redact.style
+ };
+ filters.push(match style {
+ RedactStyle::Black => format!("drawbox=x={x}:y={y}:w={w}:h={h}:color=black:t=fill"),
+ style => {
+ let hide = match style {
+ RedactStyle::Pixelate => {
+ let block = (w.min(h) / 8).max(4);
+ format!("pixelize=w={block}:h={block}")
+ }
+ _ => {
+ let r = (w.min(h) / 5).clamp(2, w.min(h) / 4);
+ format!("boxblur=lr={r}:lp=3:cr={}:cp=3", (r / 2).max(1))
+ }
+ };
+ format!(
+ "split[{prefix}r0][{prefix}r1];[{prefix}r1]crop={w}:{h}:{x}:{y},{hide}[{prefix}r2];\
+ [{prefix}r0][{prefix}r2]overlay={x}:{y}"
+ )
+ }
+ });
+ }
+ (filters, size)
+ }
+
+ /// The rest: blur, key, colour, vignette, grain, fringes and mask, on a
+ /// frame of `size`; `scale` is that size over the media's own (grain is
+ /// finer on a smaller frame, so it looks the same once scaled).
+ fn finishing_filters(&self, size: (u32, u32), scale: f32, prefix: &str) -> Vec {
+ let mut filters = Vec::new();
+ let fx = self.fx.clamped();
+ if fx.blur > 0.0 {
+ let r = blur_radius(fx.blur, size);
+ if r > 0 {
+ filters.push(format!("boxblur=lr={r}:lp=2:cr={}:cp=2", (r / 2).max(1)));
+ }
}
// Colour before the key would change what counts as green: key first.
let graded = self.grade.ffmpeg();
@@ -1045,6 +1320,24 @@ impl Look {
if let Some(grade) = graded {
filters.push(grade);
}
+ if fx.vignette > 0.0 {
+ // vignette drops transparency: darken the colour, keep the alpha.
+ let angle = 0.25 + 0.75 * fx.vignette;
+ filters.push(format!(
+ "format=rgba,split[{prefix}v0][{prefix}v1];[{prefix}v1]alphaextract[{prefix}va];\
+ [{prefix}v0]vignette=angle={angle:.4},format=rgb24[{prefix}vc];[{prefix}vc][{prefix}va]alphamerge"
+ ));
+ }
+ if fx.grain > 0.0 {
+ let amount = ((6.0 + 34.0 * fx.grain) * scale.clamp(0.05, 1.0)).round().max(1.0);
+ filters.push(format!(
+ "format=rgba,noise=c0s={amount}:c1s={amount}:c2s={amount}:allf=t"
+ ));
+ }
+ if fx.glitch > 0.0 {
+ let shift = ((size.0 as f32 * 0.008 * fx.glitch).round() as i32).max(1);
+ filters.push(format!("format=rgba,rgbashift=rh={shift}:bh=-{shift}"));
+ }
if let Some(mask) = self.mask {
filters.push("format=rgba".into());
filters.push(format!(
@@ -1052,10 +1345,7 @@ impl Look {
mask.ffmpeg()
));
}
- (!filters.is_empty()).then(|| {
- filters.push("format=rgba".into());
- filters.join(",")
- })
+ filters
}
}
@@ -1123,7 +1413,7 @@ mod tests {
crop: Some(crop),
..Look::default()
};
- assert!(look.pixel_filters().unwrap().starts_with("crop="));
+ assert!(look.pixel_filters((400, 200), 1.0, "t").unwrap().starts_with("crop="));
}
#[test]
@@ -1185,8 +1475,8 @@ mod tests {
mask: Some(m),
..Look::default()
};
- let f = look.pixel_filters().unwrap();
+ let f = look.pixel_filters((640, 360), 1.0, "t").unwrap();
assert!(f.starts_with("chromakey=0x00ff00:") && f.contains("despill=type=green") && f.contains("geq="));
- assert!(Look::default().pixel_filters().is_none());
+ assert!(Look::default().pixel_filters((640, 360), 1.0, "t").is_none());
}
}
diff --git a/crates/splice-core/src/model.rs b/crates/splice-core/src/model.rs
index bd46b88..5d6bf47 100644
--- a/crates/splice-core/src/model.rs
+++ b/crates/splice-core/src/model.rs
@@ -12,7 +12,7 @@ use std::path::PathBuf;
/// Version of the project JSON. Bump it and add a step to `migrate` when the
/// shape changes.
-pub const SCHEMA: u32 = 6;
+pub const SCHEMA: u32 = 7;
/// How long a still image runs when it is first placed.
pub const IMAGE_DURATION: Time = 5 * SECOND;
@@ -62,6 +62,18 @@ pub struct Media {
/// (x, y, width, height), so it is picked and outlined by what it shows.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<[f32; 4]>,
+ /// For a screen recording: where and when the mouse was clicked.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub clicks: Vec,
+}
+
+/// A mouse click in a screen recording: when (source time), and where, as
+/// fractions of the frame from its top left.
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Click {
+ pub at: Time,
+ pub x: f32,
+ pub y: f32,
}
/// What made media is made from.
@@ -265,6 +277,13 @@ pub struct Title {
pub background: Option,
/// A soft shadow under the letters.
pub shadow: bool,
+ /// Coming in a piece at a time: word by word, letter by letter, line by line.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub animate: Option,
+ /// When each word is said, from the clip's start (captions); words
+ /// come in (or light up) then.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub timing: Vec,
}
impl Default for Title {
@@ -279,6 +298,8 @@ impl Default for Title {
align: Align::Center,
background: None,
shadow: true,
+ animate: None,
+ timing: Vec::new(),
}
}
}
@@ -442,6 +463,10 @@ pub struct Project {
/// keeps its timing. None stretches all of it evenly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hold: Option<(Time, Time)>,
+ /// Black bars that frame the whole movie to a wider (or narrower)
+ /// shape: 2.39 for a cinema look. Drawn over everything.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub letterbox: Option,
next_id: u64,
}
@@ -460,6 +485,7 @@ impl Default for Project {
story: crate::story::Story::default(),
bible: crate::bible::Bible::default(),
hold: None,
+ letterbox: None,
next_id: 2,
}
}
@@ -1115,6 +1141,28 @@ impl Project {
points
}
+ /// The bars a letterbox draws, in canvas pixels: (top and bottom bar
+ /// height, left and right bar width); one of them is 0.
+ pub fn letterbox_bars(&self) -> Option<(u32, u32)> {
+ let aspect = self.letterbox.filter(|a| a.is_finite() && *a > 0.1)? as f64;
+ let (w, h) = (self.settings.width as f64, self.settings.height as f64);
+ let shape = w / h.max(1.0);
+ if (aspect - shape).abs() < 0.01 {
+ return None;
+ }
+ Some(if aspect > shape {
+ (
+ (((h - w / aspect) / 2.0).round() as u32).min(self.settings.height / 2),
+ 0,
+ )
+ } else {
+ (
+ 0,
+ (((w - h * aspect) / 2.0).round() as u32).min(self.settings.width / 2),
+ )
+ })
+ }
+
/// Visible picture layers at `t`, bottom first.
pub fn visual_stack(&self, t: Time) -> Vec<(&Clip, Source<'_>)> {
self.tracks
@@ -1155,7 +1203,10 @@ fn migrate(value: &mut serde_json::Value) -> anyhow::Result<()> {
// Schema 2 added `look` and `generator` to clips, schema 3 `recipe` to
// media, schema 4 `story` to the project, schema 5 symbols (a recipe) and
// `placeholder` on clips, schema 6 speed, reverse, freeze, transitions,
- // colour and keyframes on clips; all default, so older projects need nothing.
+ // colour and keyframes on clips, schema 7 effects, backdrops, hidden boxes,
+ // letterboxes, titles that come in a piece at a time (and captions'
+ // word timings) and recordings' clicks; all default, so older projects
+ // need nothing.
value["schema"] = SCHEMA.into();
Ok(())
}
@@ -1178,6 +1229,7 @@ mod tests {
source: PathBuf::new(),
recipe: None,
content: None,
+ clicks: Vec::new(),
}
}
diff --git a/crates/splice-core/src/motion.rs b/crates/splice-core/src/motion.rs
index 7c26fec..121839e 100644
--- a/crates/splice-core/src/motion.rs
+++ b/crates/splice-core/src/motion.rs
@@ -147,6 +147,13 @@ pub struct Gradient {
/// Degrees: 0 runs left to right, 90 top to bottom.
#[serde(default)]
pub angle: f64,
+ /// From the shape's centre out to its edge, instead of across it.
+ #[serde(default, skip_serializing_if = "is_false")]
+ pub radial: bool,
+}
+
+fn is_false(v: &bool) -> bool {
+ !*v
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -810,7 +817,7 @@ anything not drawn stays see-through.
Layer: { type: rect|ellipse|line|path|text|group, name?, x, y (its centre, canvas pixels or \"50%\"; inside a group, from \
the group), width, height, radius (rect corners), from/to [x, y] (a line's ends, from its x, y), d (SVG path data), \
text, font (any installed family), size (pixels), bold, italic, align (left|center|right: which end x names), \
-fill (\"#rrggbb[aa]\" or { stops: [[0, c], [1, c]], angle }), stroke (colour), strokeWidth, opacity, scale, \
+fill (\"#rrggbb[aa]\" or { stops: [[0, c], [1, c]], angle } or { stops, radial: true } from the centre out), stroke (colour), strokeWidth, opacity, scale, \
rotate (degrees), shadow { blur, x, y, color }, children (a group's or a shape's, placed from its centre) }.
Any number can change over time: keyframes [[seconds, value], [seconds, value, \"ease\"]].
Eases: linear, in/out/inOut + Quad|Cubic|Quart|Expo|Sine|Back|Elastic|Bounce (e.g. outBack).
diff --git a/crates/splice-core/src/store.rs b/crates/splice-core/src/store.rs
index 6d4328b..ddd947a 100644
--- a/crates/splice-core/src/store.rs
+++ b/crates/splice-core/src/store.rs
@@ -378,6 +378,7 @@ mod tests {
source: source.clone(),
recipe: None,
content: None,
+ clicks: Vec::new(),
});
project.append_clip(media);
diff --git a/crates/splice-core/src/story.rs b/crates/splice-core/src/story.rs
index d601aa5..b405bba 100644
--- a/crates/splice-core/src/story.rs
+++ b/crates/splice-core/src/story.rs
@@ -698,6 +698,7 @@ mod tests {
source: PathBuf::new(),
recipe: None,
content: None,
+ clicks: Vec::new(),
});
(p, m)
}
diff --git a/crates/splice-core/src/symbol.rs b/crates/splice-core/src/symbol.rs
index 34764ce..ac39dce 100644
--- a/crates/splice-core/src/symbol.rs
+++ b/crates/splice-core/src/symbol.rs
@@ -754,6 +754,7 @@ mod tests {
source: Default::default(),
recipe: None,
content: None,
+ clicks: Vec::new(),
}
}
diff --git a/crates/splice-core/src/textmotion.rs b/crates/splice-core/src/textmotion.rs
new file mode 100644
index 0000000..d4459e3
--- /dev/null
+++ b/crates/splice-core/src/textmotion.rs
@@ -0,0 +1,318 @@
+//! Titles that come in a piece at a time: word by word, letter by letter
+//! or line by line, fading, rising, popping or typed; or, for captions,
+//! words lighting up as they are said.
+//!
+//! The app draws a title's letters apart from its box and says where each
+//! word, letter and line is in that picture (`Unit`). Here each of those
+//! pieces gets its own pose over clip time, built on the title's, so the
+//! viewer and export move them the same way.
+
+use crate::look::build::{C, T, add, clip01, div, mul, smooth, sub};
+use crate::look::{Expr, PoseExpr};
+use crate::time::{SECOND, Time, secs};
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Style {
+ /// Each piece fades in.
+ #[default]
+ Fade,
+ /// Fades in rising a little.
+ Rise,
+ /// Grows in with a small overshoot.
+ Pop,
+ /// Appears at once, as if typed.
+ Type,
+ /// Words wait dimmed and light up as they are said.
+ Karaoke,
+}
+
+impl Style {
+ pub const ALL: [Style; 5] = [Style::Fade, Style::Rise, Style::Pop, Style::Type, Style::Karaoke];
+
+ pub fn name(self) -> &'static str {
+ match self {
+ Style::Fade => "fade",
+ Style::Rise => "rise",
+ Style::Pop => "pop",
+ Style::Type => "type",
+ Style::Karaoke => "karaoke",
+ }
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Style::Fade => "Fade",
+ Style::Rise => "Rise",
+ Style::Pop => "Pop",
+ Style::Type => "Type",
+ Style::Karaoke => "Karaoke",
+ }
+ }
+
+ pub fn parse(name: &str) -> Option